From 947743cc9e900334d805ffe62ad30a6beb535794 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:13:06 +0100 Subject: [PATCH 01/36] feat: connect the desktop app's tray, badge, notifications, files and settings to the webmail --- app/(main)/[locale]/layout.tsx | 2 + components/desktop/desktop-os-host.tsx | 101 +++++ components/email/email-composer.tsx | 4 + components/email/email-viewer.tsx | 1 + components/email/reader-attachments.tsx | 26 +- components/mail/mail-app.tsx | 12 + .../protocol-launch-handler-provider.tsx | 16 + components/settings/desktop-os-settings.tsx | 120 +++++ components/settings/general-settings.tsx | 3 + .../__tests__/use-email-drag-routing.test.tsx | 29 ++ hooks/__tests__/use-new-mail-alerts.test.tsx | 8 + hooks/use-app-badge.ts | 9 + hooks/use-attachment-drag.ts | 16 + hooks/use-email-drag.ts | 13 + hooks/use-new-mail-alerts.ts | 4 +- hooks/use-policy-refresh.ts | 7 +- lib/desktop/__tests__/os-commands.test.ts | 245 ++++++++++ lib/desktop/__tests__/os-mail.test.ts | 338 ++++++++++++++ lib/desktop/__tests__/os-window.test.tsx | 425 ++++++++++++++++++ lib/desktop/app-lock-state.ts | 19 + lib/desktop/file-drag.ts | 55 +++ lib/desktop/mail-links.ts | 44 ++ lib/desktop/mail-notifications.ts | 184 ++++++++ lib/desktop/new-mail.ts | 149 ++++++ lib/desktop/os-commands.ts | 181 ++++++++ lib/desktop/os-summary.ts | 130 ++++++ lib/desktop/power.ts | 70 +++ lib/desktop/types.ts | 117 +++++ lib/desktop/window-files.ts | 66 +++ lib/pwa-notify/__tests__/page-actions.test.ts | 38 +- lib/pwa-notify/page-actions.ts | 22 +- locales/ar/common.json | 30 ++ locales/ca/common.json | 30 ++ locales/cs/common.json | 30 ++ locales/da/common.json | 30 ++ locales/de/common.json | 30 ++ locales/en/common.json | 30 ++ locales/es/common.json | 30 ++ locales/et/common.json | 30 ++ locales/fa/common.json | 30 ++ locales/fr/common.json | 30 ++ locales/he/common.json | 30 ++ locales/hu/common.json | 30 ++ locales/it/common.json | 30 ++ locales/ja/common.json | 30 ++ locales/ko/common.json | 30 ++ locales/lv/common.json | 30 ++ locales/mn/common.json | 30 ++ locales/nb/common.json | 30 ++ locales/nl/common.json | 30 ++ locales/pl/common.json | 30 ++ locales/pt/common.json | 30 ++ locales/ro/common.json | 30 ++ locales/ru/common.json | 30 ++ locales/sk/common.json | 30 ++ locales/th/common.json | 30 ++ locales/tr/common.json | 30 ++ locales/uk/common.json | 30 ++ locales/vi/common.json | 30 ++ locales/zh-TW/common.json | 30 ++ locales/zh/common.json | 30 ++ 61 files changed, 3348 insertions(+), 6 deletions(-) create mode 100644 components/desktop/desktop-os-host.tsx create mode 100644 components/settings/desktop-os-settings.tsx create mode 100644 lib/desktop/__tests__/os-commands.test.ts create mode 100644 lib/desktop/__tests__/os-mail.test.ts create mode 100644 lib/desktop/__tests__/os-window.test.tsx create mode 100644 lib/desktop/app-lock-state.ts create mode 100644 lib/desktop/file-drag.ts create mode 100644 lib/desktop/mail-links.ts create mode 100644 lib/desktop/mail-notifications.ts create mode 100644 lib/desktop/new-mail.ts create mode 100644 lib/desktop/os-commands.ts create mode 100644 lib/desktop/os-summary.ts create mode 100644 lib/desktop/power.ts create mode 100644 lib/desktop/window-files.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..fca72c365 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -28,6 +28,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; +import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -85,6 +86,7 @@ export default async function LocaleLayout({ + {!IS_LITE && } diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx new file mode 100644 index 000000000..c8d2755d8 --- /dev/null +++ b/components/desktop/desktop-os-host.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { useArchiveActionEnabled } from "@/lib/archive-gate"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; +import { startOsSummary } from "@/lib/desktop/os-summary"; +import { startPowerWatch } from "@/lib/desktop/power"; +import { startWindowFiles } from "@/lib/desktop/window-files"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { calendarHooks } from "@/lib/plugin-hooks"; +import type { CalendarAlertView } from "@/lib/plugin-types"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useSettingsStore } from "@/stores/settings-store"; + +/** + * The main window's end of the desktop app's OS integration: new mail as native + * notifications, what the tray and jump list show, waking up after sleep, and files + * dropped or pasted on the window. Renders nothing, and does nothing in a browser. + * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time. + */ +export function DesktopOsHost() { + const t = useTranslations("desktopOs"); + const tNotify = useTranslations("notifications"); + const tPush = useTranslations("pwaNotify"); + const tCalendar = useTranslations("calendar.notifications"); + const locale = useLocale(); + const archiveEnabled = useArchiveActionEnabled(); + const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const mailRef = useRef(null); + const summaryRef = useRef<{ refresh: () => void } | null>(null); + const localeRef = useRef(locale); + localeRef.current = locale; + + const labels: MailNotificationLabels = { + newMail: t("new_mail"), + newMailCount: (count) => tNotify("new_mail_count", { count }), + noSubject: t("no_subject"), + archive: archiveEnabled ? tPush("action_archive") : null, + markRead: tPush("action_mark_read"), + delete: t("action_delete"), + reply: tPush("action_reply"), + replyPlaceholder: tPush("reply_placeholder"), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + + useEffect(() => { + if (!getDesktopBridge()?.os) return; + const mail = startMailNotifications(labelsRef.current); + mailRef.current = mail; + const summary = startOsSummary(() => localeRef.current); + summaryRef.current = summary; + const stopPower = startPowerWatch(); + const stopFiles = startWindowFiles(); + // Reminders' own native notification (snooze, join) comes with the reminders work. + // Until then a reminder must not fire into a hidden window unseen. + const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => { + const { calendarNotificationsEnabled } = useSettingsStore.getState(); + if (!calendarNotificationsEnabled) return; + const event = "eventId" in view; + void getDesktopBridge()?.os?.notify({ + id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, + title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), + body: "", + context: "{}", + }); + }); + return () => { + mail.stop(); + summary.stop(); + stopPower(); + stopFiles(); + reminders.dispose(); + mailRef.current = null; + summaryRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + mailRef.current?.setLabels(labels); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locale, archiveEnabled]); + + // Every login needs a state to work from before its first push. + useEffect(() => { + const mail = mailRef.current; + if (!mail || !isAuthenticated) return; + const auth = useAuthStore.getState(); + const clients = new Set(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? [])); + if (auth.client) clients.add(auth.client); + for (const client of clients) mail.seed(client); + summaryRef.current?.refresh(); + }, [isAuthenticated, clientKey]); + + return null; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..31341f1db 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -168,6 +168,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; +import { setComposerFileTarget } from "@/lib/desktop/window-files"; /** * Derives the text/plain alternative from the composer's HTML body, preserving @@ -2642,6 +2643,9 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); + // The desktop app: files dropped or pasted anywhere on the window come here. + useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]); + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..09c925b5e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -677,6 +677,7 @@ interface DraggableAttachmentChipProps { onPointerEnter: () => void; onDragStart: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; + onCopyAsFile?: () => Promise; }) => React.ReactNode; } diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..c92d2feca 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -2,12 +2,13 @@ import { useState, type DragEvent, type ReactNode } from "react"; import { useTranslations } from "next-intl"; -import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; +import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { cn, formatFileSize } from "@/lib/utils"; import { noMediaSavingProps } from "@/lib/device-frame"; +import { toast } from "@/stores/toast-store"; import { attachmentIcon } from "./attachment-chips"; export interface ReaderAttachmentItem { @@ -26,6 +27,8 @@ export interface AttachmentDragProps { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app only. */ + onCopyAsFile?: () => Promise; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ @@ -159,6 +162,7 @@ export function ReaderAttachments({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( ({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( onDownload(attachment)}> @@ -332,6 +337,25 @@ export function ReaderAttachments({ * The image itself when there is a thumbnail, else the file type's icon over * its extension, in the type's colour. */ +/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */ +function CopyAsFileButton({ copy }: { copy: () => Promise }) { + const t = useTranslations("desktopOs"); + return ( + { + void copy().then((ok) => { + if (ok) toast.success(t("copied")); + else toast.error(t("copy_failed")); + }, () => toast.error(t("copy_failed"))); + }} + > + + + ); +} + function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d475f4f2c..632c09b4f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -143,6 +143,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; +import { setMailLinkOpener } from "@/lib/desktop/mail-links"; import { requestTabReply } from "@/lib/pwa-notify/tab-reply"; import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff"; @@ -2922,6 +2923,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); + // The desktop app's tray, jump list and toasts, like any other permalink. A live + // composer's text goes through its own "Save or discard?" first. + useEffect(() => { + return setMailLinkOpener((link, options) => { + const open = () => void applyMailDeepLinkRef.current(link, options); + const requestClose = composerRequestCloseRef.current; + if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, []); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index d3d8f4942..0078cd8bf 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { startOsCommands } from "@/lib/desktop/os-commands"; import { flushComposers } from "@/lib/compose-flush-registry"; import { isPopoutPath } from "@/lib/popout/window-role"; import { isStandaloneDisplayMode } from "@/lib/standalone-display"; @@ -201,6 +202,21 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle return subscribeHandoff("any", goToTarget); }, [pathname, leaveFor]); + // The desktop app: what the OS asks for arrives over the bridge, not launchQueue. + const osEnv = useRef({ leaveFor, push: router.push as (path: string) => void, pathname }); + osEnv.current = { leaveFor, push: router.push as (path: string) => void, pathname }; + useEffect(() => { + if (isPopoutPath(window.location.pathname)) return; + return startOsCommands({ + leaveFor: (path) => osEnv.current.leaveFor(path), + push: (path) => { + if (osEnv.current.pathname !== path) osEnv.current.push(path); + }, + notifyFailed: () => toast.error(tLaunch("open_failed")), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx new file mode 100644 index 000000000..1703487e6 --- /dev/null +++ b/components/settings/desktop-os-settings.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */ +const SHOW_SHORTCUT = "CommandOrControl+Shift+B"; + +function shortcutLabel(platform: string | undefined): string { + return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B"; +} + +/** + * The desktop app's own settings, on the General page: only where there's an app to + * set. A browser never sees it, and it renders nothing until the bridge has answered. + * Quick compose adds its shortcut here later. + */ +export function DesktopOsSettings() { + const t = useTranslations("desktopOs"); + const [status, setStatus] = useState(null); + const [defaultApp, setDefaultApp] = useState(null); + const bridge = getDesktopBridge(); + const os = bridge?.os; + + useEffect(() => { + if (!os) return; + let stale = false; + void os.getStatus().then((next) => { + if (!stale) setStatus(next); + }); + void os.defaultAppStatus().then((next) => { + if (!stale) setDefaultApp(next); + }); + const stop = os.onStatusChange(setStatus); + return () => { + stale = true; + stop(); + }; + }, [os]); + + if (!os || !status) return null; + const { settings } = status; + const change = (patch: Partial) => { + void os.setSettings(patch).then(setStatus); + }; + + return ( + + + change({ closeToTray })} /> + + + change({ startAtLogin })} /> + + {settings.startAtLogin && ( + + change({ startMinimized })} /> + + )} + + change({ attentionOnNewMail })} /> + + + change({ quietHoursEnabled })} /> + + {settings.quietHoursEnabled && ( + +
+ change({ quietHoursStart: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> + {t("quiet_hours_to")} + change({ quietHoursEnd: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> +
+
+ )} + + change({ showShortcut: on ? SHOW_SHORTCUT : null })} + /> + + + + +
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..3493681ca 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; +import { DesktopOsSettings } from './desktop-os-settings'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; @@ -24,6 +25,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+ + {!IS_LITE && } diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index e53285031..242f40e5f 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -731,4 +731,33 @@ describe('message row drag hover wiring', () => { expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/); expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB)); }); + + it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => { + const email = mail({ subject: 'Hello world' }); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); + const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; + vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!)))); + try { + startDrag.mockClear(); + // Plain: still a move to a folder, still DownloadURL for Explorer. + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true); + expect(startDrag).toHaveBeenCalled(); + expect(nativeDrag).not.toHaveBeenCalled(); + + startDrag.mockClear(); + const preventDefault = vi.fn(); + const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' }; + act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent)); + expect(preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') }); + const [files] = nativeDrag.mock.calls[0]; + expect(files[0].name).toBe('Hello world.eml'); + expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA)); + } finally { + window.bulwarkDesktop = undefined; + } + }); }); diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx index c98b51dca..40c1fcee7 100644 --- a/hooks/__tests__/use-new-mail-alerts.test.tsx +++ b/hooks/__tests__/use-new-mail-alerts.test.tsx @@ -39,6 +39,14 @@ describe('new mail alerts', () => { vi.useRealTimers(); }); + it('in the desktop app leaves the sound to the notification path, so it plays once', () => { + window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never; + renderHook(() => useNewMailAlerts()); + arrive('a'); + expect(playNotificationSound).not.toHaveBeenCalled(); + window.bulwarkDesktop = undefined; + }); + it('tells screen readers once how many messages came, never what they say', () => { renderHook(() => useNewMailAlerts()); arrive('a'); diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts index 927a869b5..d8a33b320 100644 --- a/hooks/use-app-badge.ts +++ b/hooks/use-app-badge.ts @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { debug } from '@/lib/debug'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isPopoutWindow } from '@/lib/popout/window-role'; /** * Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to @@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void { const effectiveCount = enabled ? count : 0; + // The desktop app's own icon. A pop-out has its own account and no say in it. + const desktopOs = getDesktopBridge()?.os; + if (desktopOs) { + if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount); + return; + } + void (async () => { try { if (effectiveCount > 0) { diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index 83cdce627..ce0047251 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, DragEvent } from "react"; +import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag"; // Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on // drop to materialize a real file. Firefox and Safari ignore it, so we only @@ -40,6 +41,8 @@ export interface UseAttachmentDragResult { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */ + onCopyAsFile?: () => Promise; } const NOOP_HANDLERS: UseAttachmentDragResult = { @@ -104,6 +107,13 @@ export function useAttachmentDrag( return; } + // In the desktop app the OS's own drag, with the real file on disk first. + if (canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name, url }]); + return; + } + // `DownloadURL` format: ::. The filename must be // raw - URL-encoding it lands literally on disk (`%20` instead of a // space). Callers are expected to sanitise reserved chars (`:` etc.) @@ -125,6 +135,11 @@ export function useAttachmentDrag( } }, []); + const copyAsFile = useCallback(async () => { + const url = urlRef.current ?? await source.getBlobUrl(); + return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false; + }, [source]); + if (!enabled) return NOOP_HANDLERS; return { @@ -132,5 +147,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, + ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 25450a031..b60a4e910 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; +import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -295,6 +296,13 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const url = singleRef.current?.key === route.key ? singleRef.current.url : null; if (url) { const name = emailExportFilename(emailsToDrag[0], filenameOptions); + // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has + // to stay the page's, because it also moves the message to a folder. + if (e.altKey && canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name, url }]); + return; + } // `DownloadURL` format: ::. Chromium expects // the filename raw - URL-encoding it ends up literally on disk // (e.g. `%20` instead of a space). The sanitiser already removed @@ -313,6 +321,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { + if (e.altKey && canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name: ready.name, url: ready.url }]); + return; + } e.dataTransfer.setData( "DownloadURL", `application/zip:${ready.name}:${ready.url}`, diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts index 0b118372c..b2fd6801f 100644 --- a/hooks/use-new-mail-alerts.ts +++ b/hooks/use-new-mail-alerts.ts @@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound"; import { announce } from "@/lib/announce"; import { startTitleAlert } from "@/lib/title-alert"; import { debug } from "@/lib/debug"; +import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications"; /** * Mail that arrives within this time is told about once ("3 new messages"), @@ -51,7 +52,8 @@ export function useNewMailAlerts(): void { const s = useSettingsStore.getState(); if (s.doNotDisturb) return; if (batchTimer === null) { - if (s.emailNotificationsEnabled && s.emailNotificationSound) { + // The desktop app sounds it with the notification, in Do Not Disturb not at all. + if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) { playNotificationSound(s.notificationSoundChoice); } batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS); diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts index 3ae942946..f7115e422 100644 --- a/hooks/use-policy-refresh.ts +++ b/hooks/use-policy-refresh.ts @@ -1,6 +1,7 @@ 'use client'; import { useEffect } from 'react'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import { usePolicyStore } from '@/stores/policy-store'; /** How often an open, visible tab fetches the policy again. */ @@ -13,7 +14,9 @@ let mounted = 0; let interval: ReturnType | null = null; function refresh(force = false): void { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + // A browser tab out of view waits until it's back. The desktop app in the tray is + // out of view for days and still has to hear about a wipe or a changed policy. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return; const now = Date.now(); if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return; const state = usePolicyStore.getState(); @@ -27,7 +30,7 @@ const onVisible = () => refresh(); /** * Fetches the policy again when the tab comes back into view and every 15 - * minutes while it is visible, so an announcement (or any other change the + * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the * admin saves) reaches tabs that stay open. One timer for the page however * many components use it. */ diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts new file mode 100644 index 000000000..465180c64 --- /dev/null +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types'; +import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions'; + +/** What the OS asks for, carried out by the parts the app already has. */ + +const runNotificationAction = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/pwa-notify/page-actions', () => ({ runNotificationAction })); + +const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }]; +let authenticated = true; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ isAuthenticated: authenticated }), + subscribe: () => () => {}, + }, +})); + +const session = vi.hoisted(() => ({ + savePendingMailto: vi.fn(), + notifyPendingMailto: vi.fn(), + savePendingWebcal: vi.fn(), + notifyPendingWebcal: vi.fn(), +})); +vi.mock('@/lib/protocol-handlers/session', () => session); + +async function fingerprint() { + const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint'); + return loginFingerprint('https://one.example', 'alice@example.org'); +} + +const leaveFor = vi.fn(); +const push = vi.fn(); +const notifyFailed = vi.fn(); +const env = { leaveFor, push, notifyFailed }; +const focus = vi.fn(); + +async function run(command: DesktopOsCommand) { + const { handleOsCommand } = await import('../os-commands'); + await handleOsCommand(command, env); +} + +const text = (value: string) => new TextEncoder().encode(value); + +beforeEach(async () => { + vi.clearAllMocks(); + authenticated = true; + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as unknown as BulwarkDesktopBridge; + const { resetMailLinks } = await import('../mail-links'); + resetMailLinks(); + const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); + clearHandoffs(); + for (const kind of ['compose', 'eml', 'calendar', 'contacts'] as const) takeHandoff(kind); +}); + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('launches', () => { + it('New message goes through the launch handoff, with no question asked', async () => { + const { subscribeHandoff } = await import('@/lib/launch-handoff'); + const fired = vi.fn(); + const stop = subscribeHandoff('compose', fired); + await run({ type: 'new-message' }); + expect(fired).toHaveBeenCalled(); + stop(); + }); + + it('a mailto goes through the protocol handler to the mail page', async () => { + await run({ type: 'mailto', url: 'mailto:bob@example.org?subject=Hi&body=Text' }); + expect(session.savePendingMailto).toHaveBeenCalledTimes(1); + expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ to: ['bob@example.org'], subject: 'Hi' }); + expect(session.notifyPendingMailto).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/'); + }); + + it('a mailto that is not one does nothing', async () => { + await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) }); + expect(push).not.toHaveBeenCalledWith('/calendar'); + }); + + it('a webcal goes to the calendar', async () => { + await run({ type: 'webcal', url: 'webcal://example.org/cal.ics' }); + expect(session.savePendingWebcal).toHaveBeenCalledTimes(1); + expect(session.notifyPendingWebcal).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/calendar'); + }); + + it('a double-clicked .eml, .ics and .vcf each reach their handler', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ + type: 'files', + reason: 'open', + failed: [], + files: [ + { name: 'letter.eml', data: text('Subject: x') }, + { name: 'party.ics', data: text('BEGIN:VCALENDAR') }, + { name: 'bob.vcf', data: text('BEGIN:VCARD') }, + ], + }); + expect(await takeHandoff('eml')?.files[0].text()).toBe('Subject: x'); + expect(takeHandoff('calendar')?.files[0].name).toBe('party.ics'); + expect(takeHandoff('contacts')?.files[0].name).toBe('bob.vcf'); + expect(notifyFailed).not.toHaveBeenCalled(); + }); + + it('a file dropped on the icon becomes an attachment on a new draft', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] }); + const draft = takeHandoff('compose'); + expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']); + }); + + it('says so when a file could not be read', async () => { + await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] }); + expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']); + }); +}); + +describe('going somewhere', () => { + it('Calendar and Contacts leave through the save-first path', async () => { + await run({ type: 'navigate', target: 'calendar' }); + expect(leaveFor).toHaveBeenCalledWith('/calendar'); + }); + + it('Inbox opens the folder on the mail page when it is mounted', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'navigate', target: 'inbox' }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + expect(leaveFor).not.toHaveBeenCalled(); + stop(); + }); + + it('with the mail page not mounted the link waits for it, and the app goes there', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + await run({ type: 'open-message', emailId: 'e1', accountId: 'acc' }); + expect(leaveFor).toHaveBeenCalledWith('/'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + expect(opener).toHaveBeenCalledWith({ kind: 'message', id: 'e1', ownerAccountId: 'acc' }, undefined); + stop(); + }); +}); + +describe('a toast\'s buttons', () => { + const context = async (over: object = {}) => JSON.stringify({ emailId: 'e1', threadId: 't1', accountId: 'acc', slot: 3, login: await fingerprint(), ...over }); + const finishWith = (status: NotificationActionStatus) => { + runNotificationAction.mockImplementation(async (_request: NotificationActionRequest, answer: (s: NotificationActionStatus) => void) => { + answer('accepted'); + answer(status); + }); + }; + + it.each(['archive', 'mark-read', 'delete'] as const)('%s runs through the page\'s notification action and stays out of sight', async (action) => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + finishWith('done'); + await run({ type: 'notification', action, context: await context() }); + expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action, emailId: 'e1', accountId: 'acc', slot: 3, login: await fingerprint() }); + expect(opener).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + stop(); + }); + + it('a button that can\'t be carried out opens the message instead', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + finishWith('failed'); + await run({ type: 'notification', action: 'archive', context: await context() }); + expect(focus).toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith(expect.objectContaining({ kind: 'message', id: 'e1', slot: 3 }), undefined); + stop(); + }); + + it('a reply opens the composer with the typed text, and is not sent', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'reply', context: await context(), reply: 'On my way' }); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith( + { kind: 'message', id: 'e1', slot: 3, ownerAccountId: 'acc', reply: true }, + { replyText: 'On my way' }, + ); + stop(); + }); + + it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); + + it('a click on a summary toast opens the Inbox of that login', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined); + stop(); + }); + + it('junk in the context does no harm', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'archive', context: '{not json' }); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); +}); + +describe('listening', () => { + it('does nothing in a browser', async () => { + window.bulwarkDesktop = undefined; + const { startOsCommands } = await import('../os-commands'); + expect(() => startOsCommands(env)()).not.toThrow(); + }); + + it('subscribes, and carries out what arrives', async () => { + let listener: ((command: DesktopOsCommand) => void) | null = null; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } }, + } as unknown as BulwarkDesktopBridge; + const { startOsCommands } = await import('../os-commands'); + const stop = startOsCommands(env); + listener!({ type: 'navigate', target: 'contacts' }); + await Promise.resolve(); + expect(leaveFor).toHaveBeenCalledWith('/contacts'); + stop(); + expect(listener).toBeNull(); + }); +}); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts new file mode 100644 index 000000000..edfad4809 --- /dev/null +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +/** New mail to a native notification: which messages count, what the toast may say. */ + +const playNotificationSound = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound })); + +let pushWithoutContent = false; +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) })); + +const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }]; +let client: IJMAPClient; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId: 'alice@one', + client, + getClientForAccount: () => client, + }), + }, +})); + +const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox; +const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox; +const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox; +const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox; +const MAILBOXES = [INBOX, SENT, NEWS, JUNK]; + +function email(id: string, over: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + subject: `Secret ${id}`, + preview: 'preview text', + from: [{ name: 'Alice Sender', email: 'sender@example.org' }], + mailboxIds: { in: true }, + keywords: {}, + receivedAt: new Date().toISOString(), + ...over, + } as unknown as Email; +} + +function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) { + const queue = [...changes]; + return { + getAccountId: () => 'acc', + getAllMailboxes: vi.fn(async () => MAILBOXES), + getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })), + getEmailChanges: vi.fn(async (since: string) => { + const next = queue.shift(); + return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null; + }), + getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])), + } as unknown as IJMAPClient; +} + +const labels = { + newMail: 'new_mail', + newMailCount: (count: number) => `count:${count}`, + noSubject: 'no_subject', + archive: 'archive', + markRead: 'mark_read', + delete: 'delete', + reply: 'reply', + replyPlaceholder: 'placeholder', +}; + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +beforeEach(async () => { + vi.clearAllMocks(); + pushWithoutContent = false; + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ + emailNotificationsEnabled: true, + emailNotificationSound: true, + doNotDisturb: false, + pushNotifyInboxOnly: true, + notificationMutes: { accounts: [], mailboxes: {} }, + notificationSoundChoice: 'involved', + }); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(null); +}); + +afterEach(() => { + setBridge(undefined); + vi.useRealTimers(); +}); + +describe('which new messages notify', () => { + it('a new unread message in the Inbox', async () => { + const { notifyingMailbox } = await import('../new-mail'); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in'); + }); + + it('nothing already read, a draft, or old mail coming back from an import', async () => { + const { notifyingMailbox } = await import('../new-mail'); + expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + const old = new Date(Date.now() - 3 * 86400_000).toISOString(); + expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); + + it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => { + const { notifyingMailbox } = await import('../new-mail'); + const { useSettingsStore } = await import('@/stores/settings-store'); + const filed = email('a', { mailboxIds: { news: true } }); + expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull(); + useSettingsStore.setState({ pushNotifyInboxOnly: false }); + expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news'); + expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); + + it('a muted folder or account stays silent', async () => { + const { notifyingMailbox } = await import('../new-mail'); + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } }); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull(); + useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } }); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); +}); + +describe('the watch', () => { + const flush = async () => { + for (let i = 0; i < 20; i += 1) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + it('resolves a push against its baseline, whatever page is open', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background'); + await flush(); + expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200); + expect(seen).toEqual(['a']); + watch.stop(); + }); + + it('tells a message once when the store and the push both report it', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + watch.noteCreated(fake, 'acc', ['a']); + await flush(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(seen).toEqual(['a']); + watch.stop(); + }); + + it('without a baseline the first push only sets one: old mail is not news', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({}, []); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active'); + await flush(); + expect(fake.getEmailChanges).not.toHaveBeenCalled(); + expect(onMail).not.toHaveBeenCalled(); + watch.stop(); + }); + + it('ignores a change after it stopped', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + watch.seed(fake); + await flush(); + watch.stop(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(onMail).not.toHaveBeenCalled(); + }); +}); + +describe('what the notification says', () => { + const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) }); + + beforeEach(() => { + client = fakeClient({}); + }); + + it('sender, subject, the buttons, and what a button needs after a restart', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint'); + const notification = await buildMailNotification([one()], labels); + expect(notification.title).toBe('Alice Sender'); + expect(notification.body).toBe('Secret a'); + expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']); + expect(notification.reply?.label).toBe('reply'); + expect(JSON.parse(notification.context)).toEqual({ + accountId: 'acc', + slot: 3, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + emailId: 'a', + threadId: 't-a', + }); + }); + + it('no Archive button where the organisation has it off', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const notification = await buildMailNotification([one()], { ...labels, archive: null }); + expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']); + }); + + it('while the app is locked: no sender, no subject, no buttons', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(() => true); + const notification = await buildMailNotification([one()], labels); + const text = JSON.stringify(notification); + expect(notification.title).toBe('new_mail'); + expect(text).not.toContain('Alice Sender'); + expect(text).not.toContain('Secret'); + expect(notification.actions).toBeUndefined(); + expect(notification.reply).toBeUndefined(); + }); + + it('where the organisation keeps content off notifications: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + pushWithoutContent = true; + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + + it('several at once are told about once, and that one has no message to act on', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels); + expect(notification.title).toBe('count:3'); + expect(notification.actions).toBeUndefined(); + expect(JSON.parse(notification.context).emailId).toBeUndefined(); + }); + + it('several at once while locked say only how many', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(() => true); + const notification = await buildMailNotification([one('a'), one('b')], labels); + expect(notification.title).toBe('count:2'); + expect(notification.body).toBe(''); + }); +}); + +describe('from arrival to toast and sound', () => { + /** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */ + const settle = async () => { + const end = performance.now() + 80; + while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve)); + }; + + async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) { + const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true }); + setBridge({ version: 1, platform: 'win32', os: { notify } as never }); + client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mail = startMailNotifications(labels); + mail.seed(client); + await vi.advanceTimersByTimeAsync(10); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active'); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + mail.stop(); + return notify; + } + + it('asks main, and plays the picked sound when main says so', async () => { + const notify = await arrive(); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(notify.mock.calls[0][0].attention).toBe(true); + expect(playNotificationSound).toHaveBeenCalledWith('involved'); + }); + + it('no sound when main says it is quiet', async () => { + const notify = await arrive({ result: { shown: true, playSound: false } }); + expect(notify).toHaveBeenCalledTimes(1); + expect(playNotificationSound).not.toHaveBeenCalled(); + }); + + it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailNotificationsEnabled: false }); + expect(await arrive()).not.toHaveBeenCalled(); + useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true }); + expect(await arrive()).not.toHaveBeenCalled(); + expect(playNotificationSound).not.toHaveBeenCalled(); + }); + + it("the store's created event reaches a running watch, and nothing else", async () => { + const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const notify = vi.fn(async () => ({ shown: true, playSound: false })); + setBridge({ version: 1, platform: 'win32', os: { notify } as never }); + client = fakeClient({ a: email('a') }); + noteStoreCreated(client, 'acc', ['a']); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mail = startMailNotifications(labels); + noteStoreCreated(client, 'acc', ['a']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + mail.stop(); + noteStoreCreated(client, 'acc', ['b']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('tells main the sound is off when the user has it off', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailNotificationSound: false }); + const notify = await arrive({ result: { shown: true, playSound: false } }); + expect(notify.mock.calls[0][0].sound).toBe(false); + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx new file mode 100644 index 000000000..301ee9abc --- /dev/null +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -0,0 +1,425 @@ +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */ + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', +})); + +function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') { + window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined; +} + +afterEach(() => { + setBridge(undefined); + vi.unstubAllGlobals(); +}); + +describe('the badge', () => { + it('goes to the bridge in the app, and the Badging API is left alone', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const setBadge = vi.fn(); + const setAppBadge = vi.fn(async () => {}); + Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true }); + setBridge({ setBadge }); + const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } }); + expect(setBadge).toHaveBeenLastCalledWith(4); + rerender({ count: 4, enabled: false }); + expect(setBadge).toHaveBeenLastCalledWith(0); + expect(setAppBadge).not.toHaveBeenCalled(); + }); + + it('a pop-out has no say in it', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const role = await import('@/lib/popout/window-role'); + const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true); + const setBadge = vi.fn(); + setBridge({ setBadge }); + renderHook(() => useAppBadge(4, true)); + expect(setBadge).not.toHaveBeenCalled(); + popout.mockRestore(); + }); +}); + +describe('new-mail sound', () => { + it('is left to the notification path in the app, not played twice', async () => { + const { desktopPlaysMailSound } = await import('../mail-notifications'); + expect(desktopPlaysMailSound()).toBe(false); + setBridge({ notify: vi.fn() as never }); + expect(desktopPlaysMailSound()).toBe(true); + }); +}); + +describe('the policy keeps refreshing', () => { + const fetchPolicy = vi.fn(async () => {}); + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); + fetchPolicy.mockClear(); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy }); + const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh'); + resetPolicyRefresh(); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (document as unknown as Record).visibilityState; + }); + + it('in the desktop app while hidden in the tray', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + setBridge({}); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('but not in a browser tab out of view', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).not.toHaveBeenCalled(); + unmount(); + }); +}); + +function dropEvent(type: string, files: File[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } }); + return event; +} + +describe('files on the window', () => { + const file = new File(['x'], 'report.pdf'); + + beforeEach(async () => { + const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); + clearHandoffs(); + takeHandoff('compose'); + }); + + it('nothing happens in a browser', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('a drop on the window starts a new draft with the file', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const over = dropEvent('dragover', [file]); + window.dispatchEvent(over); + expect(over.defaultPrevented).toBe(true); + const drop = dropEvent('drop', [file]); + window.dispatchEvent(drop); + expect(drop.defaultPrevented).toBe(true); + expect(takeHandoff('compose')?.files).toEqual([file]); + stop(); + }); + + it('a drop something else took is left alone', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + event.preventDefault(); + window.dispatchEvent(event); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('text dragged around is not a file', async () => { + const { startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('dragover', []); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + stop(); + }); + + it('an open composer takes the file itself', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target); + window.dispatchEvent(dropEvent('drop', [file])); + expect(target).toHaveBeenCalledWith([file]); + expect(takeHandoff('compose')).toBeNull(); + clear(); + stop(); + }); + + it('a pasted file is taken; pasted text is not', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const paste = (files: File[]) => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files } }); + window.dispatchEvent(event); + return event; + }; + expect(paste([]).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + expect(paste([file]).defaultPrevented).toBe(true); + expect(takeHandoff('compose')?.files).toEqual([file]); + stop(); + }); + + it('our own native drag over our own window is not a new file', async () => { + const { setOwnDragActive, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + setOwnDragActive(true); + window.dispatchEvent(dropEvent('drop', [file])); + expect(takeHandoff('compose')).toBeNull(); + setOwnDragActive(false); + stop(); + }); +}); + +describe('dragging an attachment out', () => { + const blobUrl = 'blob:https://app/1'; + const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl }; + + async function ready() { + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const hook = renderHook(() => useAttachmentDrag(source, true)); + act(() => hook.result.current.onPointerEnter()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return hook; + } + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3])))); + }); + + it('in the app: the OS\'s own drag, with the bytes for a real file', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const hook = await ready(); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.dataTransfer.setData).not.toHaveBeenCalled(); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]]; + expect(files[0].name).toBe('report.pdf'); + expect([...files[0].data]).toEqual([1, 2, 3]); + }); + + it('in a browser: still DownloadURL, and no copy button', async () => { + const hook = await ready(); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + + it('Copy as file hands the bytes to main', async () => { + const copyFiles = vi.fn(async () => true); + setBridge({ startDrag: vi.fn() as never, copyFiles }); + const hook = await ready(); + await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true); + const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]]; + expect(files[0].name).toBe('report.pdf'); + }); +}); + +describe('waking up', () => { + async function started(os: Partial = {}) { + let fire: (event: DesktopOsPowerEvent) => void = () => {}; + setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); + const power = await import('../power'); + return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() }; + } + + it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => { + const { fire, power, stop } = await started(); + const { usePolicyStore } = await import('@/stores/policy-store'); + const fetchPolicy = vi.fn(async () => {}); + usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy }); + const visible = vi.fn(); + const online = vi.fn(); + const wake = vi.fn(); + document.addEventListener('visibilitychange', visible); + window.addEventListener('online', online); + const stopWake = power.addWakeHandler(wake); + fire('resume'); + fire('unlock-screen'); + expect(visible).toHaveBeenCalledTimes(1); + expect(online).toHaveBeenCalledTimes(1); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + expect(wake).toHaveBeenCalledTimes(1); + document.removeEventListener('visibilitychange', visible); + window.removeEventListener('online', online); + stopWake(); + stop(); + }); + + it('a suspend tells the sleep handlers and wakes nothing', async () => { + const { fire, power, stop } = await started(); + const sleep = vi.fn(); + const visible = vi.fn(); + const stopSleep = power.addSleepHandler(sleep); + document.addEventListener('visibilitychange', visible); + fire('suspend'); + fire('lock-screen'); + expect(sleep).toHaveBeenCalledTimes(1); + expect(visible).not.toHaveBeenCalled(); + document.removeEventListener('visibilitychange', visible); + stopSleep(); + stop(); + }); + + it('a network change counts as waking', async () => { + const connection = new EventTarget(); + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + await new Promise((resolve) => setTimeout(resolve, 0)); + connection.dispatchEvent(new Event('change')); + expect(wake).toHaveBeenCalled(); + stopWake(); + stop(); + }); +}); + +describe('the summary', () => { + const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent; + const now = new Date('2026-11-03T10:00:00Z').getTime(); + + it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => { + const { nextEventOf } = await import('../os-summary'); + const events = [ + event('later', '2026-11-03T15:00:00Z'), + event('past', '2026-11-03T09:00:00Z'), + event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }), + event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }), + event('soon', '2026-11-03T12:00:00Z'), + ]; + expect(nextEventOf(events, now)?.id).toBe('soon'); + expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull(); + }); + + describe('with a login', () => { + const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] }; + const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 })); + const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) }; + + beforeEach(async () => { + getEmails.mockClear(); + const { useAuthStore } = await import('@/stores/auth-store'); + const { useAccountStore } = await import('@/stores/account-store'); + const { useEmailStore } = await import('@/stores/email-store'); + useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' }); + useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never }); + useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never }); + }); + + afterEach(async () => { + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(null); + }); + + it('lists unread mail with what the tray item needs to open it', async () => { + const { readSummary } = await import('../os-summary'); + const summary = await readSummary('en'); + expect(summary.unread).toEqual([expect.objectContaining({ emailId: 'e1', accountId: 'acc', sender: 'Alice', subject: 'Lunch?' })]); + expect(JSON.parse(summary.unread[0].context!)).toMatchObject({ emailId: 'e1', slot: 3 }); + expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' }); + }); + + it('while locked it is empty, without asking anyone anything', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + setAppLockCheck(() => true); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + }); +}); + +describe('the settings section', () => { + const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null }; + const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false }); + + function bridgeFor(current: DesktopOsStatus) { + return { + getStatus: vi.fn(async () => current), + setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })), + onStatusChange: vi.fn(() => () => {}), + defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })), + openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })), + }; + } + + it('a browser never sees it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('in the app it shows the state and changes it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + expect(await screen.findByText('section_title')).toBeInTheDocument(); + expect(screen.queryByText('start_minimized_label')).toBeNull(); + // Second in the section, after Close to tray. + fireEvent.click(screen.getAllByRole('switch')[1]); + await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true })); + expect(await screen.findByText('start_minimized_label')).toBeInTheDocument(); + }); + + it('the button offers the app and opens where the choice is made', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' })); + await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled()); + }); + + it('says when another app holds the shortcut', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never); + render(); + expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument(); + }); + + it('macOS gets its own word for the bounce', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status()) as never, 'darwin'); + render(); + expect(await screen.findByText('attention_mac_label')).toBeInTheDocument(); + }); +}); diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts new file mode 100644 index 000000000..72432d62b --- /dev/null +++ b/lib/desktop/app-lock-state.ts @@ -0,0 +1,19 @@ +/** + * Whether the app is locked, for everything that would show mail outside the app. + * + * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a + * function that answers from its own state, and calls bridge.os.setLocked on every + * change: main then clears the tray, the jump list and delivered toasts, and strips + * anything that still comes through. Nothing else needs to change. Until then it + * answers false. + */ + +let check: (() => boolean) | null = null; + +export function setAppLockCheck(next: (() => boolean) | null): void { + check = next; +} + +export function isAppLocked(): boolean { + return check?.() === true; +} diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts new file mode 100644 index 000000000..c3f6476a7 --- /dev/null +++ b/lib/desktop/file-drag.ts @@ -0,0 +1,55 @@ +/** + * Mail as real files from the page's side: a native drag or a copy to the clipboard. + * Later agents' calls for the taskbar's progress bar live here too. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { setOwnDragActive } from '@/lib/desktop/window-files'; + +export interface DragFile { + name: string; + /** A blob: URL the page already holds. */ + url?: string; + data?: Uint8Array; +} + +export function canDragNativeFiles(): boolean { + return !!getDesktopBridge()?.os?.startDrag; +} + +async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { + if (file.data) return { name: file.name, data: file.data }; + if (!file.url) throw new Error('A file needs its bytes or a URL'); + const response = await fetch(file.url); + return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) }; +} + +/** + * Hands the file to main, which writes it to its private folder and starts the OS's + * drag. The mouse is still down, so this has to come straight from `dragstart`. + */ +export async function startNativeFileDrag(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.startDrag) return false; + setOwnDragActive(true); + try { + return await os.startDrag(await Promise.all(files.map(toBytes))); + } finally { + setOwnDragActive(false); + } +} + +/** Copy attachments as files, for pasting into Explorer or Finder. */ +export async function copyFilesToClipboard(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.copyFiles) return false; + return os.copyFiles(await Promise.all(files.map(toBytes))); +} + +/** + * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null + * to take it away. Does nothing in a browser. + */ +export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void { + getDesktopBridge()?.os?.setProgress(progress, mode); +} diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts new file mode 100644 index 000000000..12416ef30 --- /dev/null +++ b/lib/desktop/mail-links.ts @@ -0,0 +1,44 @@ +/** + * Opening a message or folder from outside the mail page: a tray item, a toast, a jump + * list entry. The mail page applies a link the way it applies a permalink. If it isn't + * on screen, the link waits and the app goes there. + */ + +import type { MailDeepLink } from '@/lib/deep-links'; + +export interface MailLinkOptions { + replyText?: string; +} + +export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void; + +let opener: MailLinkOpener | null = null; +let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null; + +/** The mail page, while it's mounted. Takes over a link that was waiting for it. */ +export function setMailLinkOpener(next: MailLinkOpener): () => void { + opener = next; + if (waiting) { + const { link, options } = waiting; + waiting = null; + next(link, options); + } + return () => { + if (opener === next) opener = null; + }; +} + +export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void { + if (opener) { + opener(link, options); + return; + } + waiting = { link, options }; + void leaveFor('/'); +} + +/** For tests. */ +export function resetMailLinks(): void { + opener = null; + waiting = null; +} diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts new file mode 100644 index 000000000..090c9ebdf --- /dev/null +++ b/lib/desktop/mail-notifications.ts @@ -0,0 +1,184 @@ +/** + * New mail as native notifications, with the same rules as the app's other ways of + * saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that + * arrives together is told about once. Sender and subject stay off the notification + * when the app is locked, or when the organisation says so for that login + * (pushWithoutContent, the rule the push preview route follows). + * + * Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers + * whether the page should play the sound the user picked, because this is the one + * place that sound is played from in the desktop app. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopOsNotification } from '@/lib/desktop/types'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail'; +import { devicesForAccount } from '@/lib/device-policy'; +import { playNotificationSound } from '@/lib/notification-sound'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +export const MAIL_BATCH_MS = 3000; + +export interface MailNotificationLabels { + newMail: string; + newMailCount: (count: number) => string; + noSubject: string; + archive: string | null; + markRead: string; + delete: string; + reply: string; + replyPlaceholder: string; +} + +/** What a toast's button hands back, possibly after the app restarted. */ +export interface MailNotificationContext { + emailId?: string; + threadId?: string; + accountId?: string; + slot?: number; + login?: string; +} + +/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */ +export function desktopPlaysMailSound(): boolean { + return !!getDesktopBridge()?.os && !isPopoutWindow(); +} + +interface Sender { + entryId: string | null; + slot?: number; + login?: string; + label: string | null; +} + +async function senderInfo(client: IJMAPClient): Promise { + const auth = useAuthStore.getState(); + const accounts = useAccountStore.getState().accounts; + const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client) + ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined); + if (!entry) return { entryId: null, label: null }; + return { + entryId: entry.id, + slot: entry.cookieSlot, + login: await loginFingerprint(entry.serverUrl, entry.username), + label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null, + }; +} + +function withoutContent(sender: Sender): boolean { + if (isAppLocked()) return true; + // Unknown counts as on, as on the server's route. + if (!sender.entryId) return true; + try { + return devicesForAccount(sender.entryId).pushWithoutContent; + } catch { + return true; + } +} + +function senderName(mail: NewMail): string { + const first = mail.email.from?.[0]; + return first?.name || first?.email || ''; +} + +export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise { + const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a)); + const sender = await senderInfo(newest.client); + const hidden = withoutContent(sender); + const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login }; + const base = { + id: `mail:${newest.accountId}/${newest.email.id}`, + group: `mail:${newest.accountId}`, + sound: true, + attention: true, + }; + const suffix = sender.label && !hidden ? ` (${sender.label})` : ''; + if (batch.length > 1) { + return { + ...base, + title: `${labels.newMailCount(batch.length)}${suffix}`, + body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`, + // Nothing for one message to act on: a click opens the Inbox. + context: JSON.stringify(context), + }; + } + const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId }; + if (hidden) { + return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) }; + } + return { + ...base, + title: `${senderName(newest) || labels.newMail}${suffix}`, + body: newest.email.subject || newest.email.preview || labels.noSubject, + context: JSON.stringify(full), + actions: [ + ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []), + { id: 'mark-read', label: labels.markRead }, + { id: 'delete', label: labels.delete }, + ], + reply: { label: labels.reply, placeholder: labels.replyPlaceholder }, + }; +} + +export interface MailNotifications extends NewMailWatch { + setLabels(labels: MailNotificationLabels): void; +} + +let current: MailNotifications | null = null; + +/** + * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once + * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`. + * Does nothing where notifications don't run: in a browser, in a pop-out. + */ +export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + current?.noteCreated(client, accountId, ids); +} + +export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { + let labels = initial; + let batch: NewMail[] = []; + let timer: ReturnType | null = null; + + const flush = () => { + timer = null; + const current = batch; + batch = []; + if (current.length === 0) return; + const settings = useSettingsStore.getState(); + if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return; + void (async () => { + const notification = await buildMailNotification(current, labels); + const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound }); + if (result?.playSound) playNotificationSound(settings.notificationSoundChoice); + })().catch((error) => { + console.error('Failed to show a notification:', error); + }); + }; + + const watch = startNewMailWatch((mail) => { + batch.push(...mail); + timer ??= setTimeout(flush, MAIL_BATCH_MS); + }); + + const handle: MailNotifications = { + ...watch, + setLabels(next) { + labels = next; + }, + stop() { + watch.stop(); + if (timer) clearTimeout(timer); + timer = null; + if (current === handle) current = null; + }, + }; + current = handle; + return handle; +} diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts new file mode 100644 index 000000000..31a6568ea --- /dev/null +++ b/lib/desktop/new-mail.ts @@ -0,0 +1,149 @@ +/** + * Which messages count as new mail for the desktop app's notifications, for every + * connected login, whichever page or folder is open. + * + * With the local store the source is its `created` event (noteCreatedEmails): the + * engine has already worked out the delta. Without one, each login's push change is + * resolved here with Email/changes on a baseline of its own, the way + * reportNewInboxMail does for plugins. Both can see the same message; it's only told + * once. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { isMailboxMuted } from '@/lib/notification-mutes'; +import { subscribePushStateChanges } from '@/lib/push-events'; +import { useSettingsStore } from '@/stores/settings-store'; + +export interface NewMail { + client: IJMAPClient; + /** The JMAP account the message is in. */ + accountId: string; + email: Email; +} + +const MAX_PAGES = 5; +const MAX_CHANGES = 200; +const SEEN_LIMIT = 500; +/** An import or a restore creates old mail: nobody is waiting to hear about that. */ +const NEWEST_AGE_MS = 24 * 60 * 60 * 1000; +/** Folders mail lands in by itself, when not only the Inbox notifies. */ +const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']); + +function mailboxIdsOf(email: Email): string[] { + return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]); +} + +/** The folder a new message counts for, or null if it stays silent. */ +export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null { + if (email.keywords?.$seen || email.keywords?.$draft) return null; + const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now; + if (now - received > NEWEST_AGE_MS) return null; + const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState(); + const here = new Set(mailboxIdsOf(email)); + const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id)); + const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? ''))); + // Mail filed in a muted folder and an unmuted one still notifies. + return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null; +} + +export interface NewMailWatch { + /** The local store's `created` event for this login's account. */ + noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void; + /** Takes a state to work from, so the first push after the start can tell what's new. */ + seed(client: IJMAPClient): void; + stop(): void; +} + +export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch { + const baselines = new WeakMap>(); + const seen: string[] = []; + let queue: Promise = Promise.resolve(); + let stopped = false; + + const baselinesOf = (client: IJMAPClient) => { + let map = baselines.get(client); + if (!map) { + map = new Map(); + baselines.set(client, map); + } + return map; + }; + + const enqueue = (run: () => Promise) => { + const next = queue.then(run, run).catch((error) => { + console.error('Failed to check for new mail:', error); + }); + queue = next; + return next; + }; + + const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => { + const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`)); + if (fresh.length === 0 || stopped) return; + for (const id of fresh) seen.push(`${accountId}/${id}`); + seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); + const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]); + const own = client.getAccountId(); + const mail = emails + .filter((email) => notifyingMailbox(email, accountId, own, mailboxes)) + .map((email) => ({ client, accountId, email })); + if (mail.length > 0 && !stopped) onMail(mail); + }; + + const check = async (client: IJMAPClient, accountId: string, pushed: string) => { + const map = baselinesOf(client); + const since = map.get(accountId); + if (!since || !client.getEmailChanges) { + map.set(accountId, pushed); + return; + } + if (since === pushed) return; + const created: string[] = []; + let state = since; + for (let page = 0; page < MAX_PAGES; page += 1) { + const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES); + if (!delta) { + // Too old for the server: start again from here. + map.set(accountId, pushed); + return; + } + created.push(...delta.created); + state = delta.newState; + if (!delta.hasMoreChanges) break; + } + map.set(accountId, state); + await report(client, accountId, created); + }; + + const stopPush = subscribePushStateChanges((change, client) => { + for (const [accountId, types] of Object.entries(change.changed ?? {})) { + const pushed = types?.Email; + if (pushed) void enqueue(() => check(client, accountId, pushed)); + } + }); + + return { + noteCreated(client, accountId, ids) { + void enqueue(() => report(client, accountId, ids)); + }, + seed(client) { + void enqueue(async () => { + const map = baselinesOf(client); + const own = client.getAccountId(); + const mailboxes = await client.getAllMailboxes(); + const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox'); + await Promise.all(inboxes.map(async (inbox) => { + const accountId = inbox.accountId ?? own; + if (map.has(accountId)) return; + const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1); + if (state && !map.has(accountId)) map.set(accountId, state); + })); + }); + }, + stop() { + stopped = true; + stopPush(); + }, + }; +} diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts new file mode 100644 index 000000000..8c7332225 --- /dev/null +++ b/lib/desktop/os-commands.ts @@ -0,0 +1,181 @@ +/** + * What the OS asks of the app, carried out with what the app already has: the launch + * handoff for files and new messages, the protocol handlers for mailto: and webcal:, + * the notification actions for a toast's buttons. A bridge command comes from the + * user's own OS, so it skips the question a web share gets (decision 17). + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopOsCommand } from '@/lib/desktop/types'; +import { openMailLink } from '@/lib/desktop/mail-links'; +import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; +import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; +import { parseMailto } from '@/lib/protocol-handlers/mailto'; +import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session'; +import { parseWebcal } from '@/lib/protocol-handlers/webcal'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export interface OsCommandEnv { + /** Goes to a page, saving an open composer first. */ + leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise; + /** A route change that isn't leaving anything behind. */ + push: (path: string) => void; + /** Says something couldn't be opened. */ + notifyFailed: (names: readonly string[]) => void; +} + +/** How long a toast's button waits for a cold start to sign in. */ +const SIGN_IN_WAIT_MS = 30_000; + +function parseContext(raw: string | undefined): MailNotificationContext { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {}; + } catch { + return {}; + } +} + +function whenSignedIn(): Promise { + return new Promise((resolve) => { + if (useAuthStore.getState().isAuthenticated) { + resolve(true); + return; + } + const finish = (value: boolean) => { + clearTimeout(timer); + stop(); + resolve(value); + }; + const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS); + const stop = useAuthStore.subscribe((state) => { + if (state.isAuthenticated) finish(true); + }); + }); +} + +/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */ +async function stillThatLogin(context: MailNotificationContext): Promise { + if (context.slot === undefined || !context.login) return false; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login; +} + +function showWindow(): void { + getDesktopBridge()?.windows?.focus?.(); +} + +async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise { + showWindow(); + await whenSignedIn(); + const known = await stillThatLogin(context); + if (known && context.emailId) { + openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor); + return; + } + openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor); +} + +function runAction(request: NotificationActionRequest): Promise { + return new Promise((resolve) => { + void runNotificationAction(request, (status) => { + if (status !== 'accepted') resolve(status); + }); + }); +} + +async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise { + const context = parseContext(rawContext); + if (action === 'reply') { + await openFromNotification(context, env, { text: reply ?? '' }); + return; + } + if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) { + if (await whenSignedIn()) { + const status = await runAction({ + action, + emailId: context.emailId, + accountId: context.accountId, + threadId: context.threadId, + slot: context.slot, + login: context.login, + at: Date.now(), + }); + if (status === 'done') return; + } + } + // Clicked, or the button couldn't be carried out here: the message opens. + await openFromNotification(context, env); +} + +function handleFiles(command: Extract, env: OsCommandEnv): void { + const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); + const failed = [...command.failed]; + if (command.reason === 'attach') { + if (made.length > 0) handOffDraft({ files: made }); + } else { + for (const file of made) { + const kind = kindForFile(file); + if (kind) handOffFiles([file], kind); + else failed.push(file.name); + } + } + if (failed.length > 0) env.notifyFailed(failed); +} + +export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise { + switch (command.type) { + case 'new-message': + handOffNewMessage(); + return; + case 'navigate': + if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor); + else void env.leaveFor(`/${command.target}`); + return; + case 'open-message': { + const context = parseContext(command.context); + if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env); + openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor); + return; + } + case 'open-mailbox': + openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor); + return; + case 'mailto': { + const parsed = parseMailto(command.url); + if (!parsed) return; + savePendingMailto(parsed); + notifyPendingMailto(); + env.push('/'); + return; + } + case 'webcal': { + const parsed = parseWebcal(command.url); + if (!parsed) return; + savePendingWebcal(parsed); + notifyPendingWebcal(); + env.push('/calendar'); + return; + } + case 'files': + handleFiles(command, env); + return; + case 'notification': + return handleNotification(command.action, command.context, command.reply, env); + } +} + +/** Listens for as long as the page lives. Commands wait in main until this runs. */ +export function startOsCommands(env: OsCommandEnv): () => void { + const os = getDesktopBridge()?.os; + if (!os) return () => {}; + return os.onCommand((command) => { + void Promise.resolve(handleOsCommand(command, env)).catch((error) => { + console.error('An OS command failed:', error); + }); + }); +} diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts new file mode 100644 index 000000000..12d014051 --- /dev/null +++ b/lib/desktop/os-summary.ts @@ -0,0 +1,130 @@ +/** + * What the tray and the jump list show: recent unread mail, the newest conversations, + * the next calendar event. Read with the same client calls as the rest of the app, so + * the local store answers when it has them. No content while locked or where the + * organisation keeps it off notifications. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; +import { devicesForAccount } from '@/lib/device-policy'; +import type { CalendarEvent, Email } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useEmailStore } from '@/stores/email-store'; + +const ROWS = 5; +const REFRESH_DELAY_MS = 2000; +const REFRESH_EVERY_MS = 5 * 60 * 1000; +const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000; + +export const EMPTY_SUMMARY: DesktopOsSummary = { unread: [], recent: [], nextEvent: null }; + +export function eventStart(event: CalendarEvent): number { + return new Date(event.utcStart ?? event.start).getTime(); +} + +/** The soonest event still ahead, leaving out cancelled ones. */ +export function nextEventOf(events: readonly CalendarEvent[], now: number): CalendarEvent | null { + const ahead = events + .filter((event) => event.status !== 'cancelled' && !event.showWithoutTime && eventStart(event) >= now) + .sort((a, b) => eventStart(a) - eventStart(b)); + return ahead[0] ?? null; +} + +export function formatEventTime(start: number, now: number, locale: string | undefined): string { + const date = new Date(start); + const sameDay = date.toDateString() === new Date(now).toDateString(); + const options: Intl.DateTimeFormatOptions = sameDay + ? { hour: 'numeric', minute: '2-digit' } + : { weekday: 'short', hour: 'numeric', minute: '2-digit' }; + return new Intl.DateTimeFormat(locale, options).format(date); +} + +export async function readSummary(locale: string | undefined, now = Date.now()): Promise { + if (isAppLocked()) return EMPTY_SUMMARY; + const auth = useAuthStore.getState(); + const client: IJMAPClient | null = auth.client; + const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId); + if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY; + if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY; + + const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared); + let unread: DesktopOsTrayMessage[] = []; + let recent: DesktopOsTrayMessage[] = []; + if (inbox) { + const accountId = client.getAccountId(); + const login = await loginFingerprint(entry.serverUrl, entry.username); + const row = (email: Email): DesktopOsTrayMessage => ({ + emailId: email.id, + accountId, + sender: email.from?.[0]?.name || email.from?.[0]?.email || '', + subject: email.subject ?? '', + context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }), + }); + const [unreadList, recentList] = await Promise.all([ + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }), + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS), + ]); + unread = unreadList.emails.map(row); + recent = recentList.emails.map(row); + } + + let nextEvent: DesktopOsSummary['nextEvent'] = null; + if (useCalendarStore.getState().supportsCalendar) { + const events = await client.queryCalendarEvents({ + after: new Date(now).toISOString(), + before: new Date(now + EVENT_WINDOW_MS).toISOString(), + }).catch(() => [] as CalendarEvent[]); + const next = nextEventOf(events, now); + if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) }; + } + return { unread, recent, nextEvent }; +} + +/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */ +export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } { + const os = getDesktopBridge()?.os; + if (!os) return { refresh: () => {}, stop: () => {} }; + let timer: ReturnType | null = null; + let stale = false; + + const run = () => { + timer = null; + void readSummary(locale()).then((summary) => { + if (!stale) os.setSummary(summary); + }, (error) => { + console.warn('Could not read the tray summary:', error); + }); + }; + const refresh = () => { + timer ??= setTimeout(run, REFRESH_DELAY_MS); + }; + const inboxUnread = () => useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared)?.unreadEmails ?? -1; + let lastUnread = inboxUnread(); + const stopEmail = useEmailStore.subscribe(() => { + const current = inboxUnread(); + if (current === lastUnread) return; + lastUnread = current; + refresh(); + }); + const stopAuth = useAuthStore.subscribe((state, previous) => { + if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh(); + }); + const interval = setInterval(refresh, REFRESH_EVERY_MS); + refresh(); + return { + refresh, + stop() { + stale = true; + stopEmail(); + stopAuth(); + clearInterval(interval); + if (timer) clearTimeout(timer); + }, + }; +} diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts new file mode 100644 index 000000000..84d5ccd58 --- /dev/null +++ b/lib/desktop/power.ts @@ -0,0 +1,70 @@ +/** + * Waking up without stale state. After sleep, an unlock or a network change, the app + * does what it does when a tab comes back into view: the JMAP client checks for state + * changes and replaces a dead stream, the identities and policy refresh. Those are the + * handlers already listening, so this only gives them the word. The hidden main window + * of the desktop app never goes out of view, so without this they wouldn't hear it. + * + * The local store registers with addWakeHandler and addSleepHandler (setOnline, sync). + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { usePolicyStore } from '@/stores/policy-store'; + +type Handler = () => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +/** A resume and an unlock usually come together. */ +const WAKE_REPEAT_MS = 2000; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => wakeHandlers.delete(handler); +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => sleepHandlers.delete(handler); +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +function wake(): void { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('online')); + const policy = usePolicyStore.getState(); + if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); + runAll(wakeHandlers); +} + +export function startPowerWatch(): () => void { + const bridge = getDesktopBridge()?.os; + if (!bridge) return () => {}; + let lastWake = 0; + const wakeOnce = () => { + const now = Date.now(); + if (now - lastWake < WAKE_REPEAT_MS) return; + lastWake = now; + wake(); + }; + const stopPower = bridge.onPower((event) => { + if (event === 'resume' || event === 'unlock-screen') wakeOnce(); + else if (event === 'suspend') runAll(sleepHandlers); + }); + // Wi-Fi to cable is a new network with no offline moment in between. + const connection = (navigator as unknown as { connection?: EventTarget }).connection; + connection?.addEventListener?.('change', wakeOnce); + return () => { + stopPower(); + connection?.removeEventListener?.('change', wakeOnce); + }; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e049b727b..455ae5f1d 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -111,6 +111,122 @@ export interface DesktopPluginsBridge { install: (slug: string, version?: string) => Promise; } +export interface DesktopOsSettings { + /** Closing the main window leaves the app running in the tray. */ + closeToTray: boolean; + startAtLogin: boolean; + /** At login: no window, only the tray. */ + startMinimized: boolean; + /** Taskbar flash or dock bounce on new mail. */ + attentionOnNewMail: boolean; + quietHoursEnabled: boolean; + /** HH:MM, local time. */ + quietHoursStart: string; + quietHoursEnd: string; + /** "Show Bulwark", an Electron accelerator. Null: off. */ + showShortcut: string | null; +} + +export interface DesktopOsStatus { + settings: DesktopOsSettings; + pausedUntil: number | null; + /** The shortcut in settings is really held. Another app can have it. */ + shortcutActive: boolean; +} + +export interface DesktopOsFile { + name: string; + data: Uint8Array; +} + +/** What the OS asks the app to do: a tray item, a jump list entry, a toast, a double-clicked file. */ +export type DesktopOsCommand = + | { type: 'new-message' } + | { type: 'navigate'; target: 'inbox' | 'calendar' | 'contacts' } + | { type: 'open-message'; emailId: string; accountId?: string; context?: string } + | { type: 'open-mailbox'; mailboxId: string } + | { type: 'mailto'; url: string } + | { type: 'webcal'; url: string } + | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] } + | { type: 'notification'; action: string; context: string; reply?: string }; + +export interface DesktopOsNotification { + /** Tag of the toast. Showing an id again within a few minutes does nothing. */ + id: string; + group?: string; + title: string; + body: string; + /** Handed back with the action. Keep it short: Windows keeps it in the toast. */ + context: string; + actions?: { id: string; label: string }[]; + reply?: { label: string; placeholder: string }; + /** The user wants a sound with this. */ + sound?: boolean; + /** New mail: flash the taskbar or bounce the dock if that's on. */ + attention?: boolean; +} + +export interface DesktopOsNotifyResult { + shown: boolean; + reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid'; + /** The page should play the sound the user picked. */ + playSound: boolean; +} + +export interface DesktopOsTrayMessage { + emailId: string; + accountId: string; + sender: string; + subject: string; + context?: string; +} + +export interface DesktopOsSummary { + unread: DesktopOsTrayMessage[]; + /** Newest conversations, whether read or not, for the jump list. */ + recent: DesktopOsTrayMessage[]; + nextEvent: { title: string; when: string } | null; +} + +export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen'; + +export interface DesktopOsDefaultAppStatus { + /** Offered to the OS as a mail app. */ + registered: boolean; + mailto: boolean; + webcal: boolean; +} + +export interface DesktopOsBridge { + /** + * What the OS asks for. Main holds these until the main window's page subscribes, + * so a cold start loses nothing. + */ + onCommand: (listener: (command: DesktopOsCommand) => void) => () => void; + getStatus: () => Promise; + setSettings: (patch: Partial) => Promise; + /** Pause notifications until this time, or null to end the pause. */ + setPaused: (until: number | null) => void; + onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void; + /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */ + setBadge: (count: number) => void; + /** What the tray and the jump list show. */ + setSummary: (summary: DesktopOsSummary) => void; + /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */ + setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void; + /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */ + setLocked: (locked: boolean) => void; + notify: (notification: DesktopOsNotification) => Promise; + onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; + /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ + startDrag: (files: DesktopOsFile[]) => Promise; + /** The same, onto the clipboard as files. */ + copyFiles: (files: DesktopOsFile[]) => Promise; + defaultAppStatus: () => Promise; + /** Offers the app to the OS and opens where the user chooses. */ + openDefaultAppSettings: () => Promise; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -120,6 +236,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; lifecycle?: DesktopLifecycleBridge; + os?: DesktopOsBridge; servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts new file mode 100644 index 000000000..c9b95221a --- /dev/null +++ b/lib/desktop/window-files.ts @@ -0,0 +1,66 @@ +/** + * Files dropped on a window or pasted from the clipboard. Whatever already handles a + * drop keeps doing so, which it shows by preventDefault: the composer, the file page, + * calendar import. The rest becomes an attachment on a new draft, or on the composer + * that's open. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { handOffDraft } from '@/lib/launch-handoff'; + +type ComposerTarget = (files: File[]) => unknown; + +let composerTarget: ComposerTarget | null = null; +/** Our own native drag passing over our own window isn't a new file. */ +let ownDrag = false; + +/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */ +export function setComposerFileTarget(target: ComposerTarget): () => void { + composerTarget = target; + return () => { + if (composerTarget === target) composerTarget = null; + }; +} + +export function setOwnDragActive(active: boolean): void { + ownDrag = active; +} + +function take(files: File[]): void { + if (composerTarget) composerTarget(files); + else handOffDraft({ files }); +} + +function hasFiles(event: DragEvent): boolean { + return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); +} + +export function startWindowFiles(): () => void { + if (!getDesktopBridge()?.os) return () => {}; + const onDragOver = (event: DragEvent) => { + if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }; + const onDrop = (event: DragEvent) => { + if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; + event.preventDefault(); + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length > 0) take(files); + }; + const onPaste = (event: ClipboardEvent) => { + if (event.defaultPrevented) return; + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length === 0) return; + event.preventDefault(); + take(files); + }; + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + window.addEventListener('paste', onPaste); + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + window.removeEventListener('paste', onPaste); + }; +} diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts index 39287cd2f..c911eeae8 100644 --- a/lib/pwa-notify/__tests__/page-actions.test.ts +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -13,6 +13,7 @@ const makeClient = (accountId: string) => ({ getAccountId: () => accountId, getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })), getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]), + batchMoveEmails: vi.fn(async (..._args: unknown[]) => {}), }); let clients: Record>; let activeAccountId = 'alice@one'; @@ -31,7 +32,9 @@ vi.mock('@/stores/auth-store', () => ({ }), }, })); -vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +let listed: { id: string; sourceClientAccountId?: string }[] = []; +const deleteEmail = vi.fn(async (..._args: unknown[]) => true); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive, deleteEmail, emails: listed }) } })); // Each login's own subject, and a policy that reads it: the gate of the login // the notification is for, not of the one that's active here. vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) })); @@ -43,6 +46,7 @@ vi.mock('@/stores/policy-store', () => ({ }, })); vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: { getState: () => ({ deleteAction: 'trash' }) } })); async function run(over: Partial = {}) { const request: NotificationActionRequest = { @@ -65,6 +69,7 @@ beforeEach(() => { activeAccountId = 'alice@one'; gateOff.clear(); hooksRegistered = false; + listed = []; batchMarkAsRead.mockResolvedValue(true); batchArchive.mockResolvedValue(true); }); @@ -278,3 +283,34 @@ describe('Reply', () => { removeCurrent(); }); }); + +describe("Delete, from the desktop app's toast", () => { + it('goes through the store when the message is in the list, with its hooks and the Delete setting', async () => { + listed = [{ id: 'e1' }]; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(deleteEmail).toHaveBeenCalledWith(clients['alice@one'], 'e1'); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("moves it to Trash by the folder's real id when it is not in the list", async () => { + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(clients['alice@one'].batchMoveEmails).toHaveBeenCalledWith(['e1'], 'bin', undefined, expect.any(Boolean)); + expect(deleteEmail).not.toHaveBeenCalled(); + }); + + it('plugins are never gone round: with a veto hook listening, the message opens instead', async () => { + hooksRegistered = true; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it('a message since moved to Trash stays there', async () => { + clients['alice@one'].getEmail.mockResolvedValue({ id: 'e1', mailboxIds: { bin: true } } as never); + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("the worker can't send one", () => { + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e1', accountId: 'a', at: Date.now() })).toBe(false); + }); +}); diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts index 96b1db101..1368a61be 100644 --- a/lib/pwa-notify/page-actions.ts +++ b/lib/pwa-notify/page-actions.ts @@ -5,10 +5,12 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore } from '@/stores/policy-store'; import { loginFingerprint } from './login-fingerprint'; import { vetoHooksRegistered } from './plugin-veto'; +import { useSettingsStore } from '@/stores/settings-store'; /** What the service worker posts when a notification's button is clicked (public/sw.js). */ export interface NotificationActionRequest { - action: 'archive' | 'mark-read' | 'reply'; + /** `delete` only comes from the desktop app's toasts, never from the worker. */ + action: 'archive' | 'mark-read' | 'reply' | 'delete'; emailId: string; accountId: string; /** When the worker asked. */ @@ -149,6 +151,24 @@ export async function runNotificationAction( answer('failed'); return; } + if (request.action === 'delete') { + const store = useEmailStore.getState(); + // In the list: the store's own delete, with its hooks and the Delete setting. + if (store.emails.some((row) => row.id === request.emailId && (row.sourceClientAccountId ?? entry.id) === entry.id)) { + answer(await store.deleteEmail(client, request.emailId) ? 'done' : 'failed'); + return; + } + // Not in the list, so no store action can reach it, and plugins are never gone round. + const trash = mailboxes.find((mailbox) => mailbox.role === 'trash'); + if (!trash || !email || vetoHooksRegistered()) { + answer('failed'); + return; + } + const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; + await client.batchMoveEmails([request.emailId], trash.originalId || trash.id, accountId, markRead); + answer('done'); + return; + } const done = request.action === 'mark-read' ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId }) : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true }); diff --git a/locales/ar/common.json b/locales/ar/common.json index 91db0f8ea..73529f9a4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6509,5 +6509,35 @@ "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." + }, + "desktopOs": { + "section_title": "تطبيق سطح المكتب", + "section_description": "طريقة عمل التطبيق مع حاسوبك.", + "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق", + "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.", + "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب", + "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.", + "start_minimized_label": "البدء في شريط النظام", + "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.", + "attention_label": "وميض شريط المهام عند وصول بريد جديد", + "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد", + "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.", + "quiet_hours_label": "ساعات الهدوء", + "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.", + "quiet_hours_from": "من", + "quiet_hours_to": "إلى", + "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح", + "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.", + "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.", + "default_app_label": "تطبيق البريد الافتراضي", + "default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.", + "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.", + "default_app_button": "اختيار التطبيق الافتراضي…", + "new_mail": "بريد جديد", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "نسخ كملف", + "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", + "copy_failed": "تعذّر نسخ الملف." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index d2e062a69..baea89184 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." + }, + "desktopOs": { + "section_title": "Aplicació d'escriptori", + "section_description": "Com funciona l'aplicació amb l'ordinador.", + "close_to_tray_label": "Continua a la safata en tancar", + "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.", + "start_at_login_label": "Inicia en entrar a l'ordinador", + "start_at_login_description": "L'aplicació s'inicia sola quan entreu.", + "start_minimized_label": "Inicia a la safata", + "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.", + "attention_label": "Fes parpellejar la barra de tasques amb correu nou", + "attention_mac_label": "Fes botar la icona del Dock amb correu nou", + "attention_description": "Només quan l'aplicació no és al davant.", + "quiet_hours_label": "Hores de silenci", + "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat", + "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.", + "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.", + "default_app_label": "Aplicació de correu predeterminada", + "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.", + "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.", + "default_app_button": "Tria l'aplicació predeterminada…", + "new_mail": "Correu nou", + "no_subject": "(sense assumpte)", + "action_delete": "Suprimeix", + "copy_as_file": "Copia com a fitxer", + "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", + "copy_failed": "No s'ha pogut copiar el fitxer." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 6b1e41754..b6f1dfbaa 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." + }, + "desktopOs": { + "section_title": "Desktopová aplikace", + "section_description": "Jak aplikace spolupracuje s vaším počítačem.", + "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti", + "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.", + "start_at_login_label": "Spustit po přihlášení k počítači", + "start_at_login_description": "Aplikace se po přihlášení spustí sama.", + "start_minimized_label": "Spustit v oznamovací oblasti", + "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.", + "attention_label": "Při nové poště blikat na hlavním panelu", + "attention_mac_label": "Při nové poště poskakovat ikonou v Docku", + "attention_description": "Jen když aplikace není v popředí.", + "quiet_hours_label": "Doba klidu", + "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou", + "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.", + "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.", + "default_app_label": "Výchozí poštovní aplikace", + "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.", + "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.", + "default_app_button": "Vybrat výchozí aplikaci…", + "new_mail": "Nová pošta", + "no_subject": "(bez předmětu)", + "action_delete": "Smazat", + "copy_as_file": "Kopírovat jako soubor", + "copied": "Soubor zkopírován. Můžete ho vložit do složky.", + "copy_failed": "Soubor se nepodařilo zkopírovat." } } diff --git a/locales/da/common.json b/locales/da/common.json index 61d6a1412..d9ccb53b3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." + }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Sådan arbejder appen sammen med din computer.", + "close_to_tray_label": "Fortsæt i systembakken ved lukning", + "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.", + "start_at_login_label": "Start, når du logger på computeren", + "start_at_login_description": "Appen starter selv, når du logger på.", + "start_minimized_label": "Start i systembakken", + "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.", + "attention_label": "Blink i proceslinjen ved ny post", + "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post", + "attention_description": "Kun når appen ikke er forrest.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en tastaturgenvej", + "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.", + "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.", + "default_app_label": "Standardmailapp", + "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.", + "default_app_is_default": "Denne app åbner dine maillinks.", + "default_app_button": "Vælg standardapp…", + "new_mail": "Ny post", + "no_subject": "(intet emne)", + "action_delete": "Slet", + "copy_as_file": "Kopiér som fil", + "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", + "copy_failed": "Filen kunne ikke kopieres." } } diff --git a/locales/de/common.json b/locales/de/common.json index eb1ec9a29..1e6a972ed 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." + }, + "desktopOs": { + "section_title": "Desktop-App", + "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", + "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen", + "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.", + "start_at_login_label": "Beim Anmelden am Computer starten", + "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.", + "start_minimized_label": "Im Infobereich starten", + "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.", + "attention_label": "Taskleiste bei neuen E-Mails blinken lassen", + "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen", + "attention_description": "Nur wenn die App nicht im Vordergrund ist.", + "quiet_hours_label": "Ruhezeiten", + "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.", + "quiet_hours_from": "Von", + "quiet_hours_to": "bis", + "show_shortcut_label": "App mit einer Tastenkombination anzeigen", + "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.", + "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.", + "default_app_label": "Standard-E-Mail-App", + "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.", + "default_app_is_default": "Diese App öffnet Ihre Mail-Links.", + "default_app_button": "Standard-App auswählen…", + "new_mail": "Neue E-Mail", + "no_subject": "(kein Betreff)", + "action_delete": "Löschen", + "copy_as_file": "Als Datei kopieren", + "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", + "copy_failed": "Die Datei konnte nicht kopiert werden." } } diff --git a/locales/en/common.json b/locales/en/common.json index 0af0e143f..450132227 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." + }, + "desktopOs": { + "section_title": "Desktop app", + "section_description": "How the app works with your computer.", + "close_to_tray_label": "Keep running in the tray when closed", + "close_to_tray_description": "New mail and reminders keep coming after you close the window.", + "start_at_login_label": "Start when you sign in to your computer", + "start_at_login_description": "The app starts by itself when you sign in.", + "start_minimized_label": "Start in the tray", + "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.", + "attention_label": "Flash the taskbar on new mail", + "attention_mac_label": "Bounce the Dock icon on new mail", + "attention_description": "Only when the app is not in front.", + "quiet_hours_label": "Quiet hours", + "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.", + "quiet_hours_from": "From", + "quiet_hours_to": "to", + "show_shortcut_label": "Show the app with a keyboard shortcut", + "show_shortcut_description": "{keys} brings the app to the front, from any other app.", + "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.", + "default_app_label": "Default mail app", + "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.", + "default_app_is_default": "This app opens your mail links.", + "default_app_button": "Choose default app…", + "new_mail": "New mail", + "no_subject": "(no subject)", + "action_delete": "Delete", + "copy_as_file": "Copy as file", + "copied": "File copied. You can paste it into a folder.", + "copy_failed": "The file could not be copied." } } diff --git a/locales/es/common.json b/locales/es/common.json index 83aeead77..e208908e9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." + }, + "desktopOs": { + "section_title": "Aplicación de escritorio", + "section_description": "Cómo funciona la aplicación con tu ordenador.", + "close_to_tray_label": "Seguir en la bandeja al cerrar", + "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.", + "start_at_login_label": "Iniciar al entrar en el ordenador", + "start_at_login_description": "La aplicación se inicia sola cuando entras.", + "start_minimized_label": "Iniciar en la bandeja", + "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.", + "attention_label": "Hacer parpadear la barra de tareas con correo nuevo", + "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo", + "attention_description": "Solo cuando la aplicación no está delante.", + "quiet_hours_label": "Horas de silencio", + "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado", + "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.", + "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.", + "default_app_label": "Aplicación de correo predeterminada", + "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.", + "default_app_is_default": "Esta aplicación abre tus enlaces de correo.", + "default_app_button": "Elegir aplicación predeterminada…", + "new_mail": "Correo nuevo", + "no_subject": "(sin asunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como archivo", + "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", + "copy_failed": "No se pudo copiar el archivo." } } diff --git a/locales/et/common.json b/locales/et/common.json index 788fb5171..709b73243 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." + }, + "desktopOs": { + "section_title": "Töölauarakendus", + "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", + "close_to_tray_label": "Sulgemisel jätka salves", + "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.", + "start_at_login_label": "Käivita arvutisse sisselogimisel", + "start_at_login_description": "Rakendus käivitub sisselogimisel ise.", + "start_minimized_label": "Käivita salves", + "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.", + "attention_label": "Vilguta tegumiriba uue kirja korral", + "attention_mac_label": "Põrgata Docki ikooni uue kirja korral", + "attention_description": "Ainult siis, kui rakendus pole esiplaanil.", + "quiet_hours_label": "Vaikne aeg", + "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.", + "quiet_hours_from": "Alates", + "quiet_hours_to": "kuni", + "show_shortcut_label": "Näita rakendust kiirklahviga", + "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.", + "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.", + "default_app_label": "Vaikimisi e-posti rakendus", + "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.", + "default_app_is_default": "See rakendus avab sinu e-posti lingid.", + "default_app_button": "Vali vaikerakendus…", + "new_mail": "Uus kiri", + "no_subject": "(teema puudub)", + "action_delete": "Kustuta", + "copy_as_file": "Kopeeri failina", + "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", + "copy_failed": "Faili ei saanud kopeerida." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 9eadf3199..7c5d654d8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6509,5 +6509,35 @@ "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." + }, + "desktopOs": { + "section_title": "برنامه دسکتاپ", + "section_description": "نحوه کار برنامه با رایانه شما.", + "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند", + "close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.", + "start_at_login_label": "هنگام ورود به رایانه شروع شود", + "start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.", + "start_minimized_label": "شروع در سینی سیستم", + "start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.", + "attention_label": "چشمک نوار وظیفه هنگام نامه جدید", + "attention_mac_label": "جهش نماد Dock هنگام نامه جدید", + "attention_description": "فقط وقتی برنامه در جلو نیست.", + "quiet_hours_label": "ساعات سکوت", + "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.", + "quiet_hours_from": "از", + "quiet_hours_to": "تا", + "show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید", + "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.", + "show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.", + "default_app_label": "برنامه پیش‌فرض ایمیل", + "default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.", + "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.", + "default_app_button": "انتخاب برنامه پیش‌فرض…", + "new_mail": "نامه جدید", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "رونوشت به‌صورت پرونده", + "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", + "copy_failed": "رونوشت پرونده ممکن نشد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index a44af9dbe..64eb990a1 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." + }, + "desktopOs": { + "section_title": "Application de bureau", + "section_description": "Comment l'application fonctionne avec votre ordinateur.", + "close_to_tray_label": "Rester dans la zone de notification à la fermeture", + "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.", + "start_at_login_label": "Démarrer à l'ouverture de session", + "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.", + "start_minimized_label": "Démarrer dans la zone de notification", + "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.", + "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier", + "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier", + "attention_description": "Seulement quand l'application n'est pas au premier plan.", + "quiet_hours_label": "Heures calmes", + "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.", + "quiet_hours_from": "De", + "quiet_hours_to": "à", + "show_shortcut_label": "Afficher l'application avec un raccourci clavier", + "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.", + "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.", + "default_app_label": "Application de messagerie par défaut", + "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.", + "default_app_is_default": "Cette application ouvre vos liens de messagerie.", + "default_app_button": "Choisir l'application par défaut…", + "new_mail": "Nouveau courrier", + "no_subject": "(sans objet)", + "action_delete": "Supprimer", + "copy_as_file": "Copier en tant que fichier", + "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", + "copy_failed": "Le fichier n'a pas pu être copié." } } diff --git a/locales/he/common.json b/locales/he/common.json index 17580b9a8..18bb2fd87 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6510,5 +6510,35 @@ "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." + }, + "desktopOs": { + "section_title": "יישום שולחן עבודה", + "section_description": "איך היישום עובד עם המחשב שלך.", + "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה", + "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.", + "start_at_login_label": "להפעיל בכניסה למחשב", + "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.", + "start_minimized_label": "להפעיל במגש המערכת", + "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.", + "attention_label": "הבהוב בשורת המשימות בדואר חדש", + "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש", + "attention_description": "רק כשהיישום לא בחזית.", + "quiet_hours_label": "שעות שקט", + "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.", + "quiet_hours_from": "מ-", + "quiet_hours_to": "עד", + "show_shortcut_label": "הצגת היישום בקיצור מקשים", + "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.", + "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.", + "default_app_label": "יישום הדואר המוגדר כברירת מחדל", + "default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.", + "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.", + "default_app_button": "בחירת יישום ברירת מחדל…", + "new_mail": "דואר חדש", + "no_subject": "(ללא נושא)", + "action_delete": "מחיקה", + "copy_as_file": "העתקה כקובץ", + "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", + "copy_failed": "לא ניתן להעתיק את הקובץ." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 474772591..3a8863848 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." + }, + "desktopOs": { + "section_title": "Asztali alkalmazás", + "section_description": "Hogyan működik az alkalmazás a számítógépével.", + "close_to_tray_label": "Bezáráskor fusson tovább a tálcán", + "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.", + "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor", + "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.", + "start_minimized_label": "Indítás a tálcán", + "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.", + "attention_label": "Tálca villogtatása új levélnél", + "attention_mac_label": "Dock-ikon ugráltatása új levélnél", + "attention_description": "Csak ha az alkalmazás nincs előtérben.", + "quiet_hours_label": "Csendes órák", + "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.", + "quiet_hours_from": "Ettől", + "quiet_hours_to": "eddig", + "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal", + "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.", + "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.", + "default_app_label": "Alapértelmezett levelezőalkalmazás", + "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.", + "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.", + "default_app_button": "Alapértelmezett alkalmazás kiválasztása…", + "new_mail": "Új levél", + "no_subject": "(nincs tárgy)", + "action_delete": "Törlés", + "copy_as_file": "Másolás fájlként", + "copied": "Fájl másolva. Beillesztheti egy mappába.", + "copy_failed": "A fájlt nem sikerült másolni." } } diff --git a/locales/it/common.json b/locales/it/common.json index 8cc39c364..b3e7964cd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." + }, + "desktopOs": { + "section_title": "App desktop", + "section_description": "Come l'app funziona con il tuo computer.", + "close_to_tray_label": "Resta nell'area di notifica alla chiusura", + "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.", + "start_at_login_label": "Avvia all'accesso al computer", + "start_at_login_description": "L'app si avvia da sola quando accedi.", + "start_minimized_label": "Avvia nell'area di notifica", + "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.", + "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta", + "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta", + "attention_description": "Solo quando l'app non è in primo piano.", + "quiet_hours_label": "Ore di silenzio", + "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.", + "quiet_hours_from": "Da", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera", + "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.", + "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.", + "default_app_label": "App di posta predefinita", + "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.", + "default_app_is_default": "Questa app apre i tuoi link di posta.", + "default_app_button": "Scegli app predefinita…", + "new_mail": "Nuova posta", + "no_subject": "(nessun oggetto)", + "action_delete": "Elimina", + "copy_as_file": "Copia come file", + "copied": "File copiato. Puoi incollarlo in una cartella.", + "copy_failed": "Impossibile copiare il file." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e051ceb66..e776616c2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6509,5 +6509,35 @@ "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" + }, + "desktopOs": { + "section_title": "デスクトップアプリ", + "section_description": "アプリとパソコンの連携方法です。", + "close_to_tray_label": "閉じてもトレイで実行を続ける", + "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。", + "start_at_login_label": "パソコンへのサインイン時に起動", + "start_at_login_description": "サインインするとアプリが自動で起動します。", + "start_minimized_label": "トレイで起動", + "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。", + "attention_label": "新着メールでタスクバーを点滅", + "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる", + "attention_description": "アプリが前面にないときのみ。", + "quiet_hours_label": "おやすみ時間", + "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。", + "quiet_hours_from": "開始", + "quiet_hours_to": "終了", + "show_shortcut_label": "キーボードショートカットでアプリを表示", + "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。", + "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。", + "default_app_label": "既定のメールアプリ", + "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。", + "default_app_is_default": "メールのリンクはこのアプリで開きます。", + "default_app_button": "既定のアプリを選択…", + "new_mail": "新着メール", + "no_subject": "(件名なし)", + "action_delete": "削除", + "copy_as_file": "ファイルとしてコピー", + "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", + "copy_failed": "ファイルをコピーできませんでした。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index e7f7be907..d589f30e9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6509,5 +6509,35 @@ "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." + }, + "desktopOs": { + "section_title": "데스크톱 앱", + "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", + "close_to_tray_label": "닫을 때 트레이에서 계속 실행", + "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.", + "start_at_login_label": "컴퓨터에 로그인할 때 시작", + "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.", + "start_minimized_label": "트레이에서 시작", + "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.", + "attention_label": "새 메일이 오면 작업 표시줄 깜박이기", + "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기", + "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.", + "quiet_hours_label": "방해 금지 시간", + "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.", + "quiet_hours_from": "시작", + "quiet_hours_to": "종료", + "show_shortcut_label": "키보드 단축키로 앱 표시", + "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.", + "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.", + "default_app_label": "기본 메일 앱", + "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.", + "default_app_is_default": "이 앱이 메일 링크를 엽니다.", + "default_app_button": "기본 앱 선택…", + "new_mail": "새 메일", + "no_subject": "(제목 없음)", + "action_delete": "삭제", + "copy_as_file": "파일로 복사", + "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", + "copy_failed": "파일을 복사할 수 없습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index c07fe9c31..22c93c057 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." + }, + "desktopOs": { + "section_title": "Darbvirsmas lietotne", + "section_description": "Kā lietotne darbojas ar jūsu datoru.", + "close_to_tray_label": "Aizverot turpināt darbu teknē", + "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.", + "start_at_login_label": "Sākt, piesakoties datorā", + "start_at_login_description": "Lietotne sākas pati, kad piesakāties.", + "start_minimized_label": "Sākt teknē", + "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.", + "attention_label": "Mirgot uzdevumjoslā, pienākot pastam", + "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam", + "attention_description": "Tikai tad, ja lietotne nav priekšplānā.", + "quiet_hours_label": "Klusuma stundas", + "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.", + "quiet_hours_from": "No", + "quiet_hours_to": "līdz", + "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu", + "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.", + "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.", + "default_app_label": "Noklusējuma pasta lietotne", + "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.", + "default_app_is_default": "Šī lietotne atver jūsu pasta saites.", + "default_app_button": "Izvēlēties noklusējuma lietotni…", + "new_mail": "Jauns pasts", + "no_subject": "(nav tēmas)", + "action_delete": "Dzēst", + "copy_as_file": "Kopēt kā failu", + "copied": "Fails nokopēts. Varat to ielīmēt mapē.", + "copy_failed": "Failu neizdevās nokopēt." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4633a5d6d..3d8bb9a5d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." + }, + "desktopOs": { + "section_title": "Ширээний апп", + "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", + "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах", + "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.", + "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх", + "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.", + "start_minimized_label": "Тавиур дээр эхлүүлэх", + "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.", + "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах", + "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх", + "attention_description": "Зөвхөн апп урд талд байхгүй үед.", + "quiet_hours_label": "Чимээгүй цаг", + "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.", + "quiet_hours_from": "Эхлэх", + "quiet_hours_to": "хүртэл", + "show_shortcut_label": "Гарын товчлолоор аппыг харуулах", + "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.", + "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.", + "default_app_label": "Үндсэн шуудангийн апп", + "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.", + "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.", + "default_app_button": "Үндсэн апп сонгох…", + "new_mail": "Шинэ захиа", + "no_subject": "(гарчиггүй)", + "action_delete": "Устгах", + "copy_as_file": "Файл болгож хуулах", + "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", + "copy_failed": "Файлыг хуулж чадсангүй." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d2e1a518e..d4d7099ad 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." + }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Slik samarbeider appen med datamaskinen din.", + "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking", + "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.", + "start_at_login_label": "Start når du logger på datamaskinen", + "start_at_login_description": "Appen starter av seg selv når du logger på.", + "start_minimized_label": "Start i systemstatusfeltet", + "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.", + "attention_label": "Blink i oppgavelinjen ved ny e-post", + "attention_mac_label": "La Dock-ikonet sprette ved ny e-post", + "attention_description": "Bare når appen ikke er foran.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en hurtigtast", + "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.", + "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.", + "default_app_label": "Standard e-postapp", + "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.", + "default_app_is_default": "Denne appen åpner e-postlenkene dine.", + "default_app_button": "Velg standardapp…", + "new_mail": "Ny e-post", + "no_subject": "(uten emne)", + "action_delete": "Slett", + "copy_as_file": "Kopier som fil", + "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", + "copy_failed": "Filen kunne ikke kopieres." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index b704a66fc..12301289c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." + }, + "desktopOs": { + "section_title": "Desktop-app", + "section_description": "Hoe de app samenwerkt met je computer.", + "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak", + "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.", + "start_at_login_label": "Starten als je je aanmeldt op de computer", + "start_at_login_description": "De app start vanzelf als je je aanmeldt.", + "start_minimized_label": "Starten in het systeemvak", + "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.", + "attention_label": "Taakbalk laten knipperen bij nieuwe mail", + "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail", + "attention_description": "Alleen als de app niet vooraan staat.", + "quiet_hours_label": "Stille uren", + "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.", + "quiet_hours_from": "Van", + "quiet_hours_to": "tot", + "show_shortcut_label": "App tonen met een sneltoets", + "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.", + "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.", + "default_app_label": "Standaard-mailapp", + "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.", + "default_app_is_default": "Deze app opent je maillinks.", + "default_app_button": "Standaard-app kiezen…", + "new_mail": "Nieuwe mail", + "no_subject": "(geen onderwerp)", + "action_delete": "Verwijderen", + "copy_as_file": "Kopiëren als bestand", + "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", + "copy_failed": "Het bestand kon niet worden gekopieerd." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 8ccc30784..6b31fdcf1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." + }, + "desktopOs": { + "section_title": "Aplikacja na komputer", + "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", + "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku", + "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.", + "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera", + "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.", + "start_minimized_label": "Uruchamiaj w zasobniku", + "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.", + "attention_label": "Migaj na pasku zadań przy nowej poczcie", + "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie", + "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.", + "quiet_hours_label": "Godziny ciszy", + "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym", + "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.", + "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.", + "default_app_label": "Domyślna aplikacja poczty", + "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.", + "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.", + "default_app_button": "Wybierz domyślną aplikację…", + "new_mail": "Nowa poczta", + "no_subject": "(bez tematu)", + "action_delete": "Usuń", + "copy_as_file": "Kopiuj jako plik", + "copied": "Plik skopiowany. Możesz wkleić go do folderu.", + "copy_failed": "Nie udało się skopiować pliku." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 188b256f8..ed181e935 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." + }, + "desktopOs": { + "section_title": "Aplicação de ambiente de trabalho", + "section_description": "Como a aplicação funciona com o seu computador.", + "close_to_tray_label": "Continuar na bandeja ao fechar", + "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.", + "start_at_login_label": "Iniciar ao entrar no computador", + "start_at_login_description": "A aplicação inicia sozinha quando entra.", + "start_minimized_label": "Iniciar na bandeja", + "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.", + "attention_label": "Piscar a barra de tarefas com correio novo", + "attention_mac_label": "Saltar o ícone da Dock com correio novo", + "attention_description": "Só quando a aplicação não está à frente.", + "quiet_hours_label": "Horas de silêncio", + "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.", + "quiet_hours_from": "De", + "quiet_hours_to": "até", + "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado", + "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.", + "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.", + "default_app_label": "Aplicação de correio predefinida", + "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.", + "default_app_is_default": "Esta aplicação abre as suas ligações de correio.", + "default_app_button": "Escolher aplicação predefinida…", + "new_mail": "Correio novo", + "no_subject": "(sem assunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como ficheiro", + "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", + "copy_failed": "Não foi possível copiar o ficheiro." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index ae31839c2..fb2ca2d1f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." + }, + "desktopOs": { + "section_title": "Aplicație desktop", + "section_description": "Cum funcționează aplicația cu computerul tău.", + "close_to_tray_label": "Rămâne în zona de notificări la închidere", + "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.", + "start_at_login_label": "Pornește la conectarea în computer", + "start_at_login_description": "Aplicația pornește singură când te conectezi.", + "start_minimized_label": "Pornește în zona de notificări", + "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.", + "attention_label": "Clipește în bara de activități la e-mail nou", + "attention_mac_label": "Sare pictograma din Dock la e-mail nou", + "attention_description": "Doar când aplicația nu este în față.", + "quiet_hours_label": "Ore de liniște", + "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.", + "quiet_hours_from": "De la", + "quiet_hours_to": "până la", + "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură", + "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.", + "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.", + "default_app_label": "Aplicația de e-mail implicită", + "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.", + "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.", + "default_app_button": "Alege aplicația implicită…", + "new_mail": "E-mail nou", + "no_subject": "(fără subiect)", + "action_delete": "Șterge", + "copy_as_file": "Copiază ca fișier", + "copied": "Fișier copiat. Îl poți lipi într-un dosar.", + "copy_failed": "Fișierul nu a putut fi copiat." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index a9ee1c65c..1661ae3ca 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." + }, + "desktopOs": { + "section_title": "Настольное приложение", + "section_description": "Как приложение работает с вашим компьютером.", + "close_to_tray_label": "При закрытии продолжать работу в трее", + "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.", + "start_at_login_label": "Запускать при входе в систему", + "start_at_login_description": "Приложение запускается само, когда вы входите.", + "start_minimized_label": "Запускать в трее", + "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.", + "attention_label": "Мигать на панели задач при новой почте", + "attention_mac_label": "Подпрыгивать значком в Dock при новой почте", + "attention_description": "Только когда приложение не на переднем плане.", + "quiet_hours_label": "Тихие часы", + "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.", + "quiet_hours_from": "С", + "quiet_hours_to": "до", + "show_shortcut_label": "Показывать приложение сочетанием клавиш", + "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.", + "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.", + "default_app_label": "Почтовое приложение по умолчанию", + "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.", + "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.", + "default_app_button": "Выбрать приложение по умолчанию…", + "new_mail": "Новая почта", + "no_subject": "(без темы)", + "action_delete": "Удалить", + "copy_as_file": "Копировать как файл", + "copied": "Файл скопирован. Его можно вставить в папку.", + "copy_failed": "Не удалось скопировать файл." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index a018ba55a..76252a888 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." + }, + "desktopOs": { + "section_title": "Počítačová aplikácia", + "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", + "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení", + "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.", + "start_at_login_label": "Spustiť po prihlásení do počítača", + "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.", + "start_minimized_label": "Spustiť v oblasti oznámení", + "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.", + "attention_label": "Pri novej pošte blikať na paneli úloh", + "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku", + "attention_description": "Len keď aplikácia nie je v popredí.", + "quiet_hours_label": "Čas pokoja", + "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou", + "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.", + "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.", + "default_app_label": "Predvolená poštová aplikácia", + "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.", + "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.", + "default_app_button": "Vybrať predvolenú aplikáciu…", + "new_mail": "Nová pošta", + "no_subject": "(bez predmetu)", + "action_delete": "Odstrániť", + "copy_as_file": "Kopírovať ako súbor", + "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", + "copy_failed": "Súbor sa nepodarilo skopírovať." } } diff --git a/locales/th/common.json b/locales/th/common.json index 065a7afbc..89c513d65 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6517,5 +6517,35 @@ "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" + }, + "desktopOs": { + "section_title": "แอปเดสก์ท็อป", + "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", + "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด", + "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง", + "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์", + "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้", + "start_minimized_label": "เริ่มในถาดระบบ", + "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ", + "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่", + "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่", + "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า", + "quiet_hours_label": "ช่วงเวลาเงียบ", + "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ", + "quiet_hours_from": "จาก", + "quiet_hours_to": "ถึง", + "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด", + "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้", + "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง", + "default_app_label": "แอปเมลเริ่มต้น", + "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้", + "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ", + "default_app_button": "เลือกแอปเริ่มต้น…", + "new_mail": "เมลใหม่", + "no_subject": "(ไม่มีหัวเรื่อง)", + "action_delete": "ลบ", + "copy_as_file": "คัดลอกเป็นไฟล์", + "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", + "copy_failed": "คัดลอกไฟล์ไม่ได้" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 81acedbf7..be92c4d19 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." + }, + "desktopOs": { + "section_title": "Masaüstü uygulaması", + "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", + "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et", + "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.", + "start_at_login_label": "Bilgisayarda oturum açınca başlat", + "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.", + "start_minimized_label": "Sistem tepsisinde başlat", + "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.", + "attention_label": "Yeni postada görev çubuğunu yanıp söndür", + "attention_mac_label": "Yeni postada Dock simgesini zıplat", + "attention_description": "Yalnızca uygulama önde değilken.", + "quiet_hours_label": "Sessiz saatler", + "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.", + "quiet_hours_from": "Başlangıç", + "quiet_hours_to": "bitiş", + "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster", + "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.", + "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.", + "default_app_label": "Varsayılan posta uygulaması", + "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.", + "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.", + "default_app_button": "Varsayılan uygulamayı seç…", + "new_mail": "Yeni posta", + "no_subject": "(konu yok)", + "action_delete": "Sil", + "copy_as_file": "Dosya olarak kopyala", + "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", + "copy_failed": "Dosya kopyalanamadı." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 42dbd1aba..f77d1099c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." + }, + "desktopOs": { + "section_title": "Настільний застосунок", + "section_description": "Як застосунок працює з вашим комп’ютером.", + "close_to_tray_label": "Під час закриття працювати далі в треї", + "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.", + "start_at_login_label": "Запускати під час входу в систему", + "start_at_login_description": "Застосунок запускається сам, коли ви входите.", + "start_minimized_label": "Запускати в треї", + "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.", + "attention_label": "Блимати на панелі завдань за нової пошти", + "attention_mac_label": "Підстрибувати значком у Dock за нової пошти", + "attention_description": "Лише коли застосунок не на передньому плані.", + "quiet_hours_label": "Тихі години", + "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.", + "quiet_hours_from": "З", + "quiet_hours_to": "до", + "show_shortcut_label": "Показувати застосунок сполученням клавіш", + "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.", + "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.", + "default_app_label": "Поштовий застосунок за замовчуванням", + "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.", + "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.", + "default_app_button": "Вибрати застосунок за замовчуванням…", + "new_mail": "Нова пошта", + "no_subject": "(без теми)", + "action_delete": "Видалити", + "copy_as_file": "Копіювати як файл", + "copied": "Файл скопійовано. Його можна вставити в папку.", + "copy_failed": "Не вдалося скопіювати файл." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 9cbcd8bd3..4692a979d 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6521,5 +6521,35 @@ "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." + }, + "desktopOs": { + "section_title": "Ứng dụng máy tính", + "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", + "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng", + "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.", + "start_at_login_label": "Khởi động khi đăng nhập vào máy tính", + "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.", + "start_minimized_label": "Khởi động ở khay hệ thống", + "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.", + "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới", + "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới", + "attention_description": "Chỉ khi ứng dụng không ở phía trước.", + "quiet_hours_label": "Giờ yên tĩnh", + "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.", + "quiet_hours_from": "Từ", + "quiet_hours_to": "đến", + "show_shortcut_label": "Hiện ứng dụng bằng phím tắt", + "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.", + "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.", + "default_app_label": "Ứng dụng thư mặc định", + "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.", + "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.", + "default_app_button": "Chọn ứng dụng mặc định…", + "new_mail": "Thư mới", + "no_subject": "(không có chủ đề)", + "action_delete": "Xóa", + "copy_as_file": "Sao chép dưới dạng tệp", + "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", + "copy_failed": "Không thể sao chép tệp." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5ba44316f..2e49af076 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6509,5 +6509,35 @@ "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" + }, + "desktopOs": { + "section_title": "桌面應用程式", + "section_description": "應用程式如何與您的電腦配合。", + "close_to_tray_label": "關閉時繼續在系統匣中執行", + "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。", + "start_at_login_label": "登入電腦時啟動", + "start_at_login_description": "您登入時應用程式會自動啟動。", + "start_minimized_label": "在系統匣中啟動", + "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。", + "attention_label": "有新郵件時閃爍工作列", + "attention_mac_label": "有新郵件時讓 Dock 圖示跳動", + "attention_description": "僅在應用程式不在前景時。", + "quiet_hours_label": "勿擾時段", + "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。", + "quiet_hours_from": "從", + "quiet_hours_to": "到", + "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式", + "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。", + "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。", + "default_app_label": "預設郵件應用程式", + "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。", + "default_app_is_default": "此應用程式會開啟您的郵件連結。", + "default_app_button": "選擇預設應用程式…", + "new_mail": "新郵件", + "no_subject": "(無主旨)", + "action_delete": "刪除", + "copy_as_file": "複製為檔案", + "copied": "檔案已複製。您可以將其貼到資料夾中。", + "copy_failed": "無法複製檔案。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f53c82da4..746815777 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6509,5 +6509,35 @@ "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" + }, + "desktopOs": { + "section_title": "桌面应用", + "section_description": "应用如何与你的电脑配合。", + "close_to_tray_label": "关闭时继续在托盘中运行", + "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。", + "start_at_login_label": "登录电脑时启动", + "start_at_login_description": "你登录时应用会自动启动。", + "start_minimized_label": "在托盘中启动", + "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。", + "attention_label": "有新邮件时闪烁任务栏", + "attention_mac_label": "有新邮件时让 Dock 图标跳动", + "attention_description": "仅在应用不在前台时。", + "quiet_hours_label": "免打扰时段", + "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。", + "quiet_hours_from": "从", + "quiet_hours_to": "到", + "show_shortcut_label": "使用键盘快捷键显示应用", + "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。", + "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。", + "default_app_label": "默认邮件应用", + "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。", + "default_app_is_default": "此应用会打开你的邮件链接。", + "default_app_button": "选择默认应用…", + "new_mail": "新邮件", + "no_subject": "(无主题)", + "action_delete": "删除", + "copy_as_file": "复制为文件", + "copied": "文件已复制。你可以将其粘贴到文件夹中。", + "copy_failed": "无法复制文件。" } } From b805571dec2cdb5cb633e0e9c81becc84826b99f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:30:37 +0100 Subject: [PATCH 02/36] fix: drag attachments and messages out of the desktop app with their bytes in hand --- components/email/email-viewer.tsx | 9 ++++ .../__tests__/use-email-drag-routing.test.tsx | 5 +-- hooks/use-attachment-drag.ts | 43 +++++++++++++------ hooks/use-email-drag.ts | 26 +++++++---- lib/desktop/__tests__/os-window.test.tsx | 30 ++++++++++--- lib/desktop/file-drag.ts | 26 ++++++++--- 6 files changed, 101 insertions(+), 38 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 09c925b5e..9dbde37b4 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -685,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index 242f40e5f..e3757d946 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -734,11 +734,10 @@ describe('message row drag hover wiring', () => { it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => { const email = mail({ subject: 'Hello world' }); - const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); - await warm(result); const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; - vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!)))); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); try { startDrag.mockClear(); // Plain: still a move to a folder, still DownloadURL for Explorer. diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index ce0047251..6136c4453 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -34,6 +34,8 @@ export interface AttachmentDragSource { name: string; type: string; getBlobUrl: () => Promise; + /** The bytes themselves, for the desktop app's native drag. */ + getBlob?: () => Promise; } export interface UseAttachmentDragResult { @@ -59,6 +61,8 @@ export function useAttachmentDrag( const urlRef = useRef(null); const ownedRef = useRef(false); const inFlightRef = useRef | null>(null); + const blobRef = useRef(null); + const nativeDrag = canDragNativeFiles() && !!source.getBlob; useEffect(() => { return () => { @@ -73,6 +77,19 @@ export function useAttachmentDrag( const prefetch = useCallback(() => { if (!enabled) return; + if (nativeDrag) { + if (blobRef.current || inFlightRef.current) return; + inFlightRef.current = source.getBlob!() + .then((blob) => { + blobRef.current = blob; + return null; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + return; + } if (urlRef.current || inFlightRef.current) return; inFlightRef.current = source .getBlobUrl() @@ -90,7 +107,7 @@ export function useAttachmentDrag( .finally(() => { inFlightRef.current = null; }); - }, [enabled, source]); + }, [enabled, source, nativeDrag]); const handleDragStart = useCallback( (e: DragEvent) => { @@ -98,6 +115,15 @@ export function useAttachmentDrag( const name = source.name || "download"; const type = source.type || "application/octet-stream"; + // In the desktop app the OS's own drag, with the real file on disk first. The bytes + // aren't here yet: fetch them for the next try, as below. + if (nativeDrag) { + e.preventDefault(); + if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]); + else prefetch(); + return; + } + if (!url) { // Blob isn't materialized yet. Kick off the fetch so the next attempt // works, but cancel this drag so the user doesn't get a silent failure @@ -107,13 +133,6 @@ export function useAttachmentDrag( return; } - // In the desktop app the OS's own drag, with the real file on disk first. - if (canDragNativeFiles()) { - e.preventDefault(); - void startNativeFileDrag([{ name, url }]); - return; - } - // `DownloadURL` format: ::. The filename must be // raw - URL-encoding it lands literally on disk (`%20` instead of a // space). Callers are expected to sanitise reserved chars (`:` etc.) @@ -121,7 +140,7 @@ export function useAttachmentDrag( e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`); e.dataTransfer.effectAllowed = "copyMove"; }, - [source.name, source.type, prefetch], + [source.name, source.type, prefetch, nativeDrag], ); const handleDragEnd = useCallback(() => { @@ -136,8 +155,8 @@ export function useAttachmentDrag( }, []); const copyAsFile = useCallback(async () => { - const url = urlRef.current ?? await source.getBlobUrl(); - return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false; + const blob = blobRef.current ?? await source.getBlob?.(); + return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false; }, [source]); if (!enabled) return NOOP_HANDLERS; @@ -147,6 +166,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, - ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}), + ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index b60a4e910..6debff52c 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; -import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag"; +import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -104,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions function revokeLater(url: string): void { // Chromium may read the URL asynchronously after the drop completes. - setTimeout(() => URL.revokeObjectURL(url), 60_000); + setTimeout(() => { + URL.revokeObjectURL(url); + forgetDragBlob(url); + }, 60_000); } async function buildEmailZip(emails: RoutedEmail[]): Promise { @@ -127,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise { }), ); const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" }); - return URL.createObjectURL(zipBlob); + return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob); } function prefetchEmailBundle( @@ -225,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (previous?.url) revokeLater(previous.url); const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null }; singleRef.current = entry; - entry.promise = route.client - .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId) + // The desktop app's native drag needs the bytes as well as the address. + const fetched = canDragNativeFiles() + ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob)) + : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId); + entry.promise = fetched .then((url) => { if (url) { if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url; @@ -298,9 +304,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const name = emailExportFilename(emailsToDrag[0], filenameOptions); // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has // to stay the page's, because it also moves the message to a folder. - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name, url }]); + void startNativeFileDrag([{ name, blob }]); return; } // `DownloadURL` format: ::. Chromium expects @@ -321,9 +328,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(ready.url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name: ready.name, url: ready.url }]); + void startNativeFileDrag([{ name: ready.name, blob }]); return; } e.dataTransfer.setData( diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 301ee9abc..f7f3a5012 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -200,23 +200,23 @@ describe('files on the window', () => { describe('dragging an attachment out', () => { const blobUrl = 'blob:https://app/1'; - const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl }; + const source = { + name: 'report.pdf', + type: 'application/pdf', + getBlobUrl: async () => blobUrl, + getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]), + }; async function ready() { const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); const hook = renderHook(() => useAttachmentDrag(source, true)); act(() => hook.result.current.onPointerEnter()); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); return hook; } - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3])))); - }); - it('in the app: the OS\'s own drag, with the bytes for a real file', async () => { const startDrag = vi.fn(async () => true); setBridge({ startDrag, copyFiles: vi.fn() as never }); @@ -231,6 +231,22 @@ describe('dragging an attachment out', () => { expect([...files[0].data]).toEqual([1, 2, 3]); }); + it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const hook = renderHook(() => useAttachmentDrag(source, true)); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + hook.result.current.onDragStart(event as never); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + }); + it('in a browser: still DownloadURL, and no copy button', async () => { const hook = await ready(); const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts index c3f6476a7..07084646c 100644 --- a/lib/desktop/file-drag.ts +++ b/lib/desktop/file-drag.ts @@ -6,11 +6,26 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { setOwnDragActive } from '@/lib/desktop/window-files'; +/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */ export interface DragFile { name: string; - /** A blob: URL the page already holds. */ - url?: string; - data?: Uint8Array; + blob: Blob; +} + +const keptBlobs = new Map(); + +/** In the app, remembers what's behind an object URL made for dragging. */ +export function keepDragBlob(url: string, blob: Blob): string { + if (canDragNativeFiles()) keptBlobs.set(url, blob); + return url; +} + +export function keptDragBlob(url: string): Blob | undefined { + return keptBlobs.get(url); +} + +export function forgetDragBlob(url: string): void { + keptBlobs.delete(url); } export function canDragNativeFiles(): boolean { @@ -18,10 +33,7 @@ export function canDragNativeFiles(): boolean { } async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { - if (file.data) return { name: file.name, data: file.data }; - if (!file.url) throw new Error('A file needs its bytes or a URL'); - const response = await fetch(file.url); - return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) }; + return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) }; } /** From 8e469571887c01d802760793f736d705ebb51eff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:34:29 +0100 Subject: [PATCH 03/36] feat: notify from the local store's new mail and take it offline when the machine sleeps --- lib/desktop/__tests__/os-mail.test.ts | 3 +- lib/desktop/__tests__/os-store.test.ts | 76 ++++++++++++++++++++++++ lib/desktop/__tests__/os-window.test.tsx | 3 +- lib/desktop/mail-notifications.ts | 16 +---- lib/desktop/power.ts | 34 ++--------- lib/desktop/system-events.ts | 56 +++++++++++++++++ lib/offline/offline-mode.ts | 10 ++++ 7 files changed, 155 insertions(+), 43 deletions(-) create mode 100644 lib/desktop/__tests__/os-store.test.ts create mode 100644 lib/desktop/system-events.ts diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index edfad4809..1338162d0 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -311,7 +311,8 @@ describe('from arrival to toast and sound', () => { }); it("the store's created event reaches a running watch, and nothing else", async () => { - const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events'); const notify = vi.fn(async () => ({ shown: true, playSound: false })); setBridge({ version: 1, platform: 'win32', os: { notify } as never }); client = fakeClient({ a: email('a') }); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts new file mode 100644 index 000000000..7339a0aa4 --- /dev/null +++ b/lib/desktop/__tests__/os-store.test.ts @@ -0,0 +1,76 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorePageHooks } from '@/lib/store/client/api'; + +/** The local store and the OS integration: mail it found, and a machine going to sleep. */ + +const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null })); +const setOnline = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/store/client/api', async (original) => ({ + ...(await original()), + configureStore: (hooks: StorePageHooks | null) => { + if (hooks) configured.hooks = hooks; + }, + getStore: () => ({ + supported: () => true, + setOnline, + ensureOpen: async () => {}, + refresh: () => {}, + sync: async () => {}, + stop: () => {}, + }), +})); + +describe('the store and the OS integration', () => { + const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ getClientForAccount: () => client as never } as never); + const { registerOfflineHooks } = await import('@/lib/offline/offline-mode'); + registerOfflineHooks(); + }); + + afterEach(async () => { + const { setStoreCreatedListener } = await import('../system-events'); + setStoreCreatedListener(null); + }); + + it("hands the store's new mail to the notification path, with the login's client", async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] }); + expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']); + }); + + it('other collections are not mail', async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('with nothing listening, as in a browser, it does nothing', () => { + expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow(); + }); + + it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => { + const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode'); + expect(offlineSupported()).toBe(true); + const { runSleepHandlers } = await import('../system-events'); + const stop = startOfflineMode(); + runSleepHandlers(); + expect(setOnline).toHaveBeenLastCalledWith(false); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never; + const { startPowerWatch } = await import('../power'); + const stopPower = startPowerWatch(); + expect(setOnline).toHaveBeenLastCalledWith(true); + stopPower(); + stop(); + window.bulwarkDesktop = undefined; + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index f7f3a5012..2e005476f 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -271,7 +271,8 @@ describe('waking up', () => { let fire: (event: DesktopOsPowerEvent) => void = () => {}; setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); const power = await import('../power'); - return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() }; + const events = await import('../system-events'); + return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() }; } it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => { diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 090c9ebdf..05b3303ba 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -14,6 +14,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsNotification } from '@/lib/desktop/types'; import { isAppLocked } from '@/lib/desktop/app-lock-state'; import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail'; +import { setStoreCreatedListener } from '@/lib/desktop/system-events'; import { devicesForAccount } from '@/lib/device-policy'; import { playNotificationSound } from '@/lib/notification-sound'; import { isPopoutWindow } from '@/lib/popout/window-role'; @@ -130,17 +131,6 @@ export interface MailNotifications extends NewMailWatch { setLabels(labels: MailNotificationLabels): void; } -let current: MailNotifications | null = null; - -/** - * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once - * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`. - * Does nothing where notifications don't run: in a browser, in a pop-out. - */ -export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void { - current?.noteCreated(client, accountId, ids); -} - export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { let labels = initial; let batch: NewMail[] = []; @@ -176,9 +166,9 @@ export function startMailNotifications(initial: MailNotificationLabels): MailNot watch.stop(); if (timer) clearTimeout(timer); timer = null; - if (current === handle) current = null; + setStoreCreatedListener(null); }, }; - current = handle; + setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids)); return handle; } diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 84d5ccd58..44d429dfc 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -4,46 +4,24 @@ * changes and replaces a dead stream, the identities and policy refresh. Those are the * handlers already listening, so this only gives them the word. The hidden main window * of the desktop app never goes out of view, so without this they wouldn't hear it. - * - * The local store registers with addWakeHandler and addSleepHandler (setOnline, sync). + * The local store hangs on the same two: `online` is setOnline(true) and a flush, + * `visibilitychange` is a sync per account. It takes itself offline for a sleep through + * addSleepHandler, in lib/desktop/system-events.ts. */ import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; import { usePolicyStore } from '@/stores/policy-store'; -type Handler = () => void; - -const wakeHandlers = new Set(); -const sleepHandlers = new Set(); /** A resume and an unlock usually come together. */ const WAKE_REPEAT_MS = 2000; -export function addWakeHandler(handler: Handler): () => void { - wakeHandlers.add(handler); - return () => wakeHandlers.delete(handler); -} - -export function addSleepHandler(handler: Handler): () => void { - sleepHandlers.add(handler); - return () => sleepHandlers.delete(handler); -} - -function runAll(handlers: Set): void { - for (const handler of [...handlers]) { - try { - handler(); - } catch (error) { - console.error('A wake or sleep handler failed:', error); - } - } -} - function wake(): void { document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('online')); const policy = usePolicyStore.getState(); if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); - runAll(wakeHandlers); + runWakeHandlers(); } export function startPowerWatch(): () => void { @@ -58,7 +36,7 @@ export function startPowerWatch(): () => void { }; const stopPower = bridge.onPower((event) => { if (event === 'resume' || event === 'unlock-screen') wakeOnce(); - else if (event === 'suspend') runAll(sleepHandlers); + else if (event === 'suspend') runSleepHandlers(); }); // Wi-Fi to cable is a new network with no offline moment in between. const connection = (navigator as unknown as { connection?: EventTarget }).connection; diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts new file mode 100644 index 000000000..f1b258077 --- /dev/null +++ b/lib/desktop/system-events.ts @@ -0,0 +1,56 @@ +/** + * What the local store and the OS integration say to each other, with nothing imported + * either way: lib/offline runs in every browser, and the notification code pulls in half + * the app's stores. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Handler = () => void; +type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +let createdListener: CreatedListener | null = null; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => { + wakeHandlers.delete(handler); + }; +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => { + sleepHandlers.delete(handler); + }; +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +export function runWakeHandlers(): void { + runAll(wakeHandlers); +} + +export function runSleepHandlers(): void { + runAll(sleepHandlers); +} + +/** The notification path, while it runs. */ +export function setStoreCreatedListener(listener: CreatedListener | null): void { + createdListener = listener; +} + +/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */ +export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + createdListener?.(client, accountId, ids); +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 836e1286b..beff3d52e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -60,6 +60,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -180,6 +181,10 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + if (event.type === 'created' && event.collection === 'Email') { + const client = onlineClientFor(event.account); + if (client) reportStoreCreatedMail(client, event.jmap, event.ids); + } if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); forgetMailSnapshot(event.account); @@ -906,6 +911,11 @@ export function startOfflineMode(): () => void { window.removeEventListener('offline', onOffline); }); + // The desktop app going to sleep. Waking comes with the two events above and below. + cleanups.push(addSleepHandler(() => { + void getStore().setOnline(false).catch(() => {}); + })); + // Back in front: catch up now rather than at the next interval. const onVisible = () => { if (document.visibilityState !== 'visible') return; From b2931ced3ed784204d9d6d15196111a5eea23e7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:44:02 +0100 Subject: [PATCH 04/36] feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft --- app/(main)/[locale]/popout/spare/page.tsx | 7 ++ .../__tests__/composer-close-guard.test.tsx | 35 +++++- components/email/email-composer.tsx | 41 ++++++- .../__tests__/popout-compose-app.test.tsx | 45 ++++++++ components/popout/popout-compose-app.tsx | 64 ++++++++++- components/popout/popout-spare-app.tsx | 45 ++++++++ .../settings/desktop-window-settings.tsx | 26 +++++ components/settings/general-settings.tsx | 2 + lib/desktop/preferences.ts | 71 +++++++++++++ lib/desktop/types.ts | 100 +++++++++++++++++- lib/desktop/window-restore.ts | 40 +++++++ lib/draft-composer-data.ts | 60 +++++++++++ lib/popout/open.ts | 6 ++ lib/popout/window-role.ts | 10 +- locales/ar/common.json | 15 +++ locales/ca/common.json | 15 +++ locales/cs/common.json | 15 +++ locales/da/common.json | 15 +++ locales/de/common.json | 15 +++ locales/en/common.json | 15 +++ locales/es/common.json | 15 +++ locales/et/common.json | 15 +++ locales/fa/common.json | 15 +++ locales/fr/common.json | 15 +++ locales/he/common.json | 15 +++ locales/hu/common.json | 15 +++ locales/it/common.json | 15 +++ locales/ja/common.json | 15 +++ locales/ko/common.json | 15 +++ locales/lv/common.json | 15 +++ locales/mn/common.json | 15 +++ locales/nb/common.json | 15 +++ locales/nl/common.json | 15 +++ locales/pl/common.json | 15 +++ locales/pt/common.json | 15 +++ locales/ro/common.json | 15 +++ locales/ru/common.json | 15 +++ locales/sk/common.json | 15 +++ locales/th/common.json | 15 +++ locales/tr/common.json | 15 +++ locales/uk/common.json | 15 +++ locales/vi/common.json | 15 +++ locales/zh-TW/common.json | 15 +++ locales/zh/common.json | 15 +++ 44 files changed, 987 insertions(+), 15 deletions(-) create mode 100644 app/(main)/[locale]/popout/spare/page.tsx create mode 100644 components/popout/popout-spare-app.tsx create mode 100644 components/settings/desktop-window-settings.tsx create mode 100644 lib/desktop/preferences.ts create mode 100644 lib/desktop/window-restore.ts create mode 100644 lib/draft-composer-data.ts diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -0,0 +1,7 @@ +import { PopoutSpareApp } from "@/components/popout/popout-spare-app"; + +// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in +// Lite: scripts/lite removes this tree. +export default function PopoutSparePage() { + return ; +} diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; @@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); + describe('in the desktop app', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const nativeBridge = (answer: number) => { + const confirm = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never; + return confirm; + }; + + it('asks through the OS, and shows no dialog of its own', async () => { + const confirm = nativeBridge(2); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // The last button is Cancel: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard discards, and the follow-up runs', async () => { + nativeBridge(1); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); + it('runs the follow-up after discarding', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..2e25fdb4d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,8 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -1936,6 +1938,14 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A composer that comes back with the app: a draft it opened on counts from the start. + // Gone with the composer: sent, discarded or closed. + useEffect(() => { + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + return () => noteOpenDraft(effectiveSessionKey, null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveSessionKey]); + // Auto-save draft to server on page close (best-effort) const ownWindowRef = useRef(ownWindow); ownWindowRef.current = ownWindow; @@ -3005,6 +3015,8 @@ export function EmailComposer({ draftIdRef.current = savedDraftId; draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; + // Before a save's promise settles: quitting waits for it, then keeps this list. + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -4418,6 +4430,29 @@ export function EmailComposer({ emitClose(); }; + /** The question before closing or discarding: the OS's own in the desktop app. */ + const openCloseDialog = (kind: 'discard' | 'close') => { + const dialogs = getDesktopBridge()?.dialogs; + if (!dialogs) { + setCloseDialogKind(kind); + setShowCloseDialog(true); + return; + } + const discarding = kind === 'discard'; + const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')]; + void dialogs.confirm({ + type: 'warning', + message: discarding ? t('discard_draft_title') : t('close_draft_title'), + detail: discarding ? t('discard_draft_message') : t('close_draft_message'), + buttons, + defaultId: discarding ? 1 : 0, + }).then((choice) => { + if (buttons[choice] === t('discard')) handleDiscardAndClose(); + else if (!discarding && choice === 0) void handleSaveDraftAndClose(); + else afterCloseRef.current = null; + }); + }; + // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. @@ -4429,8 +4464,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4442,8 +4476,7 @@ export function EmailComposer({ && !saveFailedRef.current && !attachmentsRef.current.some(att => att.uploading || att.error); if (isDirtyRef.current && !savedAlready) { - setCloseDialogKind('close'); - setShowCloseDialog(true); + openCloseDialog('close'); } else { cleanClose(); } diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 3ff5cf883..2a02faf0f 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -67,6 +67,51 @@ function openAt(handoffId: string) { render(); } +describe('a composer coming back on its draft', () => { + const closeSelf = vi.fn(); + + beforeEach(() => { + closeSelf.mockClear(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const draft = { + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], + }; + + const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { + useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', `/en/popout/compose?${search}`); + render(); + }; + + it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { + const getEmail = vi.fn(async () => draft); + openDraft(getEmail); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); + expect(getEmail).toHaveBeenCalledWith('d1', 'group'); + // Saving goes over this draft, not next to it, and its files stay. + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); + }); + + it('a draft that has gone since (sent elsewhere) closes the window', async () => { + openDraft(vi.fn(async () => null)); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { + openDraft(vi.fn(async () => { throw new Error('offline'); })); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + }); +}); + describe('PopoutComposeApp', () => { it('tells the opening window to let go only with the composer on screen', async () => { const handoff = createComposeHandoff()!; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 6f0bebeff..7aaa4f709 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,6 +18,8 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; +import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { useIdentityStore } from "@/stores/identity-store"; import { PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -31,10 +33,60 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; -function readLaunch(): { handoffId: string | null; accountId?: string } { +function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); - return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined }; + return { + handoffId: search.get("handoff"), + accountId: search.get("account") ?? undefined, + draftId: search.get("draft") ?? undefined, + ownerAccountId: search.get("owner") ?? undefined, + }; +} + +/** + * The composer that was open when the desktop app quit, back on its draft. A draft + * that has gone since (sent or deleted elsewhere) means there's nothing to show. + */ +function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { + draftId: string; + accountId?: string; + ownerAccountId?: string; + onReady: (payload: ComposePopoutPayload) => void; +}) { + const t = useTranslations(); + const tWindows = useTranslations("pwaWindows"); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); + const identities = useIdentityStore((s) => s.identities); + useEffect(() => { + if (!client) return; + let cancelled = false; + void client.getEmail(draftId, ownerAccountId).then((draft) => { + if (cancelled) return; + if (!draft) { + closePopoutWindow(); + return; + } + onReady({ + mode: "compose", + initialData: composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }), + }); + }).catch((error) => { + debug.error("Failed to reopen a draft:", error); + if (!cancelled) closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + // Once per client: the draft doesn't change under a window still opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, draftId, ownerAccountId]); + return ; } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ @@ -181,8 +233,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; /** `/popout/compose`: a draft handed over by another window, or a new message. */ export function PopoutComposeApp() { const t = useTranslations("pwaWindows"); - const [{ handoffId, accountId }] = useState(readLaunch); - const [payload, setPayload] = useState(handoffId ? null : EMPTY_PAYLOAD); + const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch); + const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD); useEffect(() => { if (!handoffId) return; @@ -216,7 +268,9 @@ export function PopoutComposeApp() { return ( - {payload ? : } + {payload ? + : draftId ? + : } ); } diff --git a/components/popout/popout-spare-app.tsx b/components/popout/popout-spare-app.tsx new file mode 100644 index 000000000..26f67104e --- /dev/null +++ b/components/popout/popout-spare-app.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { withBasePath } from "@/lib/browser-navigation"; +import { PopoutComposeApp } from "./popout-compose-app"; +import { PopoutMessageApp } from "./popout-message-app"; + +/** The message id a pop-out address names. */ +function messageIdOf(pathname: string): string { + const segments = pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : ""; +} + +/** + * The desktop app's warm spare: a hidden window with the pop-out code loaded and + * nothing signed in. When a pop-out is wanted the shell gives it an address. The + * address bar changes without a load, which is where the pop-outs read their + * arguments from, and the right one renders in a window that's already up. + */ +export function PopoutSpareApp() { + const [target, setTarget] = useState(null); + + useEffect(() => { + const spare = getDesktopBridge()?.windows?.spare; + if (!spare) { + // Someone typed the address, or a browser followed a link. + window.location.replace(withBasePath("/")); + return; + } + const stop = spare.onActivate((url) => { + const next = new URL(url, window.location.href); + window.history.replaceState(null, "", `${next.pathname}${next.search}`); + setTarget(next); + spare.taken(); + }); + spare.ready(); + return stop; + }, []); + + if (!target) return
; + if (/\/popout\/compose\/?$/.test(target.pathname)) return ; + return ; +} diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx new file mode 100644 index 000000000..3d604728c --- /dev/null +++ b/components/settings/desktop-window-settings.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { hasDesktopWindows } from "@/lib/popout/support"; +import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** How the desktop app's windows behave. Only there: a browser has nothing to set. */ +export function DesktopWindowSettings() { + const t = useTranslations("desktopUi.settings"); + const prefs = useDesktopPrefs(); + const [shown, setShown] = useState(false); + useEffect(() => setShown(hasDesktopWindows()), []); + if (!shown) return null; + return ( + + + setDesktopPref("openOnDoubleClick", checked)} /> + + + setDesktopPref("restoreWindows", checked)} /> + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..7dc969209 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; +import { DesktopWindowSettings } from './desktop-window-settings'; /** * The General page: language and time, which links the browser hands to @@ -26,6 +27,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + )}
+ {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index 7f4e306fa..df2f7d335 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -1,6 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopMenuBarEntry } from "@/lib/desktop/types"; import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay"; /** @@ -12,10 +14,46 @@ import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/ * Renders nothing, and changes nothing on the page, without an overlay. It * repeats the title for sighted users only, so it stays out of the * accessibility tree and out of the tab order. + * + * In the desktop app on Windows, Alt swaps the title for the menu's names, since a + * window without a native frame has no native bar. Each name opens the app's menu + * as a native one (src/main/menu.ts in the desktop repo). */ export function WindowTitleBar() { const [area, setArea] = useState(null); const [title, setTitle] = useState(""); + const [entries, setEntries] = useState(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); + if (!button) return; + const rect = button.getBoundingClientRect(); + getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom }); + }, []); + + useEffect(() => { + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return; + return bar.onEvent((event) => { + if (!event.visible) { + setEntries(null); + previousFocus.current?.focus?.(); + previousFocus.current = null; + return; + } + void bar.entries().then((list) => { + previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; + setEntries(list); + // Once the names are on screen. + window.setTimeout(() => { + if (event.open) openMenu(event.open); + else stripRef.current?.querySelector("button")?.focus(); + }, 0); + }); + }); + }, [openMenu]); useEffect(() => watchTitlebarArea((next) => { applyTitlebarInset(next); @@ -35,6 +73,52 @@ export function WindowTitleBar() { if (!area) return null; + const onStripKey = (event: React.KeyboardEvent) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("button") ?? []); + const at = buttons.indexOf(document.activeElement as HTMLElement); + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + event.preventDefault(); + const delta = event.key === "ArrowRight" ? 1 : -1; + buttons[(at + delta + buttons.length) % buttons.length]?.focus(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + buttons[at]?.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + setEntries(null); + previousFocus.current?.focus?.(); + previousFocus.current = null; + } + }; + + if (entries) { + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + } + return ( - , + + , document.body ); } @@ -578,11 +618,11 @@ export function ContextMenuItem({ {Icon && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )} {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} {shortcut && ( @@ -625,6 +665,7 @@ export function ContextMenuSubMenu({ hint, }: ContextMenuSubMenuProps) { const parent = useContext(MenuLevelContext); + const native = useContext(NativeMenuContext); const id = useId(); // Outside a ContextMenu (a host's own panel) it keeps its own state. const [localOpen, setLocalOpen] = useState(false); @@ -704,16 +745,21 @@ export function ContextMenuSubMenu({ {Icon && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )}
- {isOpen && !disabled && ( + {native && !disabled && ( + + )} + {!native && isOpen && !disabled && ( +
{children}
); diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index dfdc02348..eb412b42d 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import type { ContextMenuPosition } from "@/components/ui/context-menu"; +import { nativeMenuIsOpen } from "@/lib/desktop/native-menu"; interface ContextMenuState { isOpen: boolean; @@ -134,12 +135,14 @@ export function useContextMenu(): UseContextMenuReturn { }; const handleClickOutside = (e: MouseEvent) => { + if (nativeMenuIsOpen()) return; if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; const handleScroll = (e: Event) => { + if (nativeMenuIsOpen()) return; if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) { return; } @@ -150,6 +153,8 @@ export function useContextMenu(): UseContextMenuReturn { // A focused control in the menu may have opened a system dialog that // took the focus from the window, such as a color input's (#1366). if (menuRef.current?.contains(document.activeElement)) return; + // In the desktop app the menu is native: it closes itself, and says what was picked. + if (nativeMenuIsOpen()) return; closeContextMenu(); }; diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx new file mode 100644 index 000000000..2bab20eb1 --- /dev/null +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -0,0 +1,301 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types'; +import { accentColours, applySystemAccent } from '../system-accent'; +import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands'; +import { readMenu } from '../native-menu'; +import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore'; +import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences'; +import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role'; + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +afterEach(() => { + setBridge(undefined); + resetDesktopCommands(); + resetOpenDrafts(); + localStorage.clear(); + resetDesktopPrefsCache(); + vi.useRealTimers(); +}); + +function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox?: () => void } = {}) { + return ( + <> + Three messages + {})} shortcut="R" hint="to the sender" /> + {}} disabled /> + + + {})} /> + {}} /> + + + {}} /> + + {extra} + + ); +} + +describe('an HTML menu shown natively', () => { + let popup: ReturnType; + let answer: (id: string | null) => void; + + beforeEach(() => { + popup = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + }); + + async function openMenu(children: React.ReactNode, onClose = vi.fn()) { + render({children}); + await waitFor(() => expect(popup).toHaveBeenCalled()); + return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose }; + } + + it('reads every kind of row, in order, with submenus', async () => { + const { items } = await openMenu(testMenu()); + expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']); + expect(items[0].enabled).toBe(false); + expect(items[2].enabled).toBe(false); + expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']); + // A disabled submenu shows, and has nothing in it. + expect(items[5].enabled).toBe(false); + expect(items[5].submenu).toBeUndefined(); + }); + + it('takes the label without the shortcut or the hint', async () => { + const { items } = await openMenu(testMenu()); + expect(items[1].label).toBe('Reply'); + }); + + it('at the pointer from a right-click, under the button from a button', async () => { + const { at } = await openMenu(testMenu()); + expect(at).toBeUndefined(); + popup.mockClear(); + render( + {}}> + {}} /> + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 }); + }); + + it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => { + const reply = vi.fn(); + const inbox = vi.fn(); + const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox })); + await act(async () => { + answer(items[4].submenu![0].id!); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(inbox).toHaveBeenCalledTimes(1); + expect(reply).not.toHaveBeenCalled(); + }); + + it('dismissing it runs nothing and closes the menu', async () => { + const reply = vi.fn(); + const { onClose } = await openMenu(testMenu(undefined, { reply })); + await act(async () => { + answer(null); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(reply).not.toHaveBeenCalled(); + }); + + it('the hidden copy is never visible', async () => { + await openMenu(testMenu()); + const menu = document.body.querySelector('[role="menu"]'); + expect(menu?.style.visibility).toBe('hidden'); + }); + + it('stays HTML when something in it can\'t be shown natively', async () => { + render( + {}}> + {testMenu()} + , + ); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + expect(popup).not.toHaveBeenCalled(); + expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); + }); + + it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + expect(readMenu(root).unsupported).toBe(true); + }); + + it('a search field over a list is left out, and what it searches stays', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + const read = readMenu(root); + expect(read.unsupported).toBe(false); + expect(read.items.map((item) => item.label)).toEqual(['Inbox']); + }); + + it('reads check and radio rows', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + expect(readMenu(root).items).toMatchObject([ + { type: 'checkbox', checked: true, label: 'Show' }, + { type: 'radio', checked: false, label: 'Other' }, + ]); + }); +}); + +describe('the same menu in a browser', () => { + it('is the HTML menu, on screen, and no bridge is asked', async () => { + render( {}}>{testMenu()}); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + // A closed submenu's rows aren't in the page. + expect(screen.queryByRole('menuitem', { name: 'Inbox' })).toBeNull(); + }); +}); + +describe('menu commands', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('the newest surface answers, and an older one is back when it goes', () => { + const older = vi.fn(); + const newer = vi.fn(); + registerDesktopSurface({ handlers: { 'message.reply': older }, state: {} }); + const stop = registerDesktopSurface({ handlers: { 'message.reply': newer }, state: {} }); + expect(runDesktopCommand('message.reply')).toBe(true); + expect(newer).toHaveBeenCalledTimes(1); + expect(older).not.toHaveBeenCalled(); + stop(); + runDesktopCommand('message.reply'); + expect(older).toHaveBeenCalledTimes(1); + }); + + it('says when nothing answered, so the host can take it', () => { + expect(runDesktopCommand('go.calendar')).toBe(false); + }); + + it('the shell gets the state, surfaces over the base', () => { + const setState = vi.fn(); + setBridge({ version: 1, platform: 'win32', menu: { setState } }); + setDesktopBaseState({ 'message.reply': false, 'file.print': true }); + registerDesktopSurface({ handlers: {}, state: { 'message.reply': true } }); + vi.advanceTimersByTime(50); + expect(setState).toHaveBeenLastCalledWith({ 'message.reply': true, 'file.print': true }); + expect(mergedDesktopState()['file.print']).toBe(true); + }); +}); + +describe('composers that come back', () => { + it('tell the shell which draft they hold, and forget it when they go', () => { + const setRestore = vi.fn(); + setBridge({ version: 1, platform: 'win32', windows: { setRestore } }); + noteOpenDraft('a', { draftId: 'd1', localAccountId: 'login', ownerAccountId: 'group' }); + expect(setRestore).toHaveBeenLastCalledWith([expect.stringContaining('draft=d1')]); + expect(setRestore.mock.lastCall![0][0]).toContain('account=login'); + expect(setRestore.mock.lastCall![0][0]).toContain('owner=group'); + noteOpenDraft('a', { draftId: 'd2' }); + expect(setRestore.mock.lastCall![0]).toHaveLength(1); + expect(setRestore.mock.lastCall![0][0]).toContain('draft=d2'); + noteOpenDraft('a', null); + expect(setRestore).toHaveBeenLastCalledWith([]); + }); + + it('are pop-out addresses the window type check recognises', () => { + expect(restoreUrlFor({ draftId: 'x' })).toMatch(/\/popout\/compose\?draft=x$/); + }); + + it('do nothing in a browser', () => { + expect(() => noteOpenDraft('a', { draftId: 'd1' })).not.toThrow(); + }); +}); + +describe('a pop-out closing itself', () => { + it('goes through the shell in the desktop app, not window.close', () => { + const closeSelf = vi.fn(); + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + setBridge({ version: 1, platform: 'win32', windows: { closeSelf } }); + closePopoutWindow(); + expect(closeSelf).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + close.mockRestore(); + }); + + it('uses window.close in a browser', () => { + vi.useFakeTimers(); + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + closePopoutWindow(); + expect(close).toHaveBeenCalledTimes(1); + close.mockRestore(); + }); + + it('counts the warm spare as a pop-out, which is a guest of the main window', () => { + expect(isPopoutPath('/popout/spare')).toBe(true); + expect(isPopoutPath('/de/popout/spare')).toBe(true); + expect(isPopoutPath('/popout/spare/x')).toBe(false); + }); +}); + +describe('window settings', () => { + it('both default to on, and stay on this computer', () => { + expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true }); + setDesktopPref('restoreWindows', false); + resetDesktopPrefsCache(); + expect(readDesktopPrefs().restoreWindows).toBe(false); + expect(readDesktopPrefs().openOnDoubleClick).toBe(true); + }); + + it('a damaged value is the default', () => { + localStorage.setItem('bulwark-desktop-window-prefs', '{nope'); + expect(readDesktopPrefs().restoreWindows).toBe(true); + }); +}); + +describe('the OS accent colour', () => { + beforeEach(() => setBridge({ version: 1, platform: 'win32' })); + + const apply = (input: Partial[0]>) => { + const root = document.createElement('div'); + applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true, ...input }, root); + return root.style.getPropertyValue('--color-primary'); + }; + + it('is used while the theme follows the system', () => { + expect(apply({})).toBe('#0078d4'); + }); + + it('not under a theme the user picked', () => { + expect(apply({ followsSystem: false })).toBe(''); + }); + + it('not in a contrast theme', () => { + expect(apply({ highContrast: true })).toBe(''); + }); + + it('not where it would vanish against the page', () => { + expect(accentColours('#fefefe', false)).toBeNull(); + expect(accentColours('#050505', true)).toBeNull(); + }); + + it('text on it is what reads', () => { + expect(accentColours('#0078d4', false)?.foreground).toBe('#ffffff'); + expect(accentColours('#ffd400', true)?.foreground).toBe('#000000'); + }); + + it('a removed accent takes its colours off again', () => { + const root = document.createElement('div'); + applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true }, root); + applySystemAccent({ dark: false, followsSystem: true }, root); + expect(root.style.getPropertyValue('--color-primary')).toBe(''); + expect(root.style.getPropertyValue('--color-primary-foreground')).toBe(''); + }); + + it('does nothing in a browser', () => { + setBridge(undefined); + expect(apply({})).toBe(''); + }); +}); diff --git a/lib/desktop/commands.ts b/lib/desktop/commands.ts new file mode 100644 index 000000000..5013eeb66 --- /dev/null +++ b/lib/desktop/commands.ts @@ -0,0 +1,98 @@ +/** + * The desktop app's menu bar, on the webmail's side. The shell shows the menu and + * sends an id; what runs is the same handler the shortcut or button runs, from + * whichever part of the app registered it. What's on screen decides what's enabled: + * a surface reports that here, and the newest surface wins per command. + * + * Nothing happens in a browser: there's no bridge to publish to, and nobody sends + * an id. + */ + +import { useEffect, useRef } from 'react'; +import { getDesktopBridge } from './bridge'; +import type { DesktopMenuState } from './types'; + +export type DesktopCommandHandlers = Record void) | undefined>; + +interface Surface { + handlers: DesktopCommandHandlers; + state: DesktopMenuState; +} + +const surfaces = new Set(); +let base: DesktopMenuState = {}; +let timer: ReturnType | null = null; + +/** What the shell should show now: the base, with each surface over it, newest last. */ +export function mergedDesktopState(): DesktopMenuState { + const merged: DesktopMenuState = { ...base }; + for (const surface of surfaces) Object.assign(merged, surface.state); + return merged; +} + +function publish(): void { + if (timer) return; + timer = setTimeout(() => { + timer = null; + getDesktopBridge()?.menu?.setState(mergedDesktopState()); + }, 30); +} + +/** What holds whatever is on screen: signed in or not, the device policy. */ +export function setDesktopBaseState(state: DesktopMenuState): void { + base = state; + publish(); +} + +/** Runs a command from the newest surface that has it. False when none does. */ +export function runDesktopCommand(id: string): boolean { + for (const surface of [...surfaces].reverse()) { + const handler = surface.handlers[id]; + if (handler) { + handler(); + return true; + } + } + return false; +} + +export function registerDesktopSurface(surface: Surface): () => void { + surfaces.add(surface); + publish(); + return () => { + surfaces.delete(surface); + publish(); + }; +} + +function sameState(a: DesktopMenuState, b: DesktopMenuState): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * This part of the app answers these commands while `enabled`. Handlers can be new + * on every render: the newest ones run, and only a changed state goes to the shell. + */ +export function useDesktopCommands(handlers: DesktopCommandHandlers, state: DesktopMenuState, enabled = true): void { + const surfaceRef = useRef({ handlers, state }); + surfaceRef.current.handlers = handlers; + + useEffect(() => { + if (!enabled || !getDesktopBridge()?.menu) return; + return registerDesktopSurface(surfaceRef.current); + }, [enabled]); + + useEffect(() => { + if (sameState(surfaceRef.current.state, state)) return; + surfaceRef.current.state = state; + publish(); + }); +} + +/** Test helper. */ +export function resetDesktopCommands(): void { + surfaces.clear(); + base = {}; + if (timer) clearTimeout(timer); + timer = null; +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts new file mode 100644 index 000000000..ae8eca8a5 --- /dev/null +++ b/lib/desktop/native-menu.ts @@ -0,0 +1,146 @@ +/** + * Turns an HTML menu into a native one. The ContextMenu component renders as it does + * in a browser, hidden, with its submenus open. This reads the result and shows that + * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of + * entries with one set of handlers and the two can't drift. + * + * A menu with something a native one can't show (a plugin's frame, a colour picker, + * a row with no text) stays HTML. So does anything marked data-native-menu="html". + */ + +import { getDesktopBridge } from './bridge'; +import type { DesktopNativeMenuItem } from './types'; + +export function nativeMenusAvailable(): boolean { + return !!getDesktopBridge()?.menu?.popup; +} + +export interface ReadMenu { + items: DesktopNativeMenuItem[]; + /** The HTML row behind each native item's id. */ + elements: Map; + /** There's something here a native menu can't show. */ + unsupported: boolean; +} + +const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; + +function isDisabled(el: HTMLElement): boolean { + return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; +} + +function labelOf(el: HTMLElement): string { + const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? el.textContent ?? ''; + return named.replace(/\s+/g, ' ').trim(); +} + +function isHidden(el: Element): boolean { + return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none'; +} + +export function readMenu(root: HTMLElement): ReadMenu { + const elements = new Map(); + let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null; + let next = 1; + + const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { + for (const child of Array.from(container.children)) { + if (!(child instanceof HTMLElement) || isHidden(child)) continue; + if (child.getAttribute('role') === 'separator') { + into.push({ type: 'separator' }); + continue; + } + if (child.dataset.submenuPanel !== undefined) continue; + if (child.dataset.contextMenuHeader !== undefined) { + const label = labelOf(child); + if (label) into.push({ id: `h${next++}`, label, enabled: false }); + continue; + } + if (child.dataset.submenuTrigger !== undefined) { + const label = labelOf(child); + if (isDisabled(child)) { + into.push({ id: `s${next++}`, label, enabled: false }); + continue; + } + const panel = child.parentElement?.querySelector(':scope > [data-submenu-panel]'); + if (!panel) { + unsupported = true; + continue; + } + const submenu: DesktopNativeMenuItem[] = []; + walk(panel, submenu); + into.push({ label, submenu }); + continue; + } + if (child.matches(ROW)) { + // A row that opens its own popup: there's nothing to click through to. + if (child.hasAttribute('aria-haspopup')) { + unsupported = true; + continue; + } + const label = labelOf(child); + if (!label) { + // A swatch or an icon says nothing in a native menu. + unsupported = true; + continue; + } + const id = `i${next++}`; + elements.set(id, child); + const role = child.getAttribute('role'); + into.push({ + id, + label, + enabled: !isDisabled(child), + ...(role === 'menuitemcheckbox' || role === 'menuitemradio' + ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } + : {}), + }); + continue; + } + // A search field over a list is no use where you can't type. + if (child instanceof HTMLInputElement) continue; + walk(child, into); + } + }; + + const items: DesktopNativeMenuItem[] = []; + walk(root, items); + return { items, elements, unsupported }; +} + +const SETTLE_MS = 30; +const SETTLE_LIMIT_MS = 400; + +/** + * The menu once it stopped changing. Entries can render a moment after the menu + * (the mail menu waits for its data), and a native menu can't grow once it's up. + */ +export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise { + const started = Date.now(); + let previous = readMenu(root); + while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) { + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + const current = readMenu(root); + if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current; + previous = current; + } + return previous; +} + +/** Shows it and clicks the row picked. Resolves when the menu has gone. */ +export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise { + const popup = getDesktopBridge()?.menu?.popup; + if (!popup) return; + document.documentElement.dataset.nativeMenuOpen = ''; + try { + const picked = await popup(read.items, at); + if (picked) read.elements.get(picked)?.click(); + } finally { + delete document.documentElement.dataset.nativeMenuOpen; + } +} + +/** A native menu is up: the window's blur or a stray event says nothing about it. */ +export function nativeMenuIsOpen(): boolean { + return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined; +} diff --git a/lib/desktop/system-accent.ts b/lib/desktop/system-accent.ts new file mode 100644 index 000000000..49f575429 --- /dev/null +++ b/lib/desktop/system-accent.ts @@ -0,0 +1,69 @@ +/** + * The OS accent colour, when the user's theme is "system". Buttons and selection take + * it the way native controls do. Not under a theme the user chose, and not when the + * colour would be hard to see: the app's own stays then. + */ + +import { getDesktopBridge } from './bridge'; + +const VARIABLES = ['--color-primary', '--color-ring', '--color-primary-foreground'] as const; + +function channels(hex: string): [number, number, number] | null { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!match) return null; + return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]; +} + +function luminance([r, g, b]: [number, number, number]): number { + const [lr, lg, lb] = [r, g, b].map((value) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +function contrast(a: number, b: number): number { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** Non-text contrast, WCAG 1.4.11. */ +const MIN_AGAINST_BACKGROUND = 3; +const MIN_TEXT = 4.5; + +/** + * The colours to use, or null for the app's own: the accent has to stand out from the + * page, and text on it has to be readable in either black or white. + */ +export function accentColours(accent: string, dark: boolean): { primary: string; foreground: string } | null { + const rgb = channels(accent); + if (!rgb) return null; + const own = luminance(rgb); + // The standard theme's page backgrounds. + const page = dark ? 0.0091 : 1; + if (contrast(own, page) < MIN_AGAINST_BACKGROUND) return null; + const onWhite = contrast(own, 1); + const onBlack = contrast(own, 0); + if (Math.max(onWhite, onBlack) < MIN_TEXT) return null; + return { primary: accent, foreground: onWhite >= MIN_TEXT ? '#ffffff' : '#000000' }; +} + +export interface AccentInput { + accent?: string; + /** Whether the page is dark now. */ + dark: boolean; + /** The user's theme setting is "system" and no installed theme is active. */ + followsSystem: boolean; + /** A contrast theme has its own colours and they win. */ + highContrast?: boolean; +} + +export function applySystemAccent({ accent, dark, followsSystem, highContrast }: AccentInput, root: HTMLElement = document.documentElement): void { + const colours = accent && followsSystem && !highContrast && getDesktopBridge() ? accentColours(accent, dark) : null; + if (!colours) { + for (const name of VARIABLES) root.style.removeProperty(name); + return; + } + root.style.setProperty('--color-primary', colours.primary); + root.style.setProperty('--color-ring', colours.primary); + root.style.setProperty('--color-primary-foreground', colours.foreground); +} From fb0eeca9c50b5a9d49fe1f0d189436d316df78d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:49:25 +0100 Subject: [PATCH 06/36] fix: put the focus on the desktop menu strip once its names are on screen --- components/layout/window-title-bar.tsx | 18 +++-- lib/__tests__/window-title-bar.test.tsx | 95 ++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index df2f7d335..c2fd2ce84 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -25,6 +25,8 @@ export function WindowTitleBar() { const [entries, setEntries] = useState(null); const stripRef = useRef(null); const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); const openMenu = useCallback((id: string) => { const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); @@ -45,15 +47,19 @@ export function WindowTitleBar() { } void bar.entries().then((list) => { previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; + afterShow.current = event.open ?? null; setEntries(list); - // Once the names are on screen. - window.setTimeout(() => { - if (event.open) openMenu(event.open); - else stripRef.current?.querySelector("button")?.focus(); - }, 0); }); }); - }, [openMenu]); + }, []); + + useEffect(() => { + const next = afterShow.current; + if (!entries || next === undefined) return; + afterShow.current = undefined; + if (next) openMenu(next); + else stripRef.current?.querySelector("button")?.focus(); + }, [entries, openMenu]); useEffect(() => watchTitlebarArea((next) => { applyTitlebarInset(next); diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx index b0d4bb11b..e785ffb33 100644 --- a/lib/__tests__/window-title-bar.test.tsx +++ b/lib/__tests__/window-title-bar.test.tsx @@ -1,7 +1,7 @@ -import { act, cleanup, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { WindowTitleBar } from '@/components/layout/window-title-bar'; import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay'; @@ -30,6 +30,97 @@ afterEach(() => { document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR); }); +describe('the menu strip in the desktop app', () => { + type BarEvent = { visible: boolean; open?: string }; + + function withBar() { + let send: (event: BarEvent) => void = () => {}; + const open = vi.fn(); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { + setState: () => {}, + bar: { + entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }], + open, + onEvent: (listener: (event: BarEvent) => void) => { + send = listener; + return () => {}; + }, + }, + }, + } as never; + return { open, send: (event: BarEvent) => send(event) }; + } + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const overlayOn = () => { + Object.defineProperty(navigator, 'windowControlsOverlay', { + configurable: true, + value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }), + }); + }; + + afterEach(() => { + delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay; + }); + + it('Alt shows the names with the focus on the first, and Escape gives it back', async () => { + overlayOn(); + const bar = withBar(); + const before = document.createElement('button'); + document.body.appendChild(before); + before.focus(); + render(); + await act(async () => bar.send({ visible: true })); + const file = await screen.findByRole('menuitem', { name: 'File' }); + await waitFor(() => expect(document.activeElement).toBe(file)); + fireEvent.keyDown(file, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' })); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menubar')).toBeNull(); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it('a name opens that menu natively', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); + expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + }); + + it('Alt and a letter opens that one right away', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true, open: 'file' })); + await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything())); + }); + + it('the shell can take it away again', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + await screen.findByRole('menubar'); + await act(async () => bar.send({ visible: false })); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('with no bridge there is none, and the title stays', async () => { + overlayOn(); + render(); + expect(screen.queryByRole('menubar')).toBeNull(); + }); +}); + describe('the title bar', () => { it('shows nothing and touches nothing without the API', () => { installOverlay(undefined); From 42263e856dc8e46e21eb4c9542da2cf8e6139b75 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:54:17 +0100 Subject: [PATCH 07/36] fix: still notify about mail the local store hadn't got when the push arrived --- lib/desktop/__tests__/os-mail.test.ts | 21 +++++++++++++++++++++ lib/desktop/new-mail.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index 1338162d0..ca3f25bf1 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -166,6 +166,27 @@ describe('the watch', () => { watch.stop(); }); + it('a message the store has not got yet is not written off: its created event still tells it', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const held: Record = {}; + const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + // The push got here first. The device answers, and it doesn't have the message. + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(seen).toEqual([]); + // The store has synced it now. + held.a = email('a'); + watch.noteCreated(fake, 'acc', ['a']); + await flush(); + expect(seen).toEqual(['a']); + watch.stop(); + }); + it('without a baseline the first push only sets one: old mail is not news', async () => { const { startNewMailWatch } = await import('../new-mail'); const { emitPushStateChange } = await import('@/lib/push-events'); diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts index 31a6568ea..efe23173d 100644 --- a/lib/desktop/new-mail.ts +++ b/lib/desktop/new-mail.ts @@ -81,9 +81,11 @@ export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWat const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => { const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`)); if (fresh.length === 0 || stopped) return; - for (const id of fresh) seen.push(`${accountId}/${id}`); - seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]); + // Only what came back counts as told. With the store on, a push can overtake it: the + // device doesn't have the message yet, and its created event has to be able to tell it. + for (const email of emails) seen.push(`${accountId}/${email.id}`); + seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); const own = client.getAccountId(); const mail = emails .filter((email) => notifyingMailbox(email, accountId, own, mailboxes)) From 3dc9e11b4db65071f63ed3b88abc05507c627014 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:32:51 +0100 Subject: [PATCH 08/36] test: give the store and OS integration test time for its first import --- lib/desktop/__tests__/os-store.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index 7339a0aa4..cd876cfd2 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -4,6 +4,9 @@ import type { StorePageHooks } from '@/lib/store/client/api'; /** The local store and the OS integration: mail it found, and a machine going to sleep. */ +// The first import of offline-mode compiles a good part of the app. +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null })); const setOnline = vi.hoisted(() => vi.fn(async () => {})); From 41fcbba61c63d119f0a31763b336f0a0ddfc848a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:24:14 +0100 Subject: [PATCH 09/36] fix: keep sender and subject off the OS until they're known to be allowed, leave nothing on it after sign-out, and take files only where they're meant --- components/desktop/desktop-os-host.tsx | 10 +- components/email/email-composer.tsx | 7 +- components/email/email-viewer.tsx | 22 ++- .../protocol-launch-handler-provider.tsx | 2 + lib/desktop/__tests__/os-commands.test.ts | 30 +++- lib/desktop/__tests__/os-mail.test.ts | 11 ++ lib/desktop/__tests__/os-store.test.ts | 11 ++ lib/desktop/__tests__/os-window.test.tsx | 168 +++++++++++++++++- lib/desktop/bridge.ts | 5 + lib/desktop/mail-notifications.ts | 12 +- lib/desktop/os-commands.ts | 9 + lib/desktop/os-summary.ts | 26 ++- lib/desktop/power.ts | 24 ++- lib/desktop/types.ts | 11 +- lib/desktop/window-files.ts | 45 ++++- lib/offline/offline-mode.ts | 3 + lib/sign-out-cleanup.ts | 2 + locales/ar/common.json | 31 ++++ locales/ca/common.json | 31 ++++ locales/cs/common.json | 31 ++++ locales/da/common.json | 31 ++++ locales/de/common.json | 31 ++++ locales/en/common.json | 31 ++++ locales/es/common.json | 31 ++++ locales/et/common.json | 31 ++++ locales/fa/common.json | 31 ++++ locales/fr/common.json | 31 ++++ locales/he/common.json | 31 ++++ locales/hu/common.json | 31 ++++ locales/it/common.json | 31 ++++ locales/ja/common.json | 31 ++++ locales/ko/common.json | 31 ++++ locales/lv/common.json | 31 ++++ locales/mn/common.json | 31 ++++ locales/nb/common.json | 31 ++++ locales/nl/common.json | 31 ++++ locales/pl/common.json | 31 ++++ locales/pt/common.json | 31 ++++ locales/ro/common.json | 31 ++++ locales/ru/common.json | 31 ++++ locales/sk/common.json | 31 ++++ locales/th/common.json | 31 ++++ locales/tr/common.json | 31 ++++ locales/uk/common.json | 31 ++++ locales/vi/common.json | 31 ++++ locales/zh-TW/common.json | 31 ++++ locales/zh/common.json | 31 ++++ 47 files changed, 1277 insertions(+), 51 deletions(-) diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index c8d2755d8..2e4ea7fd6 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -4,7 +4,8 @@ import { useEffect, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { getDesktopBridge } from "@/lib/desktop/bridge"; -import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; +import { isAppLocked } from "@/lib/desktop/app-lock-state"; +import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; import { startWindowFiles } from "@/lib/desktop/window-files"; @@ -49,7 +50,10 @@ export function DesktopOsHost() { labelsRef.current = labels; useEffect(() => { - if (!getDesktopBridge()?.os) return; + const os = getDesktopBridge()?.os; + if (!os) return; + // Main shows no content anywhere until this page has said it may. + os.setLocked(isAppLocked()); const mail = startMailNotifications(labelsRef.current); mailRef.current = mail; const summary = startOsSummary(() => localeRef.current); @@ -64,7 +68,7 @@ export function DesktopOsHost() { const event = "eventId" in view; void getDesktopBridge()?.os?.notify({ id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, - title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), + title: (contentHiddenFor(useAuthStore.getState().activeAccountId) ? "" : event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), body: "", context: "{}", }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4c02f4eac..f0dab3ae6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2650,8 +2650,11 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); - // The desktop app: files dropped or pasted anywhere on the window come here. - useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]); + // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + useEffect(() => setComposerFileTarget( + (files) => void addFiles(files), + () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + ), [addFiles, composerRootEl]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9dbde37b4..22d9f7730 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -685,15 +685,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', - getBlob: async () => { - if (attachment.blobId && client) { - return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); - } - const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); - if (!bytes || bytes.byteLength === 0) return null; - const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; - return new Blob([buffer], { type: inertBlobType(attachment.type) }); - }, + // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it + // stays with the browser's drag, which only writes it where the user drops it. + ...(attachment.decryptedAttachment ? {} : { + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData; + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, + }), getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 0078cd8bf..f3940efaa 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -111,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const router = useRouter(); const pathname = usePathname(); const tLaunch = useTranslations("pwaLaunch"); + const tDesktop = useTranslations("desktopOs"); const sentToTarget = useRef(new Set()); const { dialogProps, confirm } = useConfirmDialog(); const [offer, setOffer] = useState(null); @@ -213,6 +214,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle if (osEnv.current.pathname !== path) osEnv.current.push(path); }, notifyFailed: () => toast.error(tLaunch("open_failed")), + notifyReplyLost: () => toast.error(tDesktop("reply_lost")), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 465180c64..d3f717f64 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -33,7 +33,8 @@ async function fingerprint() { const leaveFor = vi.fn(); const push = vi.fn(); const notifyFailed = vi.fn(); -const env = { leaveFor, push, notifyFailed }; +const notifyReplyLost = vi.fn(); +const env = { leaveFor, push, notifyFailed, notifyReplyLost }; const focus = vi.fn(); async function run(command: DesktopOsCommand) { @@ -191,6 +192,33 @@ describe('a toast\'s buttons', () => { stop(); }); + it('behind the app lock an old toast acts on nothing, and the window comes up', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + setAppLockCheck(() => true); + for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) { + await run({ type: 'notification', action, context: await context(), reply: 'x' }); + } + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + setAppLockCheck(null); + stop(); + }); + + it('typed text that has nowhere to go says so', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const stop = setMailLinkOpener(vi.fn()); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' }); + expect(notifyReplyLost).toHaveBeenCalled(); + notifyReplyLost.mockClear(); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) }); + expect(notifyReplyLost).not.toHaveBeenCalled(); + stop(); + }); + it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => { const { setMailLinkOpener } = await import('../mail-links'); const opener = vi.fn(); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index ca3f25bf1..da28971f6 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -87,6 +87,8 @@ beforeEach(async () => { }); const { setAppLockCheck } = await import('../app-lock-state'); setAppLockCheck(null); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); }); afterEach(() => { @@ -266,6 +268,15 @@ describe('what the notification says', () => { expect(JSON.stringify(notification)).not.toContain('Alice Sender'); }); + it('before the policy is known: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: false }); + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + it('several at once are told about once, and that one has no message to act on', async () => { const { buildMailNotification } = await import('../mail-notifications'); const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index cd876cfd2..e09aee90d 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -49,6 +49,17 @@ describe('the store and the OS integration', () => { expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']); }); + it('a wipe clears what the OS shows, and a browser has nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { wipeAccount } = await import('@/lib/offline/offline-mode'); + await wipeAccount('alice@one').catch(() => {}); + expect(clearSurfaces).toHaveBeenCalled(); + window.bulwarkDesktop = undefined; + }); + it('other collections are not mail', async () => { const { setStoreCreatedListener } = await import('../system-events'); const listener = vi.fn(); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 2e005476f..b6df496bb 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -159,7 +159,7 @@ describe('files on the window', () => { setBridge({}); const stop = startWindowFiles(); const target = vi.fn(); - const clear = setComposerFileTarget(target); + const clear = setComposerFileTarget(target, () => true); window.dispatchEvent(dropEvent('drop', [file])); expect(target).toHaveBeenCalledWith([file]); expect(takeHandoff('compose')).toBeNull(); @@ -167,6 +167,20 @@ describe('files on the window', () => { stop(); }); + it('a composer out of sight on a hidden tab takes nothing', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const hidden = vi.fn(); + const clear = setComposerFileTarget(hidden, () => false); + window.dispatchEvent(dropEvent('drop', [file])); + expect(hidden).not.toHaveBeenCalled(); + expect(takeHandoff('compose')?.files).toEqual([file]); + clear(); + stop(); + }); + it('a pasted file is taken; pasted text is not', async () => { const { startWindowFiles } = await import('../window-files'); const { takeHandoff } = await import('@/lib/launch-handoff'); @@ -174,7 +188,7 @@ describe('files on the window', () => { const stop = startWindowFiles(); const paste = (files: File[]) => { const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files } }); + Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); window.dispatchEvent(event); return event; }; @@ -185,6 +199,54 @@ describe('files on the window', () => { stop(); }); + describe('a paste that is not for us', () => { + const pasteOn = (target: EventTarget, types: string[]) => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } }); + target.dispatchEvent(event); + return event; + }; + + it('text with a file, as a spreadsheet copies, stays text', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('in a search field or any other input, a file is not an attachment', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const input = document.body.appendChild(document.createElement('input')); + expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + input.remove(); + stop(); + }); + + it('in the body of a composer on screen, a bare file attaches', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target, () => true); + const composer = document.body.appendChild(document.createElement('div')); + composer.setAttribute('data-testid', 'email-composer'); + const body = composer.appendChild(document.createElement('div')); + body.setAttribute('contenteditable', 'true'); + pasteOn(body, ['Files']); + expect(target).toHaveBeenCalledWith([file]); + composer.remove(); + clear(); + stop(); + }); + }); + it('our own native drag over our own window is not a new file', async () => { const { setOwnDragActive, startWindowFiles } = await import('../window-files'); const { takeHandoff } = await import('@/lib/launch-handoff'); @@ -247,6 +309,23 @@ describe('dragging an attachment out', () => { await waitFor(() => expect(startDrag).toHaveBeenCalled()); }); + it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const { name, type, getBlobUrl } = source; + const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true)); + act(() => hook.result.current.onPointerEnter()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(startDrag).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl)); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + it('in a browser: still DownloadURL, and no copy button', async () => { const hook = await ready(); const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; @@ -313,15 +392,52 @@ describe('waking up', () => { stop(); }); - it('a network change counts as waking', async () => { - const connection = new EventTarget(); - vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection })); + it('a new kind of network counts as waking; a change in its speed estimate does not', async () => { + const connection = Object.assign(new EventTarget(), { type: 'wifi' }); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); const { power, stop } = await started(); const wake = vi.fn(); const stopWake = power.addWakeHandler(wake); - await new Promise((resolve) => setTimeout(resolve, 0)); connection.dispatchEvent(new Event('change')); - expect(wake).toHaveBeenCalled(); + expect(wake).not.toHaveBeenCalled(); + connection.type = 'ethernet'; + connection.dispatchEvent(new Event('change')); + expect(wake).toHaveBeenCalledTimes(1); + stopWake(); + stop(); + }); + + it('where Chromium gives no type, a change is nothing to go on', async () => { + const connection = new EventTarget(); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + connection.dispatchEvent(new Event('change')); + expect(wake).not.toHaveBeenCalled(); + stopWake(); + stop(); + }); + + it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => { + const online = vi.fn(); + window.addEventListener('online', online); + vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } })); + const { fire, power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + fire('resume'); + expect(wake).toHaveBeenCalledTimes(1); + expect(online).not.toHaveBeenCalled(); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(1); + clock.mockReturnValue(now + 31_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(2); + clock.mockRestore(); + window.removeEventListener('online', online); stopWake(); stop(); }); @@ -357,6 +473,8 @@ describe('the summary', () => { useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' }); useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never }); useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never }); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); }); afterEach(async () => { @@ -374,6 +492,42 @@ describe('the summary', () => { expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' }); }); + it('before the policy is known it is empty', async () => { + const { usePolicyStore } = await import('@/stores/policy-store'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + usePolicyStore.setState({ loaded: false }); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + + it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => { + const setSummary = vi.fn(); + setBridge({ setSummary }); + const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary'); + const { useAuthStore } = await import('@/stores/auth-store'); + const { usePolicyStore } = await import('@/stores/policy-store'); + let answer: (value: unknown) => void = () => {}; + getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + const summary = startOsSummary(() => 'en'); + await vi.advanceTimersByTimeAsync(2100); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + answer({ emails: [row], hasMore: false, total: 1 }); + await vi.advanceTimersByTimeAsync(10); + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch'); + + setSummary.mockClear(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } }); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenCalled(); + summary.stop(); + vi.useRealTimers(); + }); + it('while locked it is empty, without asking anyone anything', async () => { const { setAppLockCheck } = await import('../app-lock-state'); const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts index 922a5c1b5..33e36bd81 100644 --- a/lib/desktop/bridge.ts +++ b/lib/desktop/bridge.ts @@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe getDesktopBridge()?.servers?.forget?.(serverUrl, reason); } +/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */ +export function clearDesktopSurfaces(): void { + getDesktopBridge()?.os?.clearSurfaces?.(); +} + /** True in any window of the desktop app, standalone or on an instance. */ export function isDesktopApp(): boolean { return getDesktopBridge() !== null; diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 05b3303ba..12cd12e5d 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -21,6 +21,7 @@ import { isPopoutWindow } from '@/lib/popout/window-role'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; import { useSettingsStore } from '@/stores/settings-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -72,17 +73,22 @@ async function senderInfo(client: IJMAPClient): Promise { }; } -function withoutContent(sender: Sender): boolean { +/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */ +export function contentHiddenFor(accountEntryId: string | null | undefined): boolean { if (isAppLocked()) return true; // Unknown counts as on, as on the server's route. - if (!sender.entryId) return true; + if (!accountEntryId || !usePolicyStore.getState().loaded) return true; try { - return devicesForAccount(sender.entryId).pushWithoutContent; + return devicesForAccount(accountEntryId).pushWithoutContent; } catch { return true; } } +function withoutContent(sender: Sender): boolean { + return contentHiddenFor(sender.entryId); +} + function senderName(mail: NewMail): string { const first = mail.email.from?.[0]; return first?.name || first?.email || ''; diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 8c7332225..534ff7544 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -5,6 +5,7 @@ * user's own OS, so it skips the question a web share gets (decision 17). */ +import { isAppLocked } from '@/lib/desktop/app-lock-state'; import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsCommand } from '@/lib/desktop/types'; import { openMailLink } from '@/lib/desktop/mail-links'; @@ -25,6 +26,8 @@ export interface OsCommandEnv { push: (path: string) => void; /** Says something couldn't be opened. */ notifyFailed: (names: readonly string[]) => void; + /** A typed reply that has nowhere to go: its message's login is gone from here. */ + notifyReplyLost: () => void; } /** How long a toast's button waits for a cold start to sign in. */ @@ -77,6 +80,7 @@ async function openFromNotification(context: MailNotificationContext, env: OsCom openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor); return; } + if (reply?.text) env.notifyReplyLost(); openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor); } @@ -89,6 +93,11 @@ function runAction(request: NotificationActionRequest): Promise { + // An old toast's Delete doesn't act behind the lock, and its message doesn't open over it. + if (isAppLocked()) { + showWindow(); + return; + } const context = parseContext(rawContext); if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts index 12d014051..599dd942a 100644 --- a/lib/desktop/os-summary.ts +++ b/lib/desktop/os-summary.ts @@ -6,9 +6,8 @@ */ import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; -import { devicesForAccount } from '@/lib/device-policy'; import type { CalendarEvent, Email } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; @@ -16,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; const ROWS = 5; const REFRESH_DELAY_MS = 2000; @@ -46,12 +46,10 @@ export function formatEventTime(start: number, now: number, locale: string | und } export async function readSummary(locale: string | undefined, now = Date.now()): Promise { - if (isAppLocked()) return EMPTY_SUMMARY; const auth = useAuthStore.getState(); const client: IJMAPClient | null = auth.client; const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId); - if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY; - if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY; + if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY; const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared); let unread: DesktopOsTrayMessage[] = []; @@ -91,12 +89,14 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () const os = getDesktopBridge()?.os; if (!os) return { refresh: () => {}, stop: () => {} }; let timer: ReturnType | null = null; - let stale = false; + /** A read under way when the account changed answers for the old one, and is dropped. */ + let generation = 0; const run = () => { timer = null; + const mine = ++generation; void readSummary(locale()).then((summary) => { - if (!stale) os.setSummary(summary); + if (mine === generation) os.setSummary(summary); }, (error) => { console.warn('Could not read the tray summary:', error); }); @@ -113,16 +113,24 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () refresh(); }); const stopAuth = useAuthStore.subscribe((state, previous) => { - if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh(); + if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return; + generation += 1; + if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY); + refresh(); + }); + // An organisation turning sender and subject off goes for what's already showing, too. + const stopPolicy = usePolicyStore.subscribe((state, previous) => { + if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh(); }); const interval = setInterval(refresh, REFRESH_EVERY_MS); refresh(); return { refresh, stop() { - stale = true; + generation += 1; stopEmail(); stopAuth(); + stopPolicy(); clearInterval(interval); if (timer) clearTimeout(timer); }, diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 44d429dfc..1e36e52ae 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -13,12 +13,13 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; import { usePolicyStore } from '@/stores/policy-store'; -/** A resume and an unlock usually come together. */ -const WAKE_REPEAT_MS = 2000; +/** A resume, an unlock and a network change come together, and each would mean a round of requests. */ +const WAKE_REPEAT_MS = 30_000; function wake(): void { document.dispatchEvent(new Event('visibilitychange')); - window.dispatchEvent(new Event('online')); + // Woken up with no network: the browser's own event comes when there is one. + if (navigator.onLine !== false) window.dispatchEvent(new Event('online')); const policy = usePolicyStore.getState(); if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); runWakeHandlers(); @@ -38,11 +39,20 @@ export function startPowerWatch(): () => void { if (event === 'resume' || event === 'unlock-screen') wakeOnce(); else if (event === 'suspend') runSleepHandlers(); }); - // Wi-Fi to cable is a new network with no offline moment in between. - const connection = (navigator as unknown as { connection?: EventTarget }).connection; - connection?.addEventListener?.('change', wakeOnce); + // Wi-Fi to cable is a new network with no offline moment in between. This event also fires + // for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the + // type, that leaves the stream's own ping check and the online event. + const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection; + let lastType = connection?.type; + const onConnection = () => { + const type = connection?.type; + if (!type || type === lastType) return; + lastType = type; + wakeOnce(); + }; + connection?.addEventListener?.('change', onConnection); return () => { stopPower(); - connection?.removeEventListener?.('change', wakeOnce); + connection?.removeEventListener?.('change', onConnection); }; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 501cc503a..859f2ea97 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -168,7 +168,7 @@ export interface DesktopOsNotification { export interface DesktopOsNotifyResult { shown: boolean; - reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid'; + reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed'; /** The page should play the sound the user picked. */ playSound: boolean; } @@ -214,8 +214,13 @@ export interface DesktopOsBridge { setSummary: (summary: DesktopOsSummary) => void; /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */ setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void; - /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */ + /** + * App lock's state. Main counts as locked until the main window's page says otherwise, + * and clears the tray, jump list and toasts and strips what comes through. + */ setLocked: (locked: boolean) => void; + /** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */ + clearSurfaces: () => void; notify: (notification: DesktopOsNotification) => Promise; onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ @@ -225,6 +230,8 @@ export interface DesktopOsBridge { defaultAppStatus: () => Promise; /** Offers the app to the OS and opens where the user chooses. */ openDefaultAppSettings: () => Promise; +} + export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; export interface DesktopKeystoreCertificate { diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts index c9b95221a..63a67630b 100644 --- a/lib/desktop/window-files.ts +++ b/lib/desktop/window-files.ts @@ -8,29 +8,58 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { handOffDraft } from '@/lib/launch-handoff'; -type ComposerTarget = (files: File[]) => unknown; +interface ComposerTarget { + take: (files: File[]) => unknown; + /** The Pro shell keeps composers mounted on tabs out of sight. */ + visible: () => boolean; +} -let composerTarget: ComposerTarget | null = null; +const composerTargets: ComposerTarget[] = []; /** Our own native drag passing over our own window isn't a new file. */ let ownDrag = false; -/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */ -export function setComposerFileTarget(target: ComposerTarget): () => void { - composerTarget = target; +/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */ +export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void { + const target = { take, visible }; + composerTargets.push(target); return () => { - if (composerTarget === target) composerTarget = null; + const at = composerTargets.indexOf(target); + if (at !== -1) composerTargets.splice(at, 1); }; } +function visibleComposer(): ComposerTarget | undefined { + return [...composerTargets].reverse().find((target) => target.visible()); +} + export function setOwnDragActive(active: boolean): void { ownDrag = active; } function take(files: File[]): void { - if (composerTarget) composerTarget(files); + const composer = visibleComposer(); + if (composer) composer.take(files); else handOffDraft({ files }); } +function isEditable(target: EventTarget | null): boolean { + const element = target as Partial & { closest?: (selector: string) => Element | null } | null; + return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]'); +} + +/** + * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a + * picture of them, and a field that takes text wants the text. The one editable place a + * bare file belongs in is the composer it's being pasted into. + */ +function pasteIsForUs(event: ClipboardEvent): boolean { + const types = [...(event.clipboardData?.types ?? [])]; + if (types.includes('text/plain') || types.includes('text/html')) return false; + if (!isEditable(event.target)) return true; + const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); + return inComposer && !!visibleComposer(); +} + function hasFiles(event: DragEvent): boolean { return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); } @@ -51,7 +80,7 @@ export function startWindowFiles(): () => void { const onPaste = (event: ClipboardEvent) => { if (event.defaultPrevented) return; const files = Array.from(event.clipboardData?.files ?? []); - if (files.length === 0) return; + if (files.length === 0 || !pasteIsForUs(event)) return; event.preventDefault(); take(files); }; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index d71993589..e138070f1 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -62,6 +62,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = @@ -807,6 +808,7 @@ async function nameLost(scope: string, stillSignedIn: boolean): Promise { } export async function wipeAccount(account: string): Promise { + clearDesktopSurfaces(); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); @@ -838,6 +840,7 @@ export async function wipeAccount(account: string): Promise { /** Nothing of offline mode stays on this device. */ export async function wipeEverything(): Promise { + clearDesktopSurfaces(); for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..67ff5ea9c 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,6 +1,7 @@ import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -138,6 +139,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { } // Files and links on their way into a draft or an import. clearHandoffs(); + clearDesktopSurfaces(); for (const cleanup of signedOutCleanups) { try { cleanup(options); diff --git a/locales/ar/common.json b/locales/ar/common.json index 654025307..911ff01cf 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "تم نقل هذه الرسالة إلى {folder}.", "window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا." }, + "desktopOs": { + "section_title": "تطبيق سطح المكتب", + "section_description": "طريقة عمل التطبيق مع حاسوبك.", + "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق", + "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.", + "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب", + "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.", + "start_minimized_label": "البدء في شريط النظام", + "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.", + "attention_label": "وميض شريط المهام عند وصول بريد جديد", + "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد", + "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.", + "quiet_hours_label": "ساعات الهدوء", + "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.", + "quiet_hours_from": "من", + "quiet_hours_to": "إلى", + "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح", + "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.", + "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.", + "default_app_label": "تطبيق البريد الافتراضي", + "default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.", + "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.", + "default_app_button": "اختيار التطبيق الافتراضي…", + "new_mail": "بريد جديد", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "نسخ كملف", + "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", + "copy_failed": "تعذّر نسخ الملف.", + "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك." + }, "desktop_sign_in": { "browser_button": "سجّل الدخول باستخدام المتصفح", "browser_waiting": "أكمل تسجيل الدخول في المتصفح", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a9eb6fb2..275c62df5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Aquest missatge s’ha mogut a {folder}.", "window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí." }, + "desktopOs": { + "section_title": "Aplicació d'escriptori", + "section_description": "Com funciona l'aplicació amb l'ordinador.", + "close_to_tray_label": "Continua a la safata en tancar", + "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.", + "start_at_login_label": "Inicia en entrar a l'ordinador", + "start_at_login_description": "L'aplicació s'inicia sola quan entreu.", + "start_minimized_label": "Inicia a la safata", + "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.", + "attention_label": "Fes parpellejar la barra de tasques amb correu nou", + "attention_mac_label": "Fes botar la icona del Dock amb correu nou", + "attention_description": "Només quan l'aplicació no és al davant.", + "quiet_hours_label": "Hores de silenci", + "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat", + "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.", + "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.", + "default_app_label": "Aplicació de correu predeterminada", + "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.", + "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.", + "default_app_button": "Tria l'aplicació predeterminada…", + "new_mail": "Correu nou", + "no_subject": "(sense assumpte)", + "action_delete": "Suprimeix", + "copy_as_file": "Copia com a fitxer", + "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", + "copy_failed": "No s'ha pogut copiar el fitxer.", + "reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà." + }, "desktop_sign_in": { "browser_button": "Inicia la sessió amb el navegador", "browser_waiting": "Acaba d'iniciar la sessió al navegador", diff --git a/locales/cs/common.json b/locales/cs/common.json index 18f0c03ee..d818f1a13 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Tato zpráva byla přesunuta do {folder}.", "window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde." }, + "desktopOs": { + "section_title": "Desktopová aplikace", + "section_description": "Jak aplikace spolupracuje s vaším počítačem.", + "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti", + "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.", + "start_at_login_label": "Spustit po přihlášení k počítači", + "start_at_login_description": "Aplikace se po přihlášení spustí sama.", + "start_minimized_label": "Spustit v oznamovací oblasti", + "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.", + "attention_label": "Při nové poště blikat na hlavním panelu", + "attention_mac_label": "Při nové poště poskakovat ikonou v Docku", + "attention_description": "Jen když aplikace není v popředí.", + "quiet_hours_label": "Doba klidu", + "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou", + "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.", + "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.", + "default_app_label": "Výchozí poštovní aplikace", + "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.", + "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.", + "default_app_button": "Vybrat výchozí aplikaci…", + "new_mail": "Nová pošta", + "no_subject": "(bez předmětu)", + "action_delete": "Smazat", + "copy_as_file": "Kopírovat jako soubor", + "copied": "Soubor zkopírován. Můžete ho vložit do složky.", + "copy_failed": "Soubor se nepodařilo zkopírovat.", + "reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní." + }, "desktop_sign_in": { "browser_button": "Přihlásit se v prohlížeči", "browser_waiting": "Dokončete přihlášení v prohlížeči", diff --git a/locales/da/common.json b/locales/da/common.json index 5578eeca0..fa38d0449 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Denne besked er flyttet til {folder}.", "window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her." }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Sådan arbejder appen sammen med din computer.", + "close_to_tray_label": "Fortsæt i systembakken ved lukning", + "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.", + "start_at_login_label": "Start, når du logger på computeren", + "start_at_login_description": "Appen starter selv, når du logger på.", + "start_minimized_label": "Start i systembakken", + "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.", + "attention_label": "Blink i proceslinjen ved ny post", + "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post", + "attention_description": "Kun når appen ikke er forrest.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en tastaturgenvej", + "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.", + "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.", + "default_app_label": "Standardmailapp", + "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.", + "default_app_is_default": "Denne app åbner dine maillinks.", + "default_app_button": "Vælg standardapp…", + "new_mail": "Ny post", + "no_subject": "(intet emne)", + "action_delete": "Slet", + "copy_as_file": "Kopiér som fil", + "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der." + }, "desktop_sign_in": { "browser_button": "Log ind med din browser", "browser_waiting": "Gør login færdigt i din browser", diff --git a/locales/de/common.json b/locales/de/common.json index efb641f2b..336c82000 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.", "window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier." }, + "desktopOs": { + "section_title": "Desktop-App", + "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", + "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen", + "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.", + "start_at_login_label": "Beim Anmelden am Computer starten", + "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.", + "start_minimized_label": "Im Infobereich starten", + "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.", + "attention_label": "Taskleiste bei neuen E-Mails blinken lassen", + "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen", + "attention_description": "Nur wenn die App nicht im Vordergrund ist.", + "quiet_hours_label": "Ruhezeiten", + "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.", + "quiet_hours_from": "Von", + "quiet_hours_to": "bis", + "show_shortcut_label": "App mit einer Tastenkombination anzeigen", + "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.", + "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.", + "default_app_label": "Standard-E-Mail-App", + "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.", + "default_app_is_default": "Diese App öffnet Ihre Mail-Links.", + "default_app_button": "Standard-App auswählen…", + "new_mail": "Neue E-Mail", + "no_subject": "(kein Betreff)", + "action_delete": "Löschen", + "copy_as_file": "Als Datei kopieren", + "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", + "copy_failed": "Die Datei konnte nicht kopiert werden.", + "reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort." + }, "desktop_sign_in": { "browser_button": "Mit dem Browser anmelden", "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", diff --git a/locales/en/common.json b/locales/en/common.json index 7728594e1..7ec268e1c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "This message was moved to {folder}.", "window_not_ready": "The new window did not open in time. Your draft stays here." }, + "desktopOs": { + "section_title": "Desktop app", + "section_description": "How the app works with your computer.", + "close_to_tray_label": "Keep running in the tray when closed", + "close_to_tray_description": "New mail and reminders keep coming after you close the window.", + "start_at_login_label": "Start when you sign in to your computer", + "start_at_login_description": "The app starts by itself when you sign in.", + "start_minimized_label": "Start in the tray", + "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.", + "attention_label": "Flash the taskbar on new mail", + "attention_mac_label": "Bounce the Dock icon on new mail", + "attention_description": "Only when the app is not in front.", + "quiet_hours_label": "Quiet hours", + "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.", + "quiet_hours_from": "From", + "quiet_hours_to": "to", + "show_shortcut_label": "Show the app with a keyboard shortcut", + "show_shortcut_description": "{keys} brings the app to the front, from any other app.", + "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.", + "default_app_label": "Default mail app", + "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.", + "default_app_is_default": "This app opens your mail links.", + "default_app_button": "Choose default app…", + "new_mail": "New mail", + "no_subject": "(no subject)", + "action_delete": "Delete", + "copy_as_file": "Copy as file", + "copied": "File copied. You can paste it into a folder.", + "copy_failed": "The file could not be copied.", + "reply_lost": "Your typed reply could not be kept. Open the message and reply there." + }, "desktop_sign_in": { "browser_button": "Sign in with your browser", "browser_waiting": "Finish signing in in your browser", diff --git a/locales/es/common.json b/locales/es/common.json index 5a2af15a4..217aecbe4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Este mensaje se movió a {folder}.", "window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí." }, + "desktopOs": { + "section_title": "Aplicación de escritorio", + "section_description": "Cómo funciona la aplicación con tu ordenador.", + "close_to_tray_label": "Seguir en la bandeja al cerrar", + "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.", + "start_at_login_label": "Iniciar al entrar en el ordenador", + "start_at_login_description": "La aplicación se inicia sola cuando entras.", + "start_minimized_label": "Iniciar en la bandeja", + "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.", + "attention_label": "Hacer parpadear la barra de tareas con correo nuevo", + "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo", + "attention_description": "Solo cuando la aplicación no está delante.", + "quiet_hours_label": "Horas de silencio", + "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado", + "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.", + "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.", + "default_app_label": "Aplicación de correo predeterminada", + "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.", + "default_app_is_default": "Esta aplicación abre tus enlaces de correo.", + "default_app_button": "Elegir aplicación predeterminada…", + "new_mail": "Correo nuevo", + "no_subject": "(sin asunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como archivo", + "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", + "copy_failed": "No se pudo copiar el archivo.", + "reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí." + }, "desktop_sign_in": { "browser_button": "Iniciar sesión con el navegador", "browser_waiting": "Termina de iniciar sesión en el navegador", diff --git a/locales/et/common.json b/locales/et/common.json index b5ce8e291..82feaa2d2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "See kiri teisaldati kausta {folder}.", "window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia." }, + "desktopOs": { + "section_title": "Töölauarakendus", + "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", + "close_to_tray_label": "Sulgemisel jätka salves", + "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.", + "start_at_login_label": "Käivita arvutisse sisselogimisel", + "start_at_login_description": "Rakendus käivitub sisselogimisel ise.", + "start_minimized_label": "Käivita salves", + "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.", + "attention_label": "Vilguta tegumiriba uue kirja korral", + "attention_mac_label": "Põrgata Docki ikooni uue kirja korral", + "attention_description": "Ainult siis, kui rakendus pole esiplaanil.", + "quiet_hours_label": "Vaikne aeg", + "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.", + "quiet_hours_from": "Alates", + "quiet_hours_to": "kuni", + "show_shortcut_label": "Näita rakendust kiirklahviga", + "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.", + "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.", + "default_app_label": "Vaikimisi e-posti rakendus", + "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.", + "default_app_is_default": "See rakendus avab sinu e-posti lingid.", + "default_app_button": "Vali vaikerakendus…", + "new_mail": "Uus kiri", + "no_subject": "(teema puudub)", + "action_delete": "Kustuta", + "copy_as_file": "Kopeeri failina", + "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", + "copy_failed": "Faili ei saanud kopeerida.", + "reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal." + }, "desktop_sign_in": { "browser_button": "Logi sisse brauseriga", "browser_waiting": "Lõpeta sisselogimine brauseris", diff --git a/locales/fa/common.json b/locales/fa/common.json index 22a2c1d43..1b1b3e88f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "این پیام به {folder} منتقل شد.", "window_not_ready": "پنجرهٔ جدید به‌موقع باز نشد. پیش‌نویس شما همین‌جا می‌ماند." }, + "desktopOs": { + "section_title": "برنامه دسکتاپ", + "section_description": "نحوه کار برنامه با رایانه شما.", + "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند", + "close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.", + "start_at_login_label": "هنگام ورود به رایانه شروع شود", + "start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.", + "start_minimized_label": "شروع در سینی سیستم", + "start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.", + "attention_label": "چشمک نوار وظیفه هنگام نامه جدید", + "attention_mac_label": "جهش نماد Dock هنگام نامه جدید", + "attention_description": "فقط وقتی برنامه در جلو نیست.", + "quiet_hours_label": "ساعات سکوت", + "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.", + "quiet_hours_from": "از", + "quiet_hours_to": "تا", + "show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید", + "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.", + "show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.", + "default_app_label": "برنامه پیش‌فرض ایمیل", + "default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.", + "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.", + "default_app_button": "انتخاب برنامه پیش‌فرض…", + "new_mail": "نامه جدید", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "رونوشت به‌صورت پرونده", + "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", + "copy_failed": "رونوشت پرونده ممکن نشد.", + "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید." + }, "desktop_sign_in": { "browser_button": "ورود با مرورگر", "browser_waiting": "ورود را در مرورگر کامل کنید", diff --git a/locales/fr/common.json b/locales/fr/common.json index e30800b16..864c7c969 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ce message a été déplacé vers {folder}.", "window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici." }, + "desktopOs": { + "section_title": "Application de bureau", + "section_description": "Comment l'application fonctionne avec votre ordinateur.", + "close_to_tray_label": "Rester dans la zone de notification à la fermeture", + "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.", + "start_at_login_label": "Démarrer à l'ouverture de session", + "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.", + "start_minimized_label": "Démarrer dans la zone de notification", + "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.", + "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier", + "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier", + "attention_description": "Seulement quand l'application n'est pas au premier plan.", + "quiet_hours_label": "Heures calmes", + "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.", + "quiet_hours_from": "De", + "quiet_hours_to": "à", + "show_shortcut_label": "Afficher l'application avec un raccourci clavier", + "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.", + "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.", + "default_app_label": "Application de messagerie par défaut", + "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.", + "default_app_is_default": "Cette application ouvre vos liens de messagerie.", + "default_app_button": "Choisir l'application par défaut…", + "new_mail": "Nouveau courrier", + "no_subject": "(sans objet)", + "action_delete": "Supprimer", + "copy_as_file": "Copier en tant que fichier", + "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", + "copy_failed": "Le fichier n'a pas pu être copié.", + "reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci." + }, "desktop_sign_in": { "browser_button": "Se connecter avec le navigateur", "browser_waiting": "Terminez la connexion dans votre navigateur", diff --git a/locales/he/common.json b/locales/he/common.json index 571f30e34..8c3d28f99 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6546,6 +6546,37 @@ "message_moved_to": "ההודעה הזו הועברה אל {folder}.", "window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן." }, + "desktopOs": { + "section_title": "יישום שולחן עבודה", + "section_description": "איך היישום עובד עם המחשב שלך.", + "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה", + "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.", + "start_at_login_label": "להפעיל בכניסה למחשב", + "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.", + "start_minimized_label": "להפעיל במגש המערכת", + "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.", + "attention_label": "הבהוב בשורת המשימות בדואר חדש", + "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש", + "attention_description": "רק כשהיישום לא בחזית.", + "quiet_hours_label": "שעות שקט", + "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.", + "quiet_hours_from": "מ-", + "quiet_hours_to": "עד", + "show_shortcut_label": "הצגת היישום בקיצור מקשים", + "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.", + "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.", + "default_app_label": "יישום הדואר המוגדר כברירת מחדל", + "default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.", + "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.", + "default_app_button": "בחירת יישום ברירת מחדל…", + "new_mail": "דואר חדש", + "no_subject": "(ללא נושא)", + "action_delete": "מחיקה", + "copy_as_file": "העתקה כקובץ", + "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", + "copy_failed": "לא ניתן להעתיק את הקובץ.", + "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם." + }, "desktop_sign_in": { "browser_button": "כניסה באמצעות הדפדפן", "browser_waiting": "יש להשלים את הכניסה בדפדפן", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2ab59129e..60c3ee5f3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.", "window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad." }, + "desktopOs": { + "section_title": "Asztali alkalmazás", + "section_description": "Hogyan működik az alkalmazás a számítógépével.", + "close_to_tray_label": "Bezáráskor fusson tovább a tálcán", + "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.", + "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor", + "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.", + "start_minimized_label": "Indítás a tálcán", + "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.", + "attention_label": "Tálca villogtatása új levélnél", + "attention_mac_label": "Dock-ikon ugráltatása új levélnél", + "attention_description": "Csak ha az alkalmazás nincs előtérben.", + "quiet_hours_label": "Csendes órák", + "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.", + "quiet_hours_from": "Ettől", + "quiet_hours_to": "eddig", + "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal", + "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.", + "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.", + "default_app_label": "Alapértelmezett levelezőalkalmazás", + "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.", + "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.", + "default_app_button": "Alapértelmezett alkalmazás kiválasztása…", + "new_mail": "Új levél", + "no_subject": "(nincs tárgy)", + "action_delete": "Törlés", + "copy_as_file": "Másolás fájlként", + "copied": "Fájl másolva. Beillesztheti egy mappába.", + "copy_failed": "A fájlt nem sikerült másolni.", + "reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott." + }, "desktop_sign_in": { "browser_button": "Bejelentkezés böngészővel", "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", diff --git a/locales/it/common.json b/locales/it/common.json index a861d3390..c7e482c6d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Questo messaggio è stato spostato in {folder}.", "window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui." }, + "desktopOs": { + "section_title": "App desktop", + "section_description": "Come l'app funziona con il tuo computer.", + "close_to_tray_label": "Resta nell'area di notifica alla chiusura", + "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.", + "start_at_login_label": "Avvia all'accesso al computer", + "start_at_login_description": "L'app si avvia da sola quando accedi.", + "start_minimized_label": "Avvia nell'area di notifica", + "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.", + "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta", + "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta", + "attention_description": "Solo quando l'app non è in primo piano.", + "quiet_hours_label": "Ore di silenzio", + "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.", + "quiet_hours_from": "Da", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera", + "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.", + "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.", + "default_app_label": "App di posta predefinita", + "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.", + "default_app_is_default": "Questa app apre i tuoi link di posta.", + "default_app_button": "Scegli app predefinita…", + "new_mail": "Nuova posta", + "no_subject": "(nessun oggetto)", + "action_delete": "Elimina", + "copy_as_file": "Copia come file", + "copied": "File copiato. Puoi incollarlo in una cartella.", + "copy_failed": "Impossibile copiare il file.", + "reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì." + }, "desktop_sign_in": { "browser_button": "Accedi con il browser", "browser_waiting": "Completa l'accesso nel browser", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0606617f1..6927afe1a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "このメッセージは {folder} に移動されました。", "window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。" }, + "desktopOs": { + "section_title": "デスクトップアプリ", + "section_description": "アプリとパソコンの連携方法です。", + "close_to_tray_label": "閉じてもトレイで実行を続ける", + "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。", + "start_at_login_label": "パソコンへのサインイン時に起動", + "start_at_login_description": "サインインするとアプリが自動で起動します。", + "start_minimized_label": "トレイで起動", + "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。", + "attention_label": "新着メールでタスクバーを点滅", + "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる", + "attention_description": "アプリが前面にないときのみ。", + "quiet_hours_label": "おやすみ時間", + "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。", + "quiet_hours_from": "開始", + "quiet_hours_to": "終了", + "show_shortcut_label": "キーボードショートカットでアプリを表示", + "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。", + "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。", + "default_app_label": "既定のメールアプリ", + "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。", + "default_app_is_default": "メールのリンクはこのアプリで開きます。", + "default_app_button": "既定のアプリを選択…", + "new_mail": "新着メール", + "no_subject": "(件名なし)", + "action_delete": "削除", + "copy_as_file": "ファイルとしてコピー", + "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", + "copy_failed": "ファイルをコピーできませんでした。", + "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。" + }, "desktop_sign_in": { "browser_button": "ブラウザでサインイン", "browser_waiting": "ブラウザでサインインを完了してください", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0daa574..ad9c104f7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.", "window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다." }, + "desktopOs": { + "section_title": "데스크톱 앱", + "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", + "close_to_tray_label": "닫을 때 트레이에서 계속 실행", + "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.", + "start_at_login_label": "컴퓨터에 로그인할 때 시작", + "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.", + "start_minimized_label": "트레이에서 시작", + "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.", + "attention_label": "새 메일이 오면 작업 표시줄 깜박이기", + "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기", + "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.", + "quiet_hours_label": "방해 금지 시간", + "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.", + "quiet_hours_from": "시작", + "quiet_hours_to": "종료", + "show_shortcut_label": "키보드 단축키로 앱 표시", + "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.", + "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.", + "default_app_label": "기본 메일 앱", + "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.", + "default_app_is_default": "이 앱이 메일 링크를 엽니다.", + "default_app_button": "기본 앱 선택…", + "new_mail": "새 메일", + "no_subject": "(제목 없음)", + "action_delete": "삭제", + "copy_as_file": "파일로 복사", + "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", + "copy_failed": "파일을 복사할 수 없습니다.", + "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요." + }, "desktop_sign_in": { "browser_button": "브라우저로 로그인", "browser_waiting": "브라우저에서 로그인을 마치세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 05939fbc0..a5295665f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.", "window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit." }, + "desktopOs": { + "section_title": "Darbvirsmas lietotne", + "section_description": "Kā lietotne darbojas ar jūsu datoru.", + "close_to_tray_label": "Aizverot turpināt darbu teknē", + "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.", + "start_at_login_label": "Sākt, piesakoties datorā", + "start_at_login_description": "Lietotne sākas pati, kad piesakāties.", + "start_minimized_label": "Sākt teknē", + "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.", + "attention_label": "Mirgot uzdevumjoslā, pienākot pastam", + "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam", + "attention_description": "Tikai tad, ja lietotne nav priekšplānā.", + "quiet_hours_label": "Klusuma stundas", + "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.", + "quiet_hours_from": "No", + "quiet_hours_to": "līdz", + "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu", + "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.", + "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.", + "default_app_label": "Noklusējuma pasta lietotne", + "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.", + "default_app_is_default": "Šī lietotne atver jūsu pasta saites.", + "default_app_button": "Izvēlēties noklusējuma lietotni…", + "new_mail": "Jauns pasts", + "no_subject": "(nav tēmas)", + "action_delete": "Dzēst", + "copy_as_file": "Kopēt kā failu", + "copied": "Fails nokopēts. Varat to ielīmēt mapē.", + "copy_failed": "Failu neizdevās nokopēt.", + "reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur." + }, "desktop_sign_in": { "browser_button": "Pierakstīties ar pārlūku", "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", diff --git a/locales/mn/common.json b/locales/mn/common.json index dfa942d34..7fe52244f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.", "window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ." }, + "desktopOs": { + "section_title": "Ширээний апп", + "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", + "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах", + "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.", + "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх", + "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.", + "start_minimized_label": "Тавиур дээр эхлүүлэх", + "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.", + "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах", + "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх", + "attention_description": "Зөвхөн апп урд талд байхгүй үед.", + "quiet_hours_label": "Чимээгүй цаг", + "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.", + "quiet_hours_from": "Эхлэх", + "quiet_hours_to": "хүртэл", + "show_shortcut_label": "Гарын товчлолоор аппыг харуулах", + "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.", + "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.", + "default_app_label": "Үндсэн шуудангийн апп", + "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.", + "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.", + "default_app_button": "Үндсэн апп сонгох…", + "new_mail": "Шинэ захиа", + "no_subject": "(гарчиггүй)", + "action_delete": "Устгах", + "copy_as_file": "Файл болгож хуулах", + "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", + "copy_failed": "Файлыг хуулж чадсангүй.", + "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу." + }, "desktop_sign_in": { "browser_button": "Хөтчөөр нэвтрэх", "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0002acca..9dbd11a2c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Denne meldingen ble flyttet til {folder}.", "window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her." }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Slik samarbeider appen med datamaskinen din.", + "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking", + "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.", + "start_at_login_label": "Start når du logger på datamaskinen", + "start_at_login_description": "Appen starter av seg selv når du logger på.", + "start_minimized_label": "Start i systemstatusfeltet", + "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.", + "attention_label": "Blink i oppgavelinjen ved ny e-post", + "attention_mac_label": "La Dock-ikonet sprette ved ny e-post", + "attention_description": "Bare når appen ikke er foran.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en hurtigtast", + "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.", + "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.", + "default_app_label": "Standard e-postapp", + "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.", + "default_app_is_default": "Denne appen åpner e-postlenkene dine.", + "default_app_button": "Velg standardapp…", + "new_mail": "Ny e-post", + "no_subject": "(uten emne)", + "action_delete": "Slett", + "copy_as_file": "Kopier som fil", + "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der." + }, "desktop_sign_in": { "browser_button": "Logg på med nettleseren", "browser_waiting": "Fullfør påloggingen i nettleseren", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5524a36da..ca060609f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Dit bericht is verplaatst naar {folder}.", "window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier." }, + "desktopOs": { + "section_title": "Desktop-app", + "section_description": "Hoe de app samenwerkt met je computer.", + "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak", + "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.", + "start_at_login_label": "Starten als je je aanmeldt op de computer", + "start_at_login_description": "De app start vanzelf als je je aanmeldt.", + "start_minimized_label": "Starten in het systeemvak", + "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.", + "attention_label": "Taakbalk laten knipperen bij nieuwe mail", + "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail", + "attention_description": "Alleen als de app niet vooraan staat.", + "quiet_hours_label": "Stille uren", + "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.", + "quiet_hours_from": "Van", + "quiet_hours_to": "tot", + "show_shortcut_label": "App tonen met een sneltoets", + "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.", + "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.", + "default_app_label": "Standaard-mailapp", + "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.", + "default_app_is_default": "Deze app opent je maillinks.", + "default_app_button": "Standaard-app kiezen…", + "new_mail": "Nieuwe mail", + "no_subject": "(geen onderwerp)", + "action_delete": "Verwijderen", + "copy_as_file": "Kopiëren als bestand", + "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", + "copy_failed": "Het bestand kon niet worden gekopieerd.", + "reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar." + }, "desktop_sign_in": { "browser_button": "Inloggen met je browser", "browser_waiting": "Rond het inloggen af in je browser", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5b5481e..155963c34 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ta wiadomość została przeniesiona do {folder}.", "window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj." }, + "desktopOs": { + "section_title": "Aplikacja na komputer", + "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", + "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku", + "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.", + "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera", + "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.", + "start_minimized_label": "Uruchamiaj w zasobniku", + "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.", + "attention_label": "Migaj na pasku zadań przy nowej poczcie", + "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie", + "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.", + "quiet_hours_label": "Godziny ciszy", + "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym", + "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.", + "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.", + "default_app_label": "Domyślna aplikacja poczty", + "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.", + "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.", + "default_app_button": "Wybierz domyślną aplikację…", + "new_mail": "Nowa poczta", + "no_subject": "(bez tematu)", + "action_delete": "Usuń", + "copy_as_file": "Kopiuj jako plik", + "copied": "Plik skopiowany. Możesz wkleić go do folderu.", + "copy_failed": "Nie udało się skopiować pliku.", + "reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej." + }, "desktop_sign_in": { "browser_button": "Zaloguj się w przeglądarce", "browser_waiting": "Dokończ logowanie w przeglądarce", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87a2e2ff1..9940ba25c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Esta mensagem foi movida para {folder}.", "window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui." }, + "desktopOs": { + "section_title": "Aplicação de ambiente de trabalho", + "section_description": "Como a aplicação funciona com o seu computador.", + "close_to_tray_label": "Continuar na bandeja ao fechar", + "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.", + "start_at_login_label": "Iniciar ao entrar no computador", + "start_at_login_description": "A aplicação inicia sozinha quando entra.", + "start_minimized_label": "Iniciar na bandeja", + "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.", + "attention_label": "Piscar a barra de tarefas com correio novo", + "attention_mac_label": "Saltar o ícone da Dock com correio novo", + "attention_description": "Só quando a aplicação não está à frente.", + "quiet_hours_label": "Horas de silêncio", + "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.", + "quiet_hours_from": "De", + "quiet_hours_to": "até", + "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado", + "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.", + "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.", + "default_app_label": "Aplicação de correio predefinida", + "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.", + "default_app_is_default": "Esta aplicação abre as suas ligações de correio.", + "default_app_button": "Escolher aplicação predefinida…", + "new_mail": "Correio novo", + "no_subject": "(sem assunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como ficheiro", + "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", + "copy_failed": "Não foi possível copiar o ficheiro.", + "reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela." + }, "desktop_sign_in": { "browser_button": "Entrar com o navegador", "browser_waiting": "Termine de entrar no navegador", diff --git a/locales/ro/common.json b/locales/ro/common.json index 80c31fa28..b12f0f293 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Acest mesaj a fost mutat în {folder}.", "window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici." }, + "desktopOs": { + "section_title": "Aplicație desktop", + "section_description": "Cum funcționează aplicația cu computerul tău.", + "close_to_tray_label": "Rămâne în zona de notificări la închidere", + "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.", + "start_at_login_label": "Pornește la conectarea în computer", + "start_at_login_description": "Aplicația pornește singură când te conectezi.", + "start_minimized_label": "Pornește în zona de notificări", + "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.", + "attention_label": "Clipește în bara de activități la e-mail nou", + "attention_mac_label": "Sare pictograma din Dock la e-mail nou", + "attention_description": "Doar când aplicația nu este în față.", + "quiet_hours_label": "Ore de liniște", + "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.", + "quiet_hours_from": "De la", + "quiet_hours_to": "până la", + "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură", + "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.", + "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.", + "default_app_label": "Aplicația de e-mail implicită", + "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.", + "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.", + "default_app_button": "Alege aplicația implicită…", + "new_mail": "E-mail nou", + "no_subject": "(fără subiect)", + "action_delete": "Șterge", + "copy_as_file": "Copiază ca fișier", + "copied": "Fișier copiat. Îl poți lipi într-un dosar.", + "copy_failed": "Fișierul nu a putut fi copiat.", + "reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo." + }, "desktop_sign_in": { "browser_button": "Conectează-te cu browserul", "browser_waiting": "Finalizează conectarea în browser", diff --git a/locales/ru/common.json b/locales/ru/common.json index 55510f4c8..2679de84a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Это письмо перемещено в {folder}.", "window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь." }, + "desktopOs": { + "section_title": "Настольное приложение", + "section_description": "Как приложение работает с вашим компьютером.", + "close_to_tray_label": "При закрытии продолжать работу в трее", + "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.", + "start_at_login_label": "Запускать при входе в систему", + "start_at_login_description": "Приложение запускается само, когда вы входите.", + "start_minimized_label": "Запускать в трее", + "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.", + "attention_label": "Мигать на панели задач при новой почте", + "attention_mac_label": "Подпрыгивать значком в Dock при новой почте", + "attention_description": "Только когда приложение не на переднем плане.", + "quiet_hours_label": "Тихие часы", + "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.", + "quiet_hours_from": "С", + "quiet_hours_to": "до", + "show_shortcut_label": "Показывать приложение сочетанием клавиш", + "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.", + "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.", + "default_app_label": "Почтовое приложение по умолчанию", + "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.", + "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.", + "default_app_button": "Выбрать приложение по умолчанию…", + "new_mail": "Новая почта", + "no_subject": "(без темы)", + "action_delete": "Удалить", + "copy_as_file": "Копировать как файл", + "copied": "Файл скопирован. Его можно вставить в папку.", + "copy_failed": "Не удалось скопировать файл.", + "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него." + }, "desktop_sign_in": { "browser_button": "Войти через браузер", "browser_waiting": "Завершите вход в браузере", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e12b12d2..8d23419cf 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Táto správa bola presunutá do {folder}.", "window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu." }, + "desktopOs": { + "section_title": "Počítačová aplikácia", + "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", + "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení", + "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.", + "start_at_login_label": "Spustiť po prihlásení do počítača", + "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.", + "start_minimized_label": "Spustiť v oblasti oznámení", + "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.", + "attention_label": "Pri novej pošte blikať na paneli úloh", + "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku", + "attention_description": "Len keď aplikácia nie je v popredí.", + "quiet_hours_label": "Čas pokoja", + "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou", + "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.", + "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.", + "default_app_label": "Predvolená poštová aplikácia", + "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.", + "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.", + "default_app_button": "Vybrať predvolenú aplikáciu…", + "new_mail": "Nová pošta", + "no_subject": "(bez predmetu)", + "action_delete": "Odstrániť", + "copy_as_file": "Kopírovať ako súbor", + "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", + "copy_failed": "Súbor sa nepodarilo skopírovať.", + "reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej." + }, "desktop_sign_in": { "browser_button": "Prihlásiť sa v prehliadači", "browser_waiting": "Dokončite prihlásenie v prehliadači", diff --git a/locales/th/common.json b/locales/th/common.json index 0be3db9db..0f81a68cb 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6553,6 +6553,37 @@ "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}", "window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่" }, + "desktopOs": { + "section_title": "แอปเดสก์ท็อป", + "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", + "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด", + "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง", + "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์", + "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้", + "start_minimized_label": "เริ่มในถาดระบบ", + "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ", + "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่", + "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่", + "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า", + "quiet_hours_label": "ช่วงเวลาเงียบ", + "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ", + "quiet_hours_from": "จาก", + "quiet_hours_to": "ถึง", + "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด", + "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้", + "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง", + "default_app_label": "แอปเมลเริ่มต้น", + "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้", + "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ", + "default_app_button": "เลือกแอปเริ่มต้น…", + "new_mail": "เมลใหม่", + "no_subject": "(ไม่มีหัวเรื่อง)", + "action_delete": "ลบ", + "copy_as_file": "คัดลอกเป็นไฟล์", + "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", + "copy_failed": "คัดลอกไฟล์ไม่ได้", + "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น" + }, "desktop_sign_in": { "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", diff --git a/locales/tr/common.json b/locales/tr/common.json index fa74d9864..a9e5517e7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Bu ileti {folder} klasörüne taşındı.", "window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor." }, + "desktopOs": { + "section_title": "Masaüstü uygulaması", + "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", + "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et", + "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.", + "start_at_login_label": "Bilgisayarda oturum açınca başlat", + "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.", + "start_minimized_label": "Sistem tepsisinde başlat", + "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.", + "attention_label": "Yeni postada görev çubuğunu yanıp söndür", + "attention_mac_label": "Yeni postada Dock simgesini zıplat", + "attention_description": "Yalnızca uygulama önde değilken.", + "quiet_hours_label": "Sessiz saatler", + "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.", + "quiet_hours_from": "Başlangıç", + "quiet_hours_to": "bitiş", + "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster", + "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.", + "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.", + "default_app_label": "Varsayılan posta uygulaması", + "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.", + "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.", + "default_app_button": "Varsayılan uygulamayı seç…", + "new_mail": "Yeni posta", + "no_subject": "(konu yok)", + "action_delete": "Sil", + "copy_as_file": "Dosya olarak kopyala", + "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", + "copy_failed": "Dosya kopyalanamadı.", + "reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın." + }, "desktop_sign_in": { "browser_button": "Tarayıcıyla oturum aç", "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", diff --git a/locales/uk/common.json b/locales/uk/common.json index 391a5c203..f393aaf5a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Цього листа переміщено в {folder}.", "window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут." }, + "desktopOs": { + "section_title": "Настільний застосунок", + "section_description": "Як застосунок працює з вашим комп’ютером.", + "close_to_tray_label": "Під час закриття працювати далі в треї", + "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.", + "start_at_login_label": "Запускати під час входу в систему", + "start_at_login_description": "Застосунок запускається сам, коли ви входите.", + "start_minimized_label": "Запускати в треї", + "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.", + "attention_label": "Блимати на панелі завдань за нової пошти", + "attention_mac_label": "Підстрибувати значком у Dock за нової пошти", + "attention_description": "Лише коли застосунок не на передньому плані.", + "quiet_hours_label": "Тихі години", + "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.", + "quiet_hours_from": "З", + "quiet_hours_to": "до", + "show_shortcut_label": "Показувати застосунок сполученням клавіш", + "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.", + "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.", + "default_app_label": "Поштовий застосунок за замовчуванням", + "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.", + "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.", + "default_app_button": "Вибрати застосунок за замовчуванням…", + "new_mail": "Нова пошта", + "no_subject": "(без теми)", + "action_delete": "Видалити", + "copy_as_file": "Копіювати як файл", + "copied": "Файл скопійовано. Його можна вставити в папку.", + "copy_failed": "Не вдалося скопіювати файл.", + "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому." + }, "desktop_sign_in": { "browser_button": "Увійти через браузер", "browser_waiting": "Завершіть вхід у браузері", diff --git a/locales/vi/common.json b/locales/vi/common.json index 347fe2cd0..4d83b90c8 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6557,6 +6557,37 @@ "message_moved_to": "Thư này đã được chuyển đến {folder}.", "window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây." }, + "desktopOs": { + "section_title": "Ứng dụng máy tính", + "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", + "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng", + "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.", + "start_at_login_label": "Khởi động khi đăng nhập vào máy tính", + "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.", + "start_minimized_label": "Khởi động ở khay hệ thống", + "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.", + "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới", + "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới", + "attention_description": "Chỉ khi ứng dụng không ở phía trước.", + "quiet_hours_label": "Giờ yên tĩnh", + "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.", + "quiet_hours_from": "Từ", + "quiet_hours_to": "đến", + "show_shortcut_label": "Hiện ứng dụng bằng phím tắt", + "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.", + "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.", + "default_app_label": "Ứng dụng thư mặc định", + "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.", + "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.", + "default_app_button": "Chọn ứng dụng mặc định…", + "new_mail": "Thư mới", + "no_subject": "(không có chủ đề)", + "action_delete": "Xóa", + "copy_as_file": "Sao chép dưới dạng tệp", + "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", + "copy_failed": "Không thể sao chép tệp.", + "reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó." + }, "desktop_sign_in": { "browser_button": "Đăng nhập bằng trình duyệt", "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 97dacbb06..ecd9e0cce 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "這封郵件已移到 {folder}。", "window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。" }, + "desktopOs": { + "section_title": "桌面應用程式", + "section_description": "應用程式如何與您的電腦配合。", + "close_to_tray_label": "關閉時繼續在系統匣中執行", + "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。", + "start_at_login_label": "登入電腦時啟動", + "start_at_login_description": "您登入時應用程式會自動啟動。", + "start_minimized_label": "在系統匣中啟動", + "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。", + "attention_label": "有新郵件時閃爍工作列", + "attention_mac_label": "有新郵件時讓 Dock 圖示跳動", + "attention_description": "僅在應用程式不在前景時。", + "quiet_hours_label": "勿擾時段", + "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。", + "quiet_hours_from": "從", + "quiet_hours_to": "到", + "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式", + "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。", + "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。", + "default_app_label": "預設郵件應用程式", + "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。", + "default_app_is_default": "此應用程式會開啟您的郵件連結。", + "default_app_button": "選擇預設應用程式…", + "new_mail": "新郵件", + "no_subject": "(無主旨)", + "action_delete": "刪除", + "copy_as_file": "複製為檔案", + "copied": "檔案已複製。您可以將其貼到資料夾中。", + "copy_failed": "無法複製檔案。", + "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。" + }, "desktop_sign_in": { "browser_button": "使用瀏覽器登入", "browser_waiting": "請在瀏覽器中完成登入", diff --git a/locales/zh/common.json b/locales/zh/common.json index de9e40f3c..5650920a2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "这封邮件已移动到 {folder}。", "window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。" }, + "desktopOs": { + "section_title": "桌面应用", + "section_description": "应用如何与你的电脑配合。", + "close_to_tray_label": "关闭时继续在托盘中运行", + "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。", + "start_at_login_label": "登录电脑时启动", + "start_at_login_description": "你登录时应用会自动启动。", + "start_minimized_label": "在托盘中启动", + "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。", + "attention_label": "有新邮件时闪烁任务栏", + "attention_mac_label": "有新邮件时让 Dock 图标跳动", + "attention_description": "仅在应用不在前台时。", + "quiet_hours_label": "免打扰时段", + "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。", + "quiet_hours_from": "从", + "quiet_hours_to": "到", + "show_shortcut_label": "使用键盘快捷键显示应用", + "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。", + "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。", + "default_app_label": "默认邮件应用", + "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。", + "default_app_is_default": "此应用会打开你的邮件链接。", + "default_app_button": "选择默认应用…", + "new_mail": "新邮件", + "no_subject": "(无主题)", + "action_delete": "删除", + "copy_as_file": "复制为文件", + "copied": "文件已复制。你可以将其粘贴到文件夹中。", + "copy_failed": "无法复制文件。", + "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。" + }, "desktop_sign_in": { "browser_button": "使用浏览器登录", "browser_waiting": "请在浏览器中完成登录", From 4fd5c8c1aa3f054a900217f3fe38716362588ec1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:25:50 +0100 Subject: [PATCH 10/36] feat: keep a record of calendar reminders in the local store, so each fires once and missed ones can be summed up --- lib/store/__tests__/reminders.test.ts | 393 ++++++++++++++++++++++++++ lib/store/areas.ts | 3 +- lib/store/calendar/reminders.ts | 38 ++- lib/store/reminders/index.ts | 20 ++ lib/store/reminders/ledger.ts | 229 +++++++++++++++ lib/store/reminders/schema.ts | 20 ++ 6 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 lib/store/__tests__/reminders.test.ts create mode 100644 lib/store/reminders/index.ts create mode 100644 lib/store/reminders/ledger.ts create mode 100644 lib/store/reminders/schema.ts diff --git a/lib/store/__tests__/reminders.test.ts b/lib/store/__tests__/reminders.test.ts new file mode 100644 index 000000000..16cf60d2f --- /dev/null +++ b/lib/store/__tests__/reminders.test.ts @@ -0,0 +1,393 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { AccountEngine } from '../engine/account-engine'; +import { remindersArea } from '../reminders'; +import { FRESH_MS, MISSED_REACH_MS, type MissedBatch, type TickResult } from '../reminders/ledger'; +import type { StorageAdapter } from '../storage'; +import { CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { openMemoryStore } from './helpers/node-adapter'; + +const MIN = 60_000; +/** 10:00 in Berlin on 2026-11-10, a Tuesday. The events' alert goes 15 minutes before. */ +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const PARAMS = { timeZone: 'Europe/Berlin', tasks: true }; + +interface Rig { + server: FakeCalendarJmap; + db: StorageAdapter; + engine: AccountEngine; + now: number; + /** What a tab, a window or the next start of the app asks. */ + tick(params?: Partial & { muted?: boolean }): Promise; + call(name: string, params?: unknown): Promise; + restart(): void; +} + +function makeEngine(rig: Pick): AccountEngine { + return new AccountEngine({ + account: 'a@fake.test', + db: rig.db, + session: CALENDAR_SESSION, + transport: rig.server, + areas: [calendarArea, remindersArea], + scope: DEFAULT_SCOPE, + now: () => rig.now, + sleep: async () => {}, + }); +} + +function setup(version: StalwartVersion = '1.0', startAt = FIRE - 60 * MIN): Rig { + const rig = { + server: new FakeCalendarJmap(version), + db: openMemoryStore(), + now: startAt, + } as Rig; + rig.engine = makeEngine(rig); + rig.call = (name: string, params?: unknown) => rig.engine.areaCall(name, params) as Promise; + rig.tick = (params = {}) => rig.call('reminders.tick', { ...PARAMS, ...params }); + rig.restart = () => { + rig.engine = makeEngine(rig); + }; + return rig; +} + +/** Synced, with a first check done, so the store has been watching since before the alert. */ +async function synced(rig: Rig): Promise { + await rig.engine.requestSync(); + await rig.tick(); + return rig; +} + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +/** Comes back every minute or so, with no gap that would look like being away. */ +async function walkTo(rig: Rig, to: number): Promise { + const due: TickResult['due'] = []; + while (rig.now < to) { + rig.now = Math.min(rig.now + MIN, to); + due.push(...(await rig.tick()).due); + } + return due; +} + +describe('firing', () => { + it('fires from what is stored with the network gone', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.engine.setOnline(false); + rig.server.request = (() => { + throw new Error('Nothing may reach the server for a reminder'); + }) as unknown as FakeCalendarJmap['request']; + const due = await walkTo(rig, FIRE); + expect(due.map((alert) => [alert.title, alert.fireTimeMs, alert.startMs])).toEqual([['Standup', FIRE, START]]); + }); + + it('does not fire early, and says when to ask next', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - 90_000; + const before = await rig.tick(); + expect(before.due).toEqual([]); + expect(before.nextAt).toBe(FIRE); + }); + + it('gives an alert to one caller only, across tabs and restarts', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE; + const [first, second] = await Promise.all([rig.tick(), rig.tick()]); + expect([first.due.length, second.due.length]).toEqual([1, 0]); + rig.restart(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('expands a series itself: every occurrence fires, under its own key', async () => { + for (const version of ['1.0', '0.16'] as const) { + const rig = setup(version); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const first = await walkTo(rig, FIRE); + const week = 7 * 24 * 60 * MIN; + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + const second = (await rig.tick()).due; + expect(first.map((alert) => alert.fireTimeMs)).toEqual([FIRE]); + expect(second.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + expect(second[0].baseEventId).toBe('e1'); + expect(second[0].key).not.toBe(first[0].key); + expect(second[0].recurrenceId).toBeTruthy(); + } + }); + + it('keeps one key when a series gets an override, so nothing fires twice', async () => { + const rig = setup(); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const [before] = await walkTo(rig, FIRE); + // Another occurrence changes. On a server with synthetic ids this reshuffles them. + rig.server.patchEvent('e1', { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'Other' } } }); + expect(before.key).toBe(`${before.jmap}:uid-1:a:${FIRE}`); + await rig.engine.requestSync(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('reads `acknowledged` on a series as covering what fired up to then', async () => { + const rig = setup(); + const week = 7 * 24 * 60 * MIN; + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, acknowledged: new Date(FIRE + 60_000).toISOString() } }, + })); + await synced(rig); + expect(await walkTo(rig, FIRE + 2 * MIN)).toEqual([]); + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + expect((await rig.tick()).due.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + }); + + it('uses the calendar defaults while the event does, and leaves tasks out when asked', async () => { + const rig = setup('0.16'); + rig.server.calendars.get('cal1')!.defaultAlertsWithTime = { d1: ALERT }; + rig.server.addEvent(event({ id: 'def', uid: 'def', useDefaultAlerts: true, alerts: null })); + rig.server.addEvent({ id: 'task', uid: 'task', '@type': 'Task', title: 'Pay', start: undefined, due: '2026-11-10T09:00:00Z', alerts: { a: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE - MIN; + await rig.tick({ tasks: false }); + rig.now = FIRE; + const due = (await rig.tick({ tasks: false })).due; + expect(due.map((alert) => [alert.baseEventId, alert.alertId])).toEqual([['def', 'd1']]); + }); + + it('holds a join link only for an event that has one', async () => { + const rig = setup(); + rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } })); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Lunch', locations: { l: { '@type': 'Location', name: 'Cafe' } } })); + await synced(rig); + const due = await walkTo(rig, FIRE); + expect(Object.fromEntries(due.map((alert) => [alert.title, alert.joinUrl]))).toEqual({ + Standup: 'https://meet.jit.si/standup', + Lunch: null, + }); + }); +}); + +describe('away', () => { + async function awayWithThree(): Promise { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T11:00:00' })); + rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Lunch', start: '2026-11-10T12:00:00' })); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + return rig; + } + + it('sends what came due while away to one summary, not a burst, and only once', async () => { + const rig = await awayWithThree(); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + const back = await rig.tick(); + expect(back.due).toEqual([]); + expect(back.missed).toBe(3); + const [one, two] = await Promise.all([ + rig.call('reminders.takeMissed', PARAMS), + rig.call('reminders.takeMissed', PARAMS), + ]); + expect(one!.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Review', 'Lunch']); + expect(two).toBeNull(); + rig.restart(); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + expect(await rig.call('reminders.takeMissed', PARAMS)).toBeNull(); + }); + + it('summarises the ones within ten minutes as well: a wake-up is no time for a burst', async () => { + const rig = await awayWithThree(); + rig.now = START + 2 * 60 * MIN - 15 * MIN + 2 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 3 }); + }); + + it('shows a single alert that just came due as a normal reminder', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [{ title: 'Standup' }], missed: 0 }); + }); + + it('summarises a single alert that is long past', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + FRESH_MS + MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('counts nothing as missed on the very first check, however old the calendar', async () => { + const rig = setup('1.0', FIRE + 60 * MIN); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('reaches back a week at most', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + MISSED_REACH_MS + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + }); + + it('leaves out what was deleted while away', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(3); + rig.server.removeEvent('e2'); + await rig.engine.requestSync(); + const batch = await rig.call('reminders.takeMissed', PARAMS); + expect(batch.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Lunch']); + }); + + it('puts a summary that could not be shown back, and reads a shown one again for a late click', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + await rig.tick(); + const refused = await rig.call('reminders.takeMissed', PARAMS); + await rig.call('reminders.releaseMissed', { batch: refused.batch }); + expect((await rig.tick()).missed).toBe(3); + const shown = await rig.call('reminders.takeMissed', PARAMS); + expect(shown.alerts).toHaveLength(3); + rig.restart(); + const read = await rig.call('reminders.batch', { batch: shown.batch }); + expect(read).toHaveLength(3); + await rig.call('reminders.dismiss', { keys: shown.alerts.map((alert) => alert.key) }); + expect(await rig.call('reminders.batch', { batch: shown.batch })).toEqual([]); + }); + + it('moves a fired alert nobody could be told about to the summary', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const [fired] = await walkTo(rig, FIRE); + await rig.call('reminders.hold', { keys: [fired.key] }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('keeps quiet while reminders are off, and nothing from that time is missed later', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + 60 * MIN; + expect(await rig.tick({ muted: true })).toMatchObject({ due: [], missed: 0, nextAt: null }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); +}); + +describe('snooze', () => { + async function fired(version: StalwartVersion = '1.0') { + const rig = setup(version); + rig.server.addEvent(event()); + await synced(rig); + const [alert] = await walkTo(rig, FIRE); + return { rig, alert }; + } + + it('comes back after the minutes asked for, once, and survives a restart', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 5 })).toBe(true); + rig.now = FIRE + 2 * MIN; + const during = await rig.tick(); + expect(during.due).toEqual([]); + expect(during.nextAt).toBe(FIRE + 5 * MIN); + + rig.restart(); + rig.now = FIRE + 4 * MIN; + expect((await rig.tick()).due).toEqual([]); + rig.now = FIRE + 5 * MIN; + const back = (await rig.tick()).due; + expect(back.map((a) => [a.title, a.snoozedUntil])).toEqual([['Standup', FIRE + 5 * MIN]]); + rig.now += MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('can be snoozed again', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due).toHaveLength(1); + await rig.call('reminders.snooze', { key: alert.key, minutes: 10 }); + rig.now = FIRE + 15 * MIN; + const back = (await rig.tick()).due; + expect(back[0].snoozedUntil).toBe(FIRE + 15 * MIN); + }); + + it('goes to the summary when it ran out while the app was quit', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('does not come back for an event deleted in the meantime', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.server.removeEvent('e1'); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('does not come back once dismissed in another window', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], nextAt: null }); + }); + + it('refuses an unknown reminder and silly minutes', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: 'nope', minutes: 5 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: -1 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 99_999 })).toBe(false); + }); +}); + +it('keeps reminders off the server', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const before = JSON.stringify(rig.server.events.get('e1')); + const [alert] = await walkTo(rig, FIRE); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + await rig.engine.requestSync(); + expect(JSON.stringify(rig.server.events.get('e1'))).toBe(before); +}); diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 08fe2c607..c17d15b7f 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -4,5 +4,6 @@ import type { StoreArea } from './area'; import { calendarArea } from './calendar'; import { mailArea } from './mail'; import { outboxArea } from './outbox'; +import { remindersArea } from './reminders'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, remindersArea]; diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts index b42307346..e293d1618 100644 --- a/lib/store/calendar/reminders.ts +++ b/lib/store/calendar/reminders.ts @@ -12,6 +12,7 @@ import { format, parseISO } from 'date-fns'; import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; import { isTaskLikeObject } from '@/lib/calendar-component-detection'; import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { findMeetingLink, type EventPlaces } from '@/lib/event-links'; import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { listObjects } from '../engine/core-schema'; @@ -22,6 +23,8 @@ export interface UpcomingAlert { /** Stable for this alert at this time. Use it to remember what was shown. */ key: string; jmap: string; + /** The series' uid, or the id where there's none: the same one whichever path shows the reminder. */ + uid: string; kind: 'event' | 'task'; /** The stored event or task: the master of a series. */ baseEventId: string; @@ -32,6 +35,10 @@ export interface UpcomingAlert { title: string; /** The start as a wall clock, or a task's due when it has no start. */ start: string | null; + /** The start as an instant, to say how long until it. Null when it can't be told. */ + startMs: number | null; + /** Joins the meeting, when the event has a video link. */ + joinUrl: string | null; timeZone: string | null; calendarId: string | null; calendarName: string | null; @@ -82,6 +89,24 @@ function withInstants(object: CalendarObject, zone: string): CalendarObject { }; } +/** + * RFC 8984 4.5.2: on a series `acknowledged` sits on the master and covers what fired + * up to then. Read as "never again", one dismissal in another app would silence every + * later occurrence. An event once is done with it either way. + */ +function acknowledgedAlready(alert: CalendarEventAlert, fireTimeMs: number, series: boolean): boolean { + if (!alert.acknowledged) return false; + if (!series) return true; + const at = new Date(alert.acknowledged).getTime(); + return Number.isNaN(at) || fireTimeMs <= at; +} + +function instant(value: string | null | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; @@ -101,7 +126,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp const object = JSON.parse(row.json) as CalendarObject; const calendar = firstCalendar(object.calendarIds, calendars); // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. - const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; + const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id; + const stableId = `${row.jmap}:${uid}`; if (isTaskLikeObject(object)) { const task = object as unknown as CalendarTask; @@ -113,6 +139,7 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp add({ key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, + uid, kind: 'task', baseEventId: object.id, recurrenceId: null, @@ -120,6 +147,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp fireTimeMs, title: task.title ?? '', start: task.start ?? task.due ?? null, + startMs: instant(task.start ?? task.due), + joinUrl: findMeetingLink(task as unknown as EventPlaces)?.uri ?? null, timeZone: task.timeZone ?? null, calendarId: calendar.id, calendarName: calendar.name, @@ -138,13 +167,14 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp if (occurrence.status === 'cancelled') continue; const isMasterOfSeries = series && occurrence === master; for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { - if (alert.action !== 'display' || alert.acknowledged) continue; + if (alert.action !== 'display') continue; if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; const fireTimeMs = computeFireTime(occurrence, alert.trigger); - if (fireTimeMs === null) continue; + if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue; add({ key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, + uid, kind: 'event', baseEventId: object.id, recurrenceId: occurrence.recurrenceId ?? null, @@ -152,6 +182,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp fireTimeMs, title: occurrence.title ?? '', start: occurrence.start, + startMs: instant(occurrence.utcStart) ?? instant(occurrence.start), + joinUrl: findMeetingLink(occurrence as unknown as EventPlaces)?.uri ?? null, timeZone: occurrence.timeZone ?? null, calendarId: calendar.id, calendarName: calendar.name, diff --git a/lib/store/reminders/index.ts b/lib/store/reminders/index.ts new file mode 100644 index 000000000..337a16b38 --- /dev/null +++ b/lib/store/reminders/index.ts @@ -0,0 +1,20 @@ +import type { StoreArea } from '../area'; +import { dismiss, holdFired, readBatch, readReminder, releaseMissed, snooze, takeMissed, tick, type TickParams } from './ledger'; +import { REMINDER_MIGRATIONS } from './schema'; + +export const remindersArea: StoreArea = { + name: 'reminders', + migrations: REMINDER_MIGRATIONS, + methods: {}, + syncers: [], + calls: { + 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), params), + 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), params), + 'reminders.releaseMissed': (ctx, params: { batch: number }) => releaseMissed(ctx.db, ctx.now(), params.batch), + 'reminders.batch': (ctx, params: { batch: number }) => readBatch(ctx.db, params.batch), + 'reminders.hold': (ctx, params: { keys: string[] }) => holdFired(ctx.db, ctx.now(), params.keys), + 'reminders.snooze': (ctx, params: { key: string; minutes: number }) => snooze(ctx.db, ctx.now(), params.key, params.minutes), + 'reminders.dismiss': (ctx, params: { keys: string[] }) => dismiss(ctx.db, ctx.now(), params.keys), + 'reminders.get': (ctx, params: { key: string }) => readReminder(ctx.db, params.key), + }, +}; diff --git a/lib/store/reminders/ledger.ts b/lib/store/reminders/ledger.ts new file mode 100644 index 000000000..3f82a4fb6 --- /dev/null +++ b/lib/store/reminders/ledger.ts @@ -0,0 +1,229 @@ +/** + * What has become of each reminder on this device: the one place that decides what + * fires. Every call is one synchronous transaction in the store's worker, and all tabs + * and windows reach the same worker, so whichever asks first gets an alert and nobody + * else ever does, across restarts too. localStorage can't do this: a worker can't read + * it, and two tabs can read it at the same moment. + * + * A row's status: + * - `fired`: shown, nothing more to do. + * - `snoozed`: comes back at `snooze_until`. + * - `missed`: waits for a summary, because the device was away or notifications were + * paused. + * - `summarised`: went into the summary `batch`. + * - `dismissed`: the user is done with it. + * + * None of this goes to the server. JSCalendar's `acknowledged` is read (reminders.ts), + * never written: decisions file, reminders. + */ + +import { metaGet, metaSet } from '../engine/core-schema'; +import { listUpcomingAlerts, type UpcomingAlert } from '../calendar/reminders'; +import type { StorageAdapter } from '../storage'; + +/** Younger than this, an alert still fires by itself. As lib/calendar-alerts.ts. */ +export const FRESH_MS = 10 * 60_000; +/** A gap between two checks longer than this means the device was away. A little over the page's refresh. */ +export const AWAY_MS = 6 * 60_000; +/** How far back a summary reaches. After a month away nobody needs a month of reminders. */ +export const MISSED_REACH_MS = 7 * 24 * 3_600_000; +/** How far ahead a check looks for the next fire time. */ +export const LOOK_AHEAD_MS = 6 * 3_600_000; +/** Rows older than the reach can't come up again. */ +const KEEP_MS = MISSED_REACH_MS + 24 * 3_600_000; +const CURSOR = 'reminder_cursor'; +const LONGEST_SNOOZE_MIN = 24 * 60; + +export interface ReminderAlert extends UpcomingAlert { + /** Set when it comes back from a snooze: when that was due. */ + snoozedUntil?: number; +} + +export interface TickParams { + /** Reads floating events: the user's zone. */ + timeZone: string; + /** Tasks' alerts too. */ + tasks: boolean; + /** Reminders are switched off: time passes, and nothing counts as missed later. */ + muted?: boolean; +} + +export interface TickResult { + /** Yours to show. No other caller gets these. */ + due: ReminderAlert[]; + /** How many wait for a summary. */ + missed: number; + /** When to ask again at the latest for the next reminder. Null: none in sight. */ + nextAt: number | null; + /** The calendar has synced at least once, so an empty answer means something. */ + ready: boolean; +} + +interface Row { + key: string; + status: string; + fire_time: number; + snooze_until: number | null; + alert: string; +} + +function put(db: StorageAdapter, alert: ReminderAlert, status: string, now: number): void { + db.run( + `INSERT INTO reminder (key, status, fire_time, snooze_until, batch, alert, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) + ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, alert = excluded.alert, updated_at = excluded.updated_at`, + [alert.key, status, alert.fireTimeMs, JSON.stringify(alert), now], + ); +} + +function readAlert(row: Pick): ReminderAlert { + return JSON.parse(row.alert) as ReminderAlert; +} + +function missedCount(db: StorageAdapter): number { + return Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM reminder WHERE status = 'missed'")?.n ?? 0); +} + +function calendarReady(db: StorageAdapter): boolean { + return !!db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'CalendarEvent' AND state IS NOT NULL LIMIT 1"); +} + +/** Whether a snoozed alert still stands: the event may have gone, moved or been dismissed elsewhere. */ +function alertNow(db: StorageAdapter, row: Pick, timeZone: string): UpcomingAlert | undefined { + return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone }).find((alert) => alert.key === row.key); +} + +/** + * Hands out what's due and settles the rest. Alerts that came due while the device was + * away go to the summary instead of arriving as a burst. A single recent one is just a + * reminder that came a little late. + */ +export function tick(db: StorageAdapter, now: number, params: TickParams): TickResult { + return db.transaction(() => { + const raw = metaGet(db, CURSOR); + const cursor = raw === null ? null : Number(raw); + metaSet(db, CURSOR, String(now)); + if (params.muted) return { due: [], missed: missedCount(db), nextAt: null, ready: calendarReady(db) }; + + const freshFrom = now - FRESH_MS; + const from = cursor === null ? freshFrom : Math.max(Math.min(cursor, freshFrom), now - MISSED_REACH_MS); + const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone }) + .filter((alert) => params.tasks || alert.kind !== 'task'); + const past = listed.filter((alert) => alert.fireTimeMs <= now); + const known = new Set( + past.length === 0 + ? [] + : db.all<{ key: string }>('SELECT key FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]).map((row) => row.key), + ); + const unseen = past.filter((alert) => !known.has(alert.key)); + + const away = cursor === null || now - cursor > AWAY_MS; + const lastSeen = cursor ?? freshFrom; + // Behind the cursor: it reached the store late, through a sync. Nobody was away from that. + const inGap = away ? unseen.filter((alert) => alert.fireTimeMs > lastSeen) : []; + const summarise = new Set( + inGap.length === 1 && inGap[0].fireTimeMs >= freshFrom ? [] : inGap.map((alert) => alert.key), + ); + const due: ReminderAlert[] = []; + for (const alert of unseen) { + if (summarise.has(alert.key) || alert.fireTimeMs < freshFrom) { + put(db, alert, 'missed', now); + } else { + put(db, alert, 'fired', now); + due.push(alert); + } + } + + const expired = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]); + for (const row of expired) { + const alert = alertNow(db, row, params.timeZone); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + if (now - row.snooze_until! < FRESH_MS) { + const back: ReminderAlert = { ...alert, snoozedUntil: row.snooze_until! }; + put(db, back, 'fired', now); + due.push(back); + } else { + put(db, alert, 'missed', now); + } + } + + db.run("DELETE FROM reminder WHERE fire_time < ? AND status NOT IN ('snoozed', 'missed')", [now - KEEP_MS]); + + const coming = listed.find((alert) => alert.fireTimeMs > now)?.fireTimeMs ?? null; + const snooze = db.get<{ at: number | null }>("SELECT MIN(snooze_until) AS at FROM reminder WHERE status = 'snoozed'")?.at ?? null; + const nextAt = coming === null ? snooze : snooze === null ? coming : Math.min(coming, snooze); + return { due, missed: missedCount(db), nextAt, ready: calendarReady(db) }; + }); +} + +export interface MissedBatch { + /** Says which summary, for a toast clicked later. */ + batch: number; + alerts: ReminderAlert[]; +} + +/** + * The summary, for one caller only. What's gone from the calendar since doesn't come + * back to haunt anyone. Null: nothing to say. + */ +export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string }): MissedBatch | null { + return db.transaction(() => { + const rows = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key"); + if (rows.length === 0) return null; + const standing = new Map( + listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone }) + .map((alert) => [alert.key, alert]), + ); + const newest = db.get<{ batch: number | null }>('SELECT MAX(batch) AS batch FROM reminder')?.batch ?? 0; + const batch = Math.max(now, newest + 1); + const alerts: ReminderAlert[] = []; + for (const row of rows) { + const alert = standing.get(row.key); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + db.run("UPDATE reminder SET status = 'summarised', batch = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, JSON.stringify(alert), now, row.key]); + alerts.push(alert); + } + return alerts.length > 0 ? { batch, alerts } : null; + }); +} + +/** The summary couldn't be shown after all (paused, quiet hours): it stays due. */ +export function releaseMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET status = 'missed', batch = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); +} + +/** What a summary said, for a click on it, possibly after a restart. */ +export function readBatch(db: StorageAdapter, batch: number): ReminderAlert[] { + return db.all("SELECT alert FROM reminder WHERE status = 'summarised' AND batch = ? ORDER BY fire_time, key", [batch]).map(readAlert); +} + +/** Fired, but nobody could be told: it goes in the next summary. */ +export function holdFired(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'missed', updated_at = ? WHERE status = 'fired' AND key = ?", [now, key]); + }); +} + +/** False when this device knows no such reminder, or the minutes make no sense. */ +export function snooze(db: StorageAdapter, now: number, key: string, minutes: number): boolean { + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > LONGEST_SNOOZE_MIN) return false; + const until = now + minutes * 60_000; + return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0; +} + +export function dismiss(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, key]); + }); +} + +/** One reminder as it was last shown: what a toast's Join reads, since the toast holds only the key. */ +export function readReminder(db: StorageAdapter, key: string): ReminderAlert | null { + const row = db.get>('SELECT alert FROM reminder WHERE key = ?', [key]); + return row ? readAlert(row) : null; +} diff --git a/lib/store/reminders/schema.ts b/lib/store/reminders/schema.ts new file mode 100644 index 000000000..445e368e1 --- /dev/null +++ b/lib/store/reminders/schema.ts @@ -0,0 +1,20 @@ +import type { Migration } from '../storage'; + +export const REMINDER_MIGRATIONS: readonly Migration[] = [ + { + id: 850, + name: 'reminders', + sql: ` + CREATE TABLE reminder ( + key TEXT PRIMARY KEY, + status TEXT NOT NULL, + fire_time INTEGER NOT NULL, + snooze_until INTEGER, + batch INTEGER, + alert TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) WITHOUT ROWID; + CREATE INDEX reminder_status ON reminder (status, snooze_until); + `, + }, +]; From 7c8ae2fe89b15c4d363f0bf0348068ca23da5eb2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:29:11 +0100 Subject: [PATCH 11/36] fix: keep a hidden composer from taking files dropped on the window --- components/email/email-composer.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f0dab3ae6..be8263737 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2650,12 +2650,6 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); - // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. - useEffect(() => setComposerFileTarget( - (files) => void addFiles(files), - () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, - ), [addFiles, composerRootEl]); - const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -2705,6 +2699,12 @@ export function EmailComposer({ const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); // The plugin side panels fold when the composer gets too narrow for them. const [composerRootEl, setComposerRootEl] = useState(null); + + // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + useEffect(() => setComposerFileTarget( + (files) => void addFiles(files), + () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + ), [addFiles, composerRootEl]); // The id plugins know this composer by (registered below, with the handle). const [pluginComposerId, setPluginComposerId] = useState(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); From a802411cddfa6d2855b3590ca1bedd1508dc54de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:31:58 +0100 Subject: [PATCH 12/36] feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed --- .../calendar/missed-reminders-dialog.tsx | 70 ++++ components/desktop/desktop-os-host.tsx | 19 - .../providers/calendar-alert-provider.tsx | 4 + .../ui/__tests__/toast-extra-actions.test.tsx | 33 ++ components/ui/toast.tsx | 3 + .../use-calendar-alerts-hooks.test.tsx | 48 +++ hooks/__tests__/use-store-reminders.test.tsx | 380 ++++++++++++++++++ hooks/use-calendar-alerts.ts | 136 ++++--- hooks/use-store-reminders.ts | 278 +++++++++++++ .../__tests__/claims.test.ts | 98 +++++ .../__tests__/runner.test.ts | 268 ++++++++++++ lib/calendar-reminders/channel.ts | 32 ++ lib/calendar-reminders/claims.ts | 70 ++++ lib/calendar-reminders/runner.ts | 156 +++++++ lib/calendar-reminders/state.ts | 55 +++ lib/calendar-reminders/system-notification.ts | 70 ++++ .../__tests__/reminder-notifications.test.ts | 239 +++++++++++ lib/desktop/os-commands.ts | 2 + lib/desktop/reminder-notifications.ts | 218 ++++++++++ lib/desktop/types.ts | 2 + lib/offline/offline-mode.ts | 2 + lib/offline/store-events.ts | 18 + lib/sign-out-cleanup.ts | 1 + locales/ar/common.json | 14 + locales/ca/common.json | 14 + locales/cs/common.json | 14 + locales/da/common.json | 14 + locales/de/common.json | 14 + locales/en/common.json | 14 + locales/es/common.json | 14 + locales/et/common.json | 14 + locales/fa/common.json | 14 + locales/fr/common.json | 14 + locales/he/common.json | 14 + locales/hu/common.json | 14 + locales/it/common.json | 14 + locales/ja/common.json | 14 + locales/ko/common.json | 14 + locales/lv/common.json | 14 + locales/mn/common.json | 14 + locales/nb/common.json | 14 + locales/nl/common.json | 14 + locales/pl/common.json | 14 + locales/pt/common.json | 14 + locales/ro/common.json | 14 + locales/ru/common.json | 14 + locales/sk/common.json | 14 + locales/th/common.json | 14 + locales/tr/common.json | 14 + locales/uk/common.json | 14 + locales/vi/common.json | 14 + locales/zh-TW/common.json | 14 + locales/zh/common.json | 14 + public/sw.js | 1 + stores/calendar-notification-store.ts | 2 + 55 files changed, 2555 insertions(+), 70 deletions(-) create mode 100644 components/calendar/missed-reminders-dialog.tsx create mode 100644 components/ui/__tests__/toast-extra-actions.test.tsx create mode 100644 hooks/__tests__/use-store-reminders.test.tsx create mode 100644 hooks/use-store-reminders.ts create mode 100644 lib/calendar-reminders/__tests__/claims.test.ts create mode 100644 lib/calendar-reminders/__tests__/runner.test.ts create mode 100644 lib/calendar-reminders/channel.ts create mode 100644 lib/calendar-reminders/claims.ts create mode 100644 lib/calendar-reminders/runner.ts create mode 100644 lib/calendar-reminders/state.ts create mode 100644 lib/calendar-reminders/system-notification.ts create mode 100644 lib/desktop/__tests__/reminder-notifications.test.ts create mode 100644 lib/desktop/reminder-notifications.ts create mode 100644 lib/offline/store-events.ts diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..6309994fd --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state"; +import { dismissReminders, joinMeeting } from "@/lib/desktop/reminder-notifications"; +import { useSettingsStore } from "@/stores/settings-store"; + +/** What came due while the app was closed, the machine slept or notifications were paused. */ +export function MissedRemindersDialog() { + const t = useTranslations("desktopReminders"); + const tAlert = useTranslations("calendar.notifications"); + const locale = useLocale(); + const timeZone = useSettingsStore((s) => s.timeZone); + const account = useMissedReminders((s) => s.account); + const alerts = useMissedReminders((s) => s.alerts); + const close = useMissedReminders((s) => s.close); + + if (!account || alerts.length === 0) return null; + + const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) }); + const finish = () => { + dismissReminders(account, alerts.map((alert) => alert.key)); + close(); + }; + + return ( + + + + + )} + > +
    + {alerts.map((alert) => ( +
  • +
    +

    {alert.title || tAlert("alert_title")}

    +

    + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} +

    +
    + {alert.joinUrl && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index c8d2755d8..df31098c0 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -9,11 +9,8 @@ import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; import { startWindowFiles } from "@/lib/desktop/window-files"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import { calendarHooks } from "@/lib/plugin-hooks"; -import type { CalendarAlertView } from "@/lib/plugin-types"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { useSettingsStore } from "@/stores/settings-store"; /** * The main window's end of the desktop app's OS integration: new mail as native @@ -25,7 +22,6 @@ export function DesktopOsHost() { const t = useTranslations("desktopOs"); const tNotify = useTranslations("notifications"); const tPush = useTranslations("pwaNotify"); - const tCalendar = useTranslations("calendar.notifications"); const locale = useLocale(); const archiveEnabled = useArchiveActionEnabled(); const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); @@ -56,29 +52,14 @@ export function DesktopOsHost() { summaryRef.current = summary; const stopPower = startPowerWatch(); const stopFiles = startWindowFiles(); - // Reminders' own native notification (snooze, join) comes with the reminders work. - // Until then a reminder must not fire into a hidden window unseen. - const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => { - const { calendarNotificationsEnabled } = useSettingsStore.getState(); - if (!calendarNotificationsEnabled) return; - const event = "eventId" in view; - void getDesktopBridge()?.os?.notify({ - id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, - title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), - body: "", - context: "{}", - }); - }); return () => { mail.stop(); summary.stop(); stopPower(); stopFiles(); - reminders.dispose(); mailRef.current = null; summaryRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx index cb0b72b6a..561586746 100644 --- a/components/providers/calendar-alert-provider.tsx +++ b/components/providers/calendar-alert-provider.tsx @@ -1,12 +1,15 @@ "use client"; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; import { useCalendarAlerts } from '@/hooks/use-calendar-alerts'; +import { useStoreReminders } from '@/hooks/use-store-reminders'; import { ToastContainer } from '@/components/ui/toast'; import { LiveRegions } from '@/components/ui/live-regions'; import { useToastStore } from '@/stores/toast-store'; export function CalendarAlertProvider({ children }: { children: React.ReactNode }) { useCalendarAlerts(); + useStoreReminders(); const toasts = useToastStore((s) => s.toasts); const removeToast = useToastStore((s) => s.removeToast); @@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode <> {children} + ); diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx new file mode 100644 index 000000000..10354d4f3 --- /dev/null +++ b/components/ui/__tests__/toast-extra-actions.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ToastItem } from '../toast'; + +describe('a toast with more than three actions', () => { + it('shows them in order, and each closes it', () => { + vi.useFakeTimers(); + const clicked = vi.fn(); + const onDismiss = vi.fn(); + const action = (label: string) => ({ label, onClick: () => clicked(label) }); + render( + , + ); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(clicked).toHaveBeenCalledWith('join'); + expect(onDismiss).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 28bb16c0a..a3d96a2be 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -36,6 +36,8 @@ export interface Toast { * that has one lays its actions out on a row of their own. */ tertiaryAction?: ToastAction; + /** More actions at the end of that row. Needs a tertiaryAction. */ + extraActions?: ToastAction[]; /** * Work in progress, e.g. an upload: 0..1, or null while the amount is * unknown. The bottom line then shows progress instead of a countdown, @@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr {actionButton(toast.tertiaryAction)} {toast.secondaryAction && actionButton(toast.secondaryAction)} {toast.action && actionButton(toast.action)} + {toast.extraActions?.map((action) => {actionButton(action)})} )} diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx index 331953d64..0f3ca9527 100644 --- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx +++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx @@ -76,6 +76,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('the 24-hour fetch', () => { + it('fires the occurrence of a series the server answered with its master', async () => { + const now = Date.now(); + const occurrenceStart = new Date(now + 5 * 60_000); + const stamp = (date: Date) => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + }; + // The same weekday and time, years ago: only the expansion finds today's. + const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000); + const master = { + id: 'series', + uid: 'uid-series', + title: 'Weekly', + start: stamp(first), + duration: 'PT30M', + calendarIds: { c1: true }, + useDefaultAlerts: false, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + alerts: { + a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } }, + }, + } as unknown as CalendarEvent; + useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true }); + useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never }); + + renderHook(() => useCalendarAlerts()); + + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly'])); + }); +}); + +describe('two tabs', () => { + it('rings in one, also when an id changed between the two', async () => { + const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent; + renderHook(() => useCalendarAlerts()); + await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1)); + + // The other tab has the same reminder under the id the server gives it now. + useCalendarStore.setState({ events: [synthetic] }); + renderHook(() => useCalendarAlerts()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 700)); + }); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); +}); + describe('reminder hooks', () => { it('reports a fired reminder once, and its dismissal when the user closes it', async () => { const fired = vi.fn(); diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx new file mode 100644 index 000000000..afcad0f8a --- /dev/null +++ b/hooks/__tests__/use-store-reminders.test.tsx @@ -0,0 +1,380 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; +import { ToastContainer } from '@/components/ui/toast'; +import { useCalendarAlerts } from '../use-calendar-alerts'; +import { useStoreReminders } from '../use-store-reminders'; +import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { emitStoreEvent } from '@/lib/offline/store-events'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +const sounds = vi.hoisted(() => ({ play: vi.fn() })); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play })); +vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (original) => ({ + ...await original(), + isPopoutWindow: () => popout.value, +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + popout.value = false; + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('in the desktop app', () => { + beforeEach(desktop); + + it('fires a native notification with the window hidden, at the time, with the network gone', async () => { + await syncedWith(event(LINK)); + await store.setOnline(false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() - 1000); + expect(notify).not.toHaveBeenCalled(); + await advance(1000); + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.title).toBe('Standup'); + expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + expect(toastTitles()).toEqual([]); + }); + + it('shows no Join for an event without a link', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join'); + }); + + it('plays the sound main asks for, and asks for none in Do not disturb', async () => { + await syncedWith(event()); + notifyResult = { shown: true, playSound: true }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).toHaveBeenCalledTimes(1); + + useSettingsStore.setState({ doNotDisturb: true }); + await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' })); + emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] }); + await advance(MIN); + expect(notify.mock.calls[1][0].sound).toBe(false); + }); + + it('shows it in the app when the window is in front, with the same choices', async () => { + await syncedWith(event(LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: true }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + expect(sounds.play).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + }); + + it('snoozing from the in-app toast brings it back, and closing it does not', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + fireEvent.click(screen.getAllByText('snooze')[0]); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + + await advance(1000); + const again = useToastStore.getState().toasts.at(-1)!; + act(() => again.onDismiss!()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); + + it('holds what fires while paused or in quiet hours for the summary afterwards', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + + // Still quiet: the summary is tried and put back. + await advance(5 * MIN); + const tried = notify.mock.calls.length; + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + + await advance(30 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + }); + + it('says nothing of the event while locked, and tells afterwards', async () => { + await syncedWith(event()); + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + expect(notify.mock.calls[0][0].actions).toBeUndefined(); + expect(toastTitles()).toEqual([]); + + await advance(10 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + + locked = false; + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1][0].body).toBe('Standup'); + }); + + it('summarises what was missed once, with a native notification, across a restart', async () => { + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + + const after = renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' }); + after.unmount(); + + renderHook(() => useStoreReminders()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('summarises in the app when the window is in front, and the dialog lists them', async () => { + await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + render(<>); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(toastTitles()).toEqual(['missed_title']); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + const list = screen.getByTestId('missed-reminders'); + expect(list.textContent).toContain('Standup'); + expect(list.textContent).toContain('Review'); + expect(screen.getAllByText('join')).toHaveLength(1); + fireEvent.click(screen.getByText('done')); + await advance(0); + expect(screen.queryByTestId('missed-reminders')).toBeNull(); + }); + + it('tells plugins, and lets one take the reminder over', async () => { + await syncedWith(event()); + const fired = vi.fn(); + calendarHooks.onCalendarAlert.register('t', fired); + calendarHooks.onBeforeCalendarAlert.register('t', () => false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' })); + expect(notify).not.toHaveBeenCalled(); + }); + + function calendarStoreHas(uid: string, title: string) { + useCalendarStore.setState({ + events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + } + + it('makes the old path stand down once the store has this account', async () => { + await syncedWith(event()); + // Not the same event as far as the claim can tell: only standing down keeps it quiet. + calendarStoreHas('another-uid', 'Old path'); + renderHook(() => { + useCalendarAlerts(); + useStoreReminders(); + }); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + }); + + it('lets the two paths tell each other what fired, for the moment the store takes over', async () => { + await syncedWith(event()); + calendarStoreHas('uid-1', 'Standup'); + const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims'); + // The old path got there just before the store was ready. + expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).not.toHaveBeenCalled(); + }); +}); + +describe('in a browser', () => { + function grantNotifications(showNotification = vi.fn(async () => {})) { + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('keeps the in-app toast, without desktop buttons', async () => { + await syncedWith(event(LINK)); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const toast = useToastStore.getState().toasts[0]; + expect(toast.title).toBe('Standup'); + expect(toast.action).toBeUndefined(); + expect(toast.extraActions).toBeUndefined(); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); + + it('adds a system notification only where permission is granted and the tab is out of view', async () => { + await syncedWith(event()); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false })); + }); + + it('asks for no permission and shows none when it was not granted', async () => { + await syncedWith(event()); + const request = vi.fn(); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request })); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(request).not.toHaveBeenCalled(); + }); + + it('fires in one of two tabs', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('closes the toast in this window when another acknowledges the reminder', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`; + const other = new BroadcastChannel('bulwark-calendar-reminders'); + other.postMessage({ kind: 'acknowledged', keys: [key] }); + other.close(); + vi.useRealTimers(); + await vi.waitFor(() => expect(toastTitles()).toEqual([])); + }); + + it('gives nothing to a pop-out', async () => { + popout.value = true; + await syncedWith(event()); + const area = vi.spyOn(store, 'area'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(area).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 8c65c12ee..365647480 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useTranslations, useLocale } from 'next-intl'; -import { parseISO } from 'date-fns'; +import { format, parseISO } from 'date-fns'; import { isPopoutWindow } from '@/lib/popout/window-role'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; @@ -11,10 +11,16 @@ import { useTaskStore } from '@/stores/task-store'; import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; import { useToastStore } from '@/stores/toast-store'; import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts'; +import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims'; +import { useReminderSource } from '@/lib/calendar-reminders/state'; +import { showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { playNotificationSound } from '@/lib/notification-sound'; import { lastingToastDuration } from '@/lib/toast-duration'; import { startTitleAlert } from '@/lib/title-alert'; import { getPathPrefix } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarAlertView } from '@/lib/plugin-types'; import { calendarHooks } from '@/lib/plugin-hooks'; @@ -23,6 +29,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events'; const CHECK_INTERVAL_MS = 60 * 1000; const PROACTIVE_FETCH_HOURS = 24; const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5; +// Wall clocks: an occurrence's can be a day off the instant it's at. +const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000; // Longest single wait for the next reminder; the periodic check covers the rest. const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000; /** How long a reminder stays at the shortest "Notification duration". */ @@ -42,7 +50,7 @@ export function reminderToastDuration(): number { * Whether a reminder plays its sound. Do not disturb keeps reminders on * screen (they were asked for) but silent. */ -function reminderSoundOn(soundSetting: boolean): boolean { +export function reminderSoundOn(soundSetting: boolean): boolean { return soundSetting && !useSettingsStore.getState().doNotDisturb; } @@ -51,7 +59,7 @@ function reminderSoundOn(soundSetting: boolean): boolean { * is in the background. Reminders were asked for, so Do not disturb keeps * this too. */ -function reminderTitleAlert(text: string): void { +export function reminderTitleAlert(text: string): void { if (useSettingsStore.getState().visualAlerts) startTitleAlert(text); } @@ -61,7 +69,7 @@ function reminderTitleAlert(text: string): void { * itself does. onCalendarAlert fires either way. With no plugin asked, the * reminder is shown at once. */ -function remind(view: CalendarAlertView, show: () => void): void { +export function remind(view: CalendarAlertView, show: () => void): void { void calendarHooks.onCalendarAlert.emit(view); if (calendarHooks.onBeforeCalendarAlert.size === 0) { show(); @@ -73,9 +81,11 @@ function remind(view: CalendarAlertView, show: () => void): void { } export function useCalendarAlerts() { - const { isAuthenticated: signedIn, client } = useAuthStore(); + const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore(); + // Reminders of an account with a local store come from there: useStoreReminders. + const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]); // The main window rings; a pop-out would ring the same alert again. - const isAuthenticated = signedIn && !isPopoutWindow(); + const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore; const { events, calendars, supportsCalendar } = useCalendarStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); @@ -91,6 +101,15 @@ export function useCalendarAlerts() { const wakeTimerRef = useRef | null>(null); const checkAlertsRef = useRef<() => void>(() => {}); + // Beside the toast: the desktop app's native notification, a browser's system one. + const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => { + if (getDesktopBridge()?.os) { + notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId); + return; + } + void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`); + }, [t, locale, activeAccountId]); + const checkAlerts = useCallback(() => { if (wakeTimerRef.current) { clearTimeout(wakeTimerRef.current); @@ -127,27 +146,33 @@ export function useCalendarAlerts() { ? `${timeLabel} · ${alert.calendarName}` : timeLabel; - remind({ - id: alert.alertId, - eventId: alert.eventId, - eventTitle: alert.event.title || '', - triggerTime: new Date(alert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') })); - addToast({ - type: 'info', - title: alert.event.title || t('alert_title'), - message, - duration: reminderToastDuration(), - onClick: () => { - window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); - }, + const uid = alert.event.uid || alert.event.originalId || alert.eventId; + claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => { + remind({ + id: alert.alertId, + eventId: alert.eventId, + eventTitle: alert.event.title || '', + triggerTime: new Date(alert.fireTimeMs).toISOString(), + }, () => { + const sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = alert.event.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); + }, + }); + tellOutsideTheWindow(key, title, message, !sound); }); }); } @@ -166,27 +191,33 @@ export function useCalendarAlerts() { ? `${t('task_due')} · ${taskAlert.calendarName}` : t('task_due'); - remind({ - id: taskAlert.alertId, - taskId: taskAlert.taskId, - taskTitle: taskAlert.task.title || '', - triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') })); - addToast({ - type: 'info', - title: taskAlert.task.title || t('alert_title'), - message: taskMsg, - duration: reminderToastDuration(), - onClick: () => { - window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); - }, + const uid = taskAlert.task.uid || taskAlert.taskId; + claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => { + remind({ + id: taskAlert.alertId, + taskId: taskAlert.taskId, + taskTitle: taskAlert.task.title || '', + triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), + }, () => { + const sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = taskAlert.task.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message: taskMsg, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); + }, + }); + tellOutsideTheWindow(key, title, taskMsg, !sound); }); }); } @@ -196,8 +227,8 @@ export function useCalendarAlerts() { } }, [ calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice, - isAuthenticated, events, calendars, acknowledgedAlerts, - acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, + isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts, + acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow, ]); checkAlertsRef.current = checkAlerts; @@ -211,7 +242,10 @@ export function useCalendarAlerts() { const start = new Date(now - 10 * 60 * 1000).toISOString(); const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString(); const fetched = await client.queryCalendarEvents({ after: start, before: end }); - proactiveEventsRef.current = fetched; + // Without synthetic ids the server answers with a series' master, whose start can + // be years ago: only its occurrences have a fire time. Passes real occurrences through. + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS)); lastProactiveFetchRef.current = Date.now(); checkAlertsRef.current(); } catch { diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts new file mode 100644 index 000000000..adeb0468b --- /dev/null +++ b/hooks/use-store-reminders.ts @@ -0,0 +1,278 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts'; +import { getPathPrefix } from '@/lib/browser-navigation'; +import { onAcknowledged } from '@/lib/calendar-reminders/channel'; +import { claimKey, claimNow } from '@/lib/calendar-reminders/claims'; +import { createReminderRunner } from '@/lib/calendar-reminders/runner'; +import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { + buildMissedNotification, + buildReminderNotification, + dismissReminders, + joinMeeting, + missedBody, + SNOOZE_MINUTES, + snoozeReminder, + type ReminderLabels, +} from '@/lib/desktop/reminder-notifications'; +import { addWakeHandler } from '@/lib/desktop/system-events'; +import { playNotificationSound } from '@/lib/notification-sound'; +import { offlineActive } from '@/lib/offline/offline-mode'; +import { visibleAccounts, useOfflineStore } from '@/lib/offline/state'; +import { addStoreEventListener } from '@/lib/offline/store-events'; +import { calendarHooks } from '@/lib/plugin-hooks'; +import type { CalendarAlertView } from '@/lib/plugin-types'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +function viewOf(alert: ReminderAlert): CalendarAlertView { + const triggerTime = new Date(alert.fireTimeMs).toISOString(); + return alert.kind === 'task' + ? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime } + : { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime }; +} + +function acknowledgeView(alert: ReminderAlert) { + return alert.kind === 'task' + ? { alertId: alert.alertId, taskId: alert.baseEventId } + : { alertId: alert.alertId, eventId: alert.baseEventId }; +} + +/** + * Reminders from the local store: on time with no network, and in the desktop app with + * the window hidden. For accounts without a store useCalendarAlerts carries on as before. + */ +export function useStoreReminders(): void { + const signedIn = useAuthStore((s) => s.isAuthenticated); + const t = useTranslations('desktopReminders'); + const tAlert = useTranslations('calendar.notifications'); + const locale = useLocale(); + const addToast = useToastStore((s) => s.addToast); + const removeToast = useToastStore((s) => s.removeToast); + const enabledKey = useOfflineStore((s) => s.enabled.join('\n')); + + const labels: ReminderLabels = { + generic: t('generic_title'), + untitled: tAlert('alert_title'), + when: (alert) => { + const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000); + const time = alert.kind === 'task' + ? tAlert('task_due') + : minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes }); + return alert.calendarName ? `${time} · ${alert.calendarName}` : time; + }, + snooze: (minutes) => t('snooze', { count: minutes }), + snoozeShort: (minutes) => t('snooze_short', { count: minutes }), + open: t('open'), + join: t('join'), + missedTitle: (count) => t('missed_title', { count }), + missedMore: (count) => t('missed_more', { count }), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`; + const calendarUrlRef = useRef(calendarUrl); + calendarUrlRef.current = calendarUrl; + const runnerRef = useRef | null>(null); + + useEffect(() => { + if (!signedIn || isPopoutWindow()) return; + const toastsByKey = new Map(); + + const inAppReminder = (account: string, alert: ReminderAlert) => { + const current = labelsRef.current; + const desktop = !!getDesktopBridge()?.os; + // Set when a button says what happens next, so closing the toast doesn't undo it. + let handled = false; + const snoozeAction = (minutes: number) => ({ + label: current.snooze(minutes), + onClick: () => { + handled = true; + void snoozeReminder(account, alert.key, minutes).catch(() => {}); + }, + }); + const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction); + const id = addToast({ + type: 'info', + title: alert.title || current.untitled, + message: current.when(alert), + duration: reminderToastDuration(), + onClick: () => { + window.location.href = calendarUrlRef.current; + }, + onDismiss: () => { + toastsByKey.delete(alert.key); + void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert)); + if (!handled) dismissReminders(account, [alert.key]); + }, + ...(desktop ? { + tertiaryAction: five, + secondaryAction: ten, + action: fifteen, + extraActions: [ + ...(alert.joinUrl ? [{ label: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []), + { label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } }, + ], + } : {}), + }); + toastsByKey.set(alert.key, id); + }; + + const showReminder = async (account: string, alert: ReminderAlert) => { + const settings = useSettingsStore.getState(); + const sound = reminderSoundOn(settings.calendarNotificationSound); + const os = getDesktopBridge()?.os; + const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {}); + if (os) { + const locked = isAppLocked(); + const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound)); + // Paused, quiet hours, or nothing may be said now: the summary says it later. + if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') { + await hold(); + return; + } + if (result.reason === 'duplicate') return; + if (result.playSound) playNotificationSound(settings.notificationSoundChoice); + if (result.shown) return; + inAppReminder(account, alert); + return; + } + if (sound) playNotificationSound(settings.notificationSoundChoice); + reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled })); + inAppReminder(account, alert); + void showSystemReminder({ + key: alert.key, + title: alert.title || labelsRef.current.untitled, + body: labelsRef.current.when(alert), + silent: !sound, + }, calendarUrlRef.current); + }; + + const showMissed = async (account: string) => { + if (isAppLocked()) return; + const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone); + const batch = await getStore().area(account, 'reminders.takeMissed', { timeZone }); + if (!batch) return; + const current = labelsRef.current; + const os = getDesktopBridge()?.os; + if (os) { + const result = await os.notify(await buildMissedNotification(account, batch, current)); + if (result.shown) return; + // Told nobody, so it has to wait for a time when it can. + if (result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') { + await getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch }); + return; + } + } + const keys = batch.alerts.map((alert) => alert.key); + let opened = false; + const body = missedBody(batch.alerts, current); + const id = addToast({ + type: 'info', + title: current.missedTitle(batch.alerts.length), + message: body, + duration: 0, + action: { + label: t('missed_show'), + onClick: () => { + opened = true; + useMissedReminders.getState().show(account, batch.alerts); + }, + }, + onDismiss: () => { + for (const key of keys) toastsByKey.delete(key); + if (!opened) dismissReminders(account, keys); + }, + }); + for (const key of keys) toastsByKey.set(key, id); + if (!os) { + void showSystemReminder({ + key: `missed:${batch.batch}`, + title: current.missedTitle(batch.alerts.length), + body, + silent: true, + }, calendarUrlRef.current); + } + }; + + const runner = createReminderRunner({ + accounts: () => visibleAccounts().filter((account) => offlineActive(account)), + store: () => getStore(), + params: () => { + const settings = useSettingsStore.getState(); + return { + timeZone: reminderTimeZone(settings.timeZone), + tasks: settings.enableCalendarTasks, + muted: !settings.calendarNotificationsEnabled, + }; + }, + hidden: () => document.visibilityState === 'hidden', + offline: () => navigator.onLine === false, + onDue: (account, alerts) => { + for (const alert of alerts) { + // Whichever of the two paths got there first: one comes back from a snooze. + const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs); + if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) continue; + remind(viewOf(alert), () => { + void showReminder(account, alert).catch((error) => { + console.error('Failed to show a reminder:', error); + }); + }); + } + }, + onMissed: showMissed, + onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven), + }); + runnerRef.current = runner; + setReminderPoke(() => runner.poke()); + runner.start(); + + const stopEvents = addStoreEventListener((event) => { + if (event.type === 'synced') runner.synced(event.account); + else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke(); + }); + const stopWake = addWakeHandler(() => runner.wake()); + const onOnline = () => runner.wake(); + const onVisible = () => { + if (document.visibilityState === 'visible') runner.poke(); + }; + window.addEventListener('online', onOnline); + document.addEventListener('visibilitychange', onVisible); + const stopAcks = onAcknowledged((keys) => { + for (const key of keys) { + const id = toastsByKey.get(key); + if (id) removeToast(id); + toastsByKey.delete(key); + } + void closeSystemReminders(keys); + }); + + return () => { + runner.stop(); + runnerRef.current = null; + setReminderPoke(null); + stopEvents(); + stopWake(); + stopAcks(); + window.removeEventListener('online', onOnline); + document.removeEventListener('visibilitychange', onVisible); + }; + // Labels go through a ref: a language change mustn't restart the check. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signedIn]); + + // An account just turned on or off. + useEffect(() => { + runnerRef.current?.poke(); + }, [enabledKey]); +} diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts new file mode 100644 index 000000000..862738746 --- /dev/null +++ b/lib/calendar-reminders/__tests__/claims.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims'; + +const FIRE = Date.UTC(2026, 10, 10, 9); + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +/** A Web Lock: one holder at a time, across "tabs". */ +function fakeLocks() { + let tail: Promise = Promise.resolve(); + return { + request: vi.fn((_name: string, callback: () => T | Promise) => { + const run = tail.then(callback); + tail = run.catch(() => {}); + return run; + }), + }; +} + +beforeEach(() => { + localStorage.clear(); + setVisibility('visible'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'locks'); +}); + +describe('claimNow', () => { + it('says yes once', () => { + const key = claimKey('acc', 'uid-1', 'a', FIRE); + expect(claimNow(key, FIRE)).toBe(true); + expect(claimNow(key, FIRE)).toBe(false); + }); + + it('tells occurrences and accounts apart', () => { + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true); + expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + }); + + it('forgets what is a day old', () => { + vi.useFakeTimers({ now: FIRE }); + claimNow('old', FIRE - 25 * 3_600_000); + claimNow('new', FIRE); + expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']); + }); + + it('holds only the key, nothing of the event', () => { + claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE); + expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE })); + }); +}); + +describe('claimAlert', () => { + it('lets one of two tabs through when their timers run out together', async () => { + Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() }); + const won = vi.fn(); + claimAlert('k', FIRE, () => won('tab 1')); + claimAlert('k', FIRE, () => won('tab 2')); + await vi.waitFor(() => expect(won).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(won.mock.calls).toEqual([['tab 1']]); + }); + + it('runs at once where there is no Web Lock', () => { + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).toHaveBeenCalledTimes(1); + }); + + it('lets a tab in view go first', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const hidden = vi.fn(); + claimAlert('k', FIRE, hidden); + setVisibility('visible'); + const visible = vi.fn(); + claimAlert('k', FIRE, visible); + expect(visible).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(hidden).not.toHaveBeenCalled(); + }); + + it('still fires in a hidden tab when no other took it', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).not.toHaveBeenCalled(); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(won).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts new file mode 100644 index 000000000..fd45f0ad4 --- /dev/null +++ b/lib/calendar-reminders/__tests__/runner.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from '../claims'; +import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner'; + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +interface Tab { + runner: ReminderRunner; + due: ReminderAlert[]; + firedAt: number[]; + missed: MissedBatch[]; + source: boolean[]; + env: ReminderRunnerEnv; + hidden: boolean; + offline: boolean; + muted: boolean; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const tabs: Tab[] = []; + +function tab(options: Partial> = {}): Tab { + const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab; + made.env = { + accounts: () => [ACCOUNT], + store: () => store, + params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }), + hidden: () => made.hidden, + offline: () => made.offline, + onDue: (_account, alerts) => { + made.due.push(...alerts); + made.firedAt.push(...alerts.map(() => Date.now())); + }, + onMissed: async (account) => { + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + if (batch) made.missed.push(batch); + }, + onSource: (_account, driven) => { + made.source.push(driven); + }, + }; + made.runner = createReminderRunner(made.env); + tabs.push(made); + return made; +} + +async function syncedStore(...events: Record[]): Promise { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); +}); + +afterEach(() => { + for (const made of tabs.splice(0)) made.runner.stop(); + vi.useRealTimers(); +}); + +describe('firing from the store', () => { + it('fires at the moment it is due, with the network gone', async () => { + await syncedStore(event()); + await store.setOnline(false); + server.request = (() => { + throw new Error('A reminder needs no server'); + }) as unknown as FakeCalendarJmap['request']; + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000); + expect(one.due).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due.map((alert) => alert.title)).toEqual(['Standup']); + expect(one.firedAt).toEqual([FIRE]); + }); + + it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => { + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(true); + store.failArea = () => true; + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + }); + + it('fires in one tab only, when both ask at the same moment', async () => { + await syncedStore(event()); + const [one, two] = [tab(), tab()]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due.length + two.due.length).toBe(1); + }); + + it('leaves it to the tab in view when another is hidden', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(0); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN); + expect(visible.due).toHaveLength(1); + expect(hidden.due).toHaveLength(0); + }); + + it('still fires when only a hidden tab is left', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS); + expect(hidden.due).toHaveLength(1); + }); + + it('notices an event that just arrived, and a snooze just set', async () => { + await syncedStore(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(2 * MIN); + expect(one.firedAt).toEqual([FIRE]); + + await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 }); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]); + }); + + it('brings a snoozed reminder back after a restart', async () => { + await syncedStore(event()); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now()); + await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 }); + before.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + + const after = tab(); + after.runner.start(); + await vi.advanceTimersByTimeAsync(10 * MIN); + expect(before.due).toHaveLength(1); + expect(after.firedAt).toEqual([FIRE + 10 * MIN]); + expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN); + }); + + it('stays quiet while reminders are off', async () => { + await syncedStore(event()); + const one = tab(); + one.muted = true; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due).toEqual([]); + }); +}); + +describe('after being away', () => { + async function awayFromBothAlerts(): Promise { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(0); + before.runner.stop(); + vi.setSystemTime(FIRE + 60 * MIN); + return before; + } + + it('waits a moment for the first sync, then summarises once, not as a burst', async () => { + await awayFromBothAlerts(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000); + expect(one.missed).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]); + + await vi.advanceTimersByTimeAsync(30 * MIN); + one.runner.wake(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(one.missed).toHaveLength(1); + }); + + it('does not wait once the sync is done, and has nothing to wait for offline', async () => { + await awayFromBothAlerts(); + const synced = tab(); + synced.runner.start(); + await vi.advanceTimersByTimeAsync(100); + synced.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(0); + expect(synced.missed).toHaveLength(1); + }); + + it('gives the summary at once with no network', async () => { + await awayFromBothAlerts(); + const offline = tab({ offline: true }); + offline.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(offline.missed).toHaveLength(1); + }); + + it('gives the summary to one window of two, and neither again after a restart', async () => { + await awayFromBothAlerts(); + const [one, two] = [tab({ offline: true }), tab({ offline: true })]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed.length + two.missed.length).toBe(1); + one.runner.stop(); + two.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(next.missed).toEqual([]); + }); + + it('sees a jump of the clock without being told, as after a sleep', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(MIN); + // Asleep: timers stood still and the clock did not. + vi.setSystemTime(FIRE + 60 * MIN); + await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]); + }); + + it('tries a summary again that could not be shown', async () => { + await awayFromBothAlerts(); + const one = tab({ offline: true }); + let refuse = true; + const plain = one.env.onMissed; + one.env.onMissed = async (account) => { + if (!refuse) return plain(account); + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + await store.area(account, 'reminders.releaseMissed', { batch: batch.batch }); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed).toEqual([]); + refuse = false; + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.missed).toHaveLength(1); + }); +}); diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts new file mode 100644 index 000000000..9e32f74ac --- /dev/null +++ b/lib/calendar-reminders/channel.ts @@ -0,0 +1,32 @@ +/** + * Telling the other windows that a reminder has been dealt with, so its toast and system + * notification go there too. What's done is recorded in the store; this only clears what's + * on screen. Only keys cross it, and they say nothing about the event but its uid. + */ + +const CHANNEL_NAME = 'bulwark-calendar-reminders'; + +interface AcknowledgedMessage { + kind: 'acknowledged'; + keys: string[]; +} + +function open(): BroadcastChannel | null { + return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME); +} + +export function announceAcknowledged(keys: readonly string[]): void { + const channel = open(); + if (!channel) return; + channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage); + channel.close(); +} + +export function onAcknowledged(listener: (keys: string[]) => void): () => void { + const channel = open(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent) => { + if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys); + }; + return () => channel.close(); +} diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts new file mode 100644 index 000000000..069660aea --- /dev/null +++ b/lib/calendar-reminders/claims.ts @@ -0,0 +1,70 @@ +/** + * One tab fires a reminder when there's no local store to decide it. The list sits in + * localStorage and holds no more than the event's uid, and the Web Lock makes reading + * and writing it one step across tabs: their timers run out at the same moment, so a + * plain read and write would let both through. + * + * Keys are the series' uid, never the id: synthetic ids reshuffle when an override + * changes, and a reminder fired again inside its ten minutes. + */ + +const STORAGE_KEY = 'calendar-alert-claims'; +const LOCK = 'bulwark-calendar-alert-claim'; +const KEEP_MS = 24 * 60 * 60 * 1000; +/** A tab out of view waits, so the one in view is who shows the reminder. */ +export const HIDDEN_GRACE_MS = 2000; + +export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string { + return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`; +} + +function readClaims(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown; + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +/** True for the first to ask. Also what the store path tells the other path with. */ +export function claimNow(key: string, fireTimeMs: number): boolean { + const claims = readClaims(); + if (key in claims) return false; + const now = Date.now(); + const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS)); + kept[key] = fireTimeMs; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(kept)); + } catch { + // Without storage each tab is on its own, as it always was. + } + return true; +} + +/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */ +export function claimAlert(key: string, fireTimeMs: number, won: () => void): void { + const attempt = () => { + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks; + if (!locks) { + if (claimNow(key, fireTimeMs)) won(); + return; + } + void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => { + if (claimed) won(); + }); + }; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS); + else attempt(); +} + +/** A sign-out: the next person starts with a clean slate. */ +export function clearClaims(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing was kept, then. + } +} + +export const CLAIMS_STORAGE_KEY = STORAGE_KEY; diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts new file mode 100644 index 000000000..26bb9a569 --- /dev/null +++ b/lib/calendar-reminders/runner.ts @@ -0,0 +1,156 @@ +/** + * Asks the local store what's due, at the moment it's due. Runs in the page: in the + * desktop app that's the main window, which keeps its timers while hidden in the tray, + * and the store's worker answers with no network. Every tab or window may run one: the + * store hands each alert to whichever asks first. + * + * Between checks it sleeps until the store's next fire time, and looks in every few + * minutes in case something changed with nobody saying so. + */ + +import type { StoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from './claims'; + +const REFRESH_MS = 5 * 60_000; +/** Timers stand still while a machine sleeps. The clock doesn't. */ +const CLOCK_WATCH_MS = 60_000; +const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS; +/** + * After a start or a wake, how long a summary waits for the first sync, so it leaves out + * what was deleted elsewhere in the meantime. Reminders that are due don't wait. + */ +export const SUMMARY_WAIT_MS = 20_000; + +export interface ReminderRunnerEnv { + /** The local accounts with a store right now. */ + accounts(): string[]; + store(): Pick; + params(): TickParams; + /** Out of view: another tab in view should get to go first. */ + hidden(): boolean; + offline(): boolean; + /** Runs in the one tab that got these. */ + onDue(account: string, alerts: ReminderAlert[]): void; + /** There's a summary to give. Resolves when that's been tried. */ + onMissed(account: string): Promise; + /** Whether this account's reminders come from the store now, or the app's old path keeps them. */ + onSource(account: string, driven: boolean): void; +} + +export interface ReminderRunner { + start(): void; + stop(): void; + /** Check now: the calendar changed, a snooze was set. */ + poke(): void; + /** Back from sleep, or back online. */ + wake(): void; + /** The store finished a sync for this account. */ + synced(account: string): void; +} + +export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { + let timer: ReturnType | null = null; + let clockWatch: ReturnType | null = null; + let lastWatch = 0; + let running = false; + let again = false; + let stopped = true; + let summaryAt = 0; + const presenting = new Set(); + const known = new Set(); + + const schedule = (at: number) => { + if (timer) clearTimeout(timer); + const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0); + timer = setTimeout(() => void run(), wait); + }; + + const check = async (account: string): Promise => { + let result: TickResult; + try { + result = await env.store().area(account, 'reminders.tick', env.params()); + } catch { + // The store is out of reach: the old path takes these over until it's back. + env.onSource(account, false); + return null; + } + env.onSource(account, result.ready); + if (result.due.length > 0) env.onDue(account, result.due); + if (result.missed === 0) return result.nextAt; + const wait = env.offline() ? 0 : summaryAt - Date.now(); + if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt); + if (!presenting.has(account)) { + presenting.add(account); + void env.onMissed(account) + .catch(() => {}) + .finally(() => presenting.delete(account)); + } + return result.nextAt; + }; + + const run = async () => { + if (stopped) return; + if (running) { + again = true; + return; + } + running = true; + let next = Date.now() + REFRESH_MS; + const accounts = env.accounts(); + for (const gone of [...known]) { + if (!accounts.includes(gone)) { + known.delete(gone); + env.onSource(gone, false); + } + } + for (const account of accounts) { + known.add(account); + const at = await check(account); + if (at !== null) next = Math.min(next, at); + } + running = false; + if (stopped) return; + if (again) { + again = false; + void run(); + return; + } + schedule(next); + }; + + return { + start() { + stopped = false; + summaryAt = Date.now() + SUMMARY_WAIT_MS; + lastWatch = Date.now(); + clockWatch = setInterval(() => { + const now = Date.now(); + const jumped = now - lastWatch > CLOCK_JUMP_MS; + lastWatch = now; + if (jumped) this.wake(); + }, CLOCK_WATCH_MS); + void run(); + }, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + if (clockWatch) clearInterval(clockWatch); + timer = null; + clockWatch = null; + for (const account of known) env.onSource(account, false); + known.clear(); + }, + poke() { + void run(); + }, + wake() { + summaryAt = Date.now() + SUMMARY_WAIT_MS; + void run(); + }, + synced() { + summaryAt = 0; + void run(); + }, + }; +} diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts new file mode 100644 index 000000000..109df52ee --- /dev/null +++ b/lib/calendar-reminders/state.ts @@ -0,0 +1,55 @@ +/** + * What the reminder code shares across the page, kept free of the rest of the app so + * useCalendarAlerts can read it without pulling in the local store. + */ + +import { create } from 'zustand'; +import type { ReminderAlert } from '@/lib/store/reminders/ledger'; + +interface ReminderSourceState { + /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */ + driven: Record; + setDriven: (account: string, driven: boolean) => void; +} + +export const useReminderSource = create()((set) => ({ + driven: {}, + setDriven: (account, driven) => set((state) => { + if (!!state.driven[account] === driven) return state; + const next = { ...state.driven }; + if (driven) next[account] = true; + else delete next[account]; + return { driven: next }; + }), +})); + +interface MissedRemindersState { + account: string | null; + alerts: ReminderAlert[]; + show: (account: string, alerts: ReminderAlert[]) => void; + close: () => void; +} + +/** The summary dialog's contents. Empty: it's closed. */ +export const useMissedReminders = create()((set) => ({ + account: null, + alerts: [], + show: (account, alerts) => set({ account, alerts }), + close: () => set({ account: null, alerts: [] }), +})); + +let poke: (() => void) | null = null; + +/** The running check, so something done from a toast takes effect at once. */ +export function setReminderPoke(next: (() => void) | null): void { + poke = next; +} + +export function pokeReminders(): void { + poke?.(); +} + +/** The zone that reads a floating event: the setting's, or the system's on `auto`. */ +export function reminderTimeZone(setting: string | undefined): string { + return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts new file mode 100644 index 000000000..538669c1b --- /dev/null +++ b/lib/calendar-reminders/system-notification.ts @@ -0,0 +1,70 @@ +/** + * A browser's system notification for a reminder, where the user has already granted the + * permission. This never asks for it. Through the service worker where there is one, since + * Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever + * tab asks. + */ + +export interface SystemReminder { + key: string; + title: string; + body: string; + /** Do not disturb: on screen, but quiet. */ + silent: boolean; +} + +function tagOf(key: string): string { + return `calendar-reminder:${key}`; +} + +function permitted(): boolean { + return typeof Notification !== 'undefined' && Notification.permission === 'granted'; +} + +async function registration(): Promise { + try { + return await navigator.serviceWorker?.getRegistration(); + } catch { + return undefined; + } +} + +/** In front of the user the in-app toast says it already. */ +export function wantsSystemNotification(): boolean { + if (!permitted() || typeof document === 'undefined') return false; + return document.visibilityState === 'hidden' || !document.hasFocus(); +} + +export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise { + if (!wantsSystemNotification()) return; + const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent }; + try { + const worker = await registration(); + if (worker) { + await worker.showNotification(reminder.title, { ...options, data: { kind: 'calendar-reminder' } }); + return; + } + const shown = new Notification(reminder.title, options); + shown.onclick = () => { + window.focus(); + window.location.href = openUrl; + shown.close(); + }; + } catch (error) { + console.warn('A system notification could not be shown:', error); + } +} + +/** Dealt with elsewhere: it shouldn't stay in the notification list. */ +export async function closeSystemReminders(keys: readonly string[]): Promise { + if (!permitted() || keys.length === 0) return; + const worker = await registration(); + if (!worker) return; + try { + for (const key of keys) { + for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close(); + } + } catch { + // Nothing that matters is left behind. + } +} diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts new file mode 100644 index 000000000..1a19e29d2 --- /dev/null +++ b/lib/desktop/__tests__/reminder-notifications.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMissedReminders } from '@/lib/calendar-reminders/state'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger'; +import { setAppLockCheck } from '../app-lock-state'; +import { + buildMissedNotification, + buildReminderNotification, + handleReminderCommand, + missedBody, + notifyWithoutStore, + type ReminderLabels, +} from '../reminder-notifications'; +import type { BulwarkDesktopBridge } from '../types'; + +const ACCOUNT = 'alice@one'; +const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }]; +let activeAccountId: string | null = ACCOUNT; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }) } })); + +const policy = vi.hoisted(() => ({ pushWithoutContent: false })); +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) })); + +const MIN = 60_000; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; + +const labels: ReminderLabels = { + generic: 'GENERIC', + when: () => 'In 15 min · Personal', + untitled: 'UNTITLED', + snooze: (minutes) => `Snooze ${minutes} min`, + snoozeShort: (minutes) => `${minutes} min`, + open: 'Open', + join: 'Join', + missedTitle: (count) => `${count} missed`, + missedMore: (count) => `and ${count} more`, +}; + +function alert(partial: Partial = {}): ReminderAlert { + return { + key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE, + title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', + ...partial, + }; +} + +const focus = vi.fn(); +const openExternal = vi.fn(); +const notify = vi.fn(async () => ({ shown: true, playSound: false })); + +function setPlatform(platform: string) { + window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const env = { leaveFor: vi.fn(), push: vi.fn() }; + +beforeEach(() => { + vi.clearAllMocks(); + policy.pushWithoutContent = false; + activeAccountId = ACCOUNT; + setAppLockCheck(null); + setPlatform('win32'); + useMissedReminders.getState().close(); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.useRealTimers(); +}); + +async function contextFor(reminder: ReminderAlert): Promise { + return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context; +} + +describe('what a reminder says', () => { + it('offers Join only when the event has a link', async () => { + const without = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']); + const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false); + expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + }); + + it('uses short snooze labels where five buttons share a row', async () => { + const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min'); + setPlatform('darwin'); + const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min'); + }); + + it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => { + setAppLockCheck(() => true); + const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true); + expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' }); + expect(locked.actions).toBeUndefined(); + expect(JSON.stringify(locked)).not.toContain('Secret'); + expect(JSON.stringify(locked)).not.toContain('uid-1'); + }); + + it('keeps the title off when the organisation says so, the buttons stay', async () => { + policy.pushWithoutContent = true; + const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(hidden).toMatchObject({ title: 'GENERIC', body: '' }); + expect(hidden.actions).toHaveLength(4); + }); + + it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => { + const first = await buildReminderNotification(ACCOUNT, alert(), labels, false); + const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false); + expect(back.id).not.toBe(first.id); + }); + + it('falls back on a neutral title for an event without one', async () => { + expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED'); + }); + + it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => { + const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title })); + expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more'); + const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels); + expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' }); + setAppLockCheck(() => true); + expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe(''); + }); + + it('without a store says what it can, and nothing when locked', () => { + notifyWithoutStore('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' })); + setAppLockCheck(() => true); + notifyWithoutStore('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' })); + }); +}); + +describe('what its buttons do', () => { + /** A reminder really on record in the store. */ + async function fired(partial: Record = {}): Promise { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0]; + } + + it('snoozes from the store with the window left hidden', async () => { + const reminder = await fired(); + expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true); + expect(focus).not.toHaveBeenCalled(); + expect(env.leaveFor).not.toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 9 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + vi.setSystemTime(FIRE + 10 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1); + }); + + it('refuses a snooze length no button offers', async () => { + const reminder = await fired(); + await handleReminderCommand('snooze-999', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 16 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('joins through the OS from what the store holds, with the window left hidden', async () => { + const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }); + await handleReminderCommand('join', await contextFor(reminder), env); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + expect(focus).not.toHaveBeenCalled(); + }); + + it('opens the event in the account on screen', async () => { + const reminder = await fired(); + await handleReminderCommand('open', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1'); + }); + + it('opens only the calendar for an account that is not on screen', async () => { + const reminder = await fired(); + const context = await contextFor(reminder); + activeAccountId = 'someone-else'; + await handleReminderCommand('open', context, env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).not.toHaveBeenCalled(); + }); + + it('touches nothing when the slot has gone to another login since', async () => { + const reminder = await fired(); + const context = JSON.parse(await contextFor(reminder)) as { login: string }; + context.login = 'not-this-login'; + await handleReminderCommand('snooze-5', JSON.stringify(context), env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 5 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('opens the summary from a click on its toast, even after a restart', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE + 60 * MIN); + await store.area(ACCOUNT, 'reminders.tick', params); + const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params); + const toast = await buildMissedNotification(ACCOUNT, batch, labels); + + await handleReminderCommand('open', toast.context, env); + expect(focus).toHaveBeenCalled(); + expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']); + expect(useMissedReminders.getState().account).toBe(ACCOUNT); + }); + + it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => { + expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false); + expect(await handleReminderCommand('open', '', env)).toBe(false); + expect(await handleReminderCommand('open', '{broken', env)).toBe(false); + }); +}); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 8c7332225..3a6fa8703 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -8,6 +8,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsCommand } from '@/lib/desktop/types'; import { openMailLink } from '@/lib/desktop/mail-links'; +import { handleReminderCommand } from '@/lib/desktop/reminder-notifications'; import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; import { parseMailto } from '@/lib/protocol-handlers/mailto'; @@ -89,6 +90,7 @@ function runAction(request: NotificationActionRequest): Promise { + if (await handleReminderCommand(action, rawContext, env)) return; const context = parseContext(rawContext); if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts new file mode 100644 index 000000000..3979c1d96 --- /dev/null +++ b/lib/desktop/reminder-notifications.ts @@ -0,0 +1,218 @@ +/** + * Calendar reminders as native notifications, and what their buttons do. + * + * A toast may be clicked a day later and after a restart, so what it hands back says + * which login and which reminder and no more: the rest comes from the store. Main keeps + * pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at + * all while the app is locked, or when the organisation says so for that login + * (pushWithoutContent, the rule new mail follows). + */ + +import { announceAcknowledged } from '@/lib/calendar-reminders/channel'; +import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import type { DesktopOsNotification } from '@/lib/desktop/types'; +import { devicesForAccount } from '@/lib/device-policy'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export const SNOOZE_MINUTES = [5, 10, 15] as const; +const SNOOZE_PREFIX = 'snooze-'; +const JOIN = 'join'; +const OPEN = 'open'; +/** How many titles a summary names. */ +const SUMMARY_TITLES = 3; + +export interface ReminderLabels { + /** Says nothing about the event. */ + generic: string; + /** "In 10 min · Personal". */ + when: (alert: ReminderAlert) => string; + untitled: string; + snooze: (minutes: number) => string; + /** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */ + snoozeShort: (minutes: number) => string; + open: string; + join: string; + missedTitle: (count: number) => string; + missedMore: (count: number) => string; +} + +export interface ReminderToastContext { + /** The reminder. */ + r?: string; + /** The summary. */ + rb?: number; + /** No store to ask: the click only goes to the calendar. */ + cal?: boolean; + slot?: number; + login?: string; +} + +async function loginOf(account: string): Promise> { + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (!entry) return {}; + return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) }; +} + +function hideContent(account: string): boolean { + if (isAppLocked()) return true; + try { + return devicesForAccount(account).pushWithoutContent; + } catch { + return true; + } +} + +function reminderId(alert: ReminderAlert): string { + // Main drops an id it saw in the last few minutes, and a snooze can be shorter. + return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`; +} + +export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise { + const base = { group: 'reminders', kind: 'reminder' as const, sound }; + // Not even the uid. Two at the same moment are one "you have a reminder". + if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' }; + const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) }; + const short = getDesktopBridge()?.platform === 'win32'; + const actions = [ + ...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []), + { id: OPEN, label: labels.open }, + ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })), + ]; + const hidden = hideContent(account); + return { + ...base, + id: reminderId(alert), + title: hidden ? labels.generic : alert.title || labels.untitled, + body: hidden ? '' : labels.when(alert), + context: JSON.stringify(context), + actions, + }; +} + +/** The first few titles of a summary, for a place too small for all of them. */ +export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string { + const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', '); + const more = alerts.length - SUMMARY_TITLES; + return more > 0 ? `${named}, ${labels.missedMore(more)}` : named; +} + +export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise { + const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) }; + return { + id: `reminder-missed:${batch.batch}`, + group: 'reminders', + kind: 'reminder', + title: labels.missedTitle(batch.alerts.length), + body: hideContent(account) ? '' : missedBody(batch.alerts, labels), + context: JSON.stringify(context), + }; +} + +/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */ +export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void { + const hidden = isAppLocked() || !account || hideContent(account); + void getDesktopBridge()?.os?.notify({ + id, + group: 'reminders', + kind: 'reminder', + title: hidden ? labels.generic : title, + body: hidden ? '' : body, + context: JSON.stringify({ cal: true } satisfies ReminderToastContext), + }); +} + +async function accountOf(context: ReminderToastContext): Promise { + if (context.slot === undefined || !context.login) return null; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null; + return entry.id; +} + +/** Goes to the event when it's in the account on screen, otherwise to the calendar. */ +export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null { + if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null; + const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : ''; + return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`; +} + +export function joinMeeting(url: string): void { + const shell = getDesktopBridge()?.shell; + if (shell) shell.openExternal(url); + else window.open(url, '_blank', 'noopener,noreferrer'); +} + +export function dismissReminders(account: string, keys: string[]): void { + announceAcknowledged(keys); + void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {}); +} + +export async function snoozeReminder(account: string, key: string, minutes: number): Promise { + announceAcknowledged([key]); + await getStore().area(account, 'reminders.snooze', { key, minutes }); + pokeReminders(); +} + +export interface ReminderCommandEnv { + leaveFor: (path: '/calendar') => void | Promise; + push: (path: string) => void; +} + +function parse(raw: string): ReminderToastContext | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ReminderToastContext; + return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null; + } catch { + return null; + } +} + +function snoozeMinutes(action: string): number | null { + if (!action.startsWith(SNOOZE_PREFIX)) return null; + const minutes = Number(action.slice(SNOOZE_PREFIX.length)); + return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null; +} + +/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */ +export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise { + const context = parse(rawContext); + if (!context) return false; + const account = await accountOf(context); + const show = () => getDesktopBridge()?.windows?.focus?.(); + + if (context.rb !== undefined) { + show(); + const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : []; + if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts); + else await env.leaveFor('/calendar'); + return true; + } + + const key = context.r; + const minutes = snoozeMinutes(action); + if (minutes !== null && account && key) { + await snoozeReminder(account, key, minutes); + return true; + } + + const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null; + if (action === JOIN && alert?.joinUrl) { + joinMeeting(alert.joinUrl); + dismissReminders(account!, [alert.key]); + return true; + } + + // Open, a click on the toast, or a button that can't be carried out any more. + show(); + await env.leaveFor('/calendar'); + const path = account ? calendarPathFor(account, alert) : null; + if (path) env.push(path); + if (account && alert) dismissReminders(account, [alert.key]); + return true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 696ab3183..249a45cba 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -235,6 +235,8 @@ export interface DesktopOsNotification { sound?: boolean; /** New mail: flash the taskbar or bounce the dock if that's on. */ attention?: boolean; + /** What a locked app says instead: new mail unless this says otherwise. */ + kind?: 'mail' | 'reminder'; } export interface DesktopOsNotifyResult { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index d71993589..77122dd0e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -30,6 +30,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; +import { emitStoreEvent } from '@/lib/offline/store-events'; import { STORAGE_ERROR_AREA } from '@/lib/store/area'; import type { DraftError } from '@/lib/store/drafts/pending'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; @@ -197,6 +198,7 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + emitStoreEvent(event); if (event.type === 'created' && event.collection === 'Email') { const client = onlineClientFor(event.account); if (client) reportStoreCreatedMail(client, event.jmap, event.ids); diff --git a/lib/offline/store-events.ts b/lib/offline/store-events.ts new file mode 100644 index 000000000..b857ec351 --- /dev/null +++ b/lib/offline/store-events.ts @@ -0,0 +1,18 @@ +/** The store's events, for the parts of the page that aren't offline mode itself. */ + +import type { StoreEvent } from '@/lib/store/area'; + +type Listener = (event: StoreEvent) => void; + +const listeners = new Set(); + +export function addStoreEventListener(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function emitStoreEvent(event: StoreEvent): void { + for (const listener of [...listeners]) listener(event); +} diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..2400d48f3 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -21,6 +21,7 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'contact-storage', 'calendar-storage', 'calendar-notification-storage', + 'calendar-alert-claims', // Open tabs name messages and their subjects. 'pro-tabs', 'search-history-storage', diff --git a/locales/ar/common.json b/locales/ar/common.json index 169c04ec5..05c769ae3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "نسخ كملف", "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", "copy_failed": "تعذّر نسخ الملف." + }, + "desktopReminders": { + "generic_title": "تذكير", + "snooze": "تأجيل {count} د", + "snooze_short": "{count} د", + "open": "فتح", + "join": "انضمام", + "missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}", + "missed_more": "و{count} أخرى", + "missed_show": "عرض", + "missed_dialog_title": "التذكيرات الفائتة", + "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.", + "open_calendar": "فتح التقويم", + "done": "تم" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 5dd448d8e..b3a56b969 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia com a fitxer", "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", "copy_failed": "No s'ha pogut copiar el fitxer." + }, + "desktopReminders": { + "generic_title": "Recordatori", + "snooze": "Posposa {count} min", + "snooze_short": "{count} min", + "open": "Obre", + "join": "Uneix-te", + "missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}", + "missed_more": "i {count} més", + "missed_show": "Mostra", + "missed_dialog_title": "Recordatoris perduts", + "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.", + "open_calendar": "Obre el calendari", + "done": "Fet" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 567020087..626c9d150 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovat jako soubor", "copied": "Soubor zkopírován. Můžete ho vložit do složky.", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "desktopReminders": { + "generic_title": "Připomenutí", + "snooze": "Odložit o {count} min", + "snooze_short": "{count} min", + "open": "Otevřít", + "join": "Připojit se", + "missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}", + "missed_more": "a další ({count})", + "missed_show": "Zobrazit", + "missed_dialog_title": "Zmeškaná připomenutí", + "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.", + "open_calendar": "Otevřít kalendář", + "done": "Hotovo" } } diff --git a/locales/da/common.json b/locales/da/common.json index 352cd2805..5c9d9a580 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiér som fil", "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påmindelse", + "snooze": "Udsæt {count} min.", + "snooze_short": "{count} min.", + "open": "Åbn", + "join": "Deltag", + "missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}", + "missed_more": "og {count} mere", + "missed_show": "Vis", + "missed_dialog_title": "Oversete påmindelser", + "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.", + "open_calendar": "Åbn kalender", + "done": "Færdig" } } diff --git a/locales/de/common.json b/locales/de/common.json index 65fa8c8dd..ea62fa855 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Als Datei kopieren", "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "desktopReminders": { + "generic_title": "Erinnerung", + "snooze": "{count} Min. schlummern", + "snooze_short": "{count} Min.", + "open": "Öffnen", + "join": "Teilnehmen", + "missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}", + "missed_more": "und {count} weitere", + "missed_show": "Anzeigen", + "missed_dialog_title": "Verpasste Erinnerungen", + "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.", + "open_calendar": "Kalender öffnen", + "done": "Fertig" } } diff --git a/locales/en/common.json b/locales/en/common.json index 4512e406e..7857501d3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copy as file", "copied": "File copied. You can paste it into a folder.", "copy_failed": "The file could not be copied." + }, + "desktopReminders": { + "generic_title": "Reminder", + "snooze": "Snooze {count} min", + "snooze_short": "{count} min", + "open": "Open", + "join": "Join", + "missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}", + "missed_more": "and {count} more", + "missed_show": "Show", + "missed_dialog_title": "Reminders you missed", + "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.", + "open_calendar": "Open calendar", + "done": "Done" } } diff --git a/locales/es/common.json b/locales/es/common.json index 6ed72a0f4..7542536a1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como archivo", "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", "copy_failed": "No se pudo copiar el archivo." + }, + "desktopReminders": { + "generic_title": "Recordatorio", + "snooze": "Posponer {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Unirse", + "missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}", + "missed_more": "y {count} más", + "missed_show": "Mostrar", + "missed_dialog_title": "Recordatorios perdidos", + "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.", + "open_calendar": "Abrir calendario", + "done": "Listo" } } diff --git a/locales/et/common.json b/locales/et/common.json index d0d6356b6..40abb6d20 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopeeri failina", "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", "copy_failed": "Faili ei saanud kopeerida." + }, + "desktopReminders": { + "generic_title": "Meeldetuletus", + "snooze": "Lükka {count} min edasi", + "snooze_short": "{count} min", + "open": "Ava", + "join": "Liitu", + "missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}", + "missed_more": "ja veel {count}", + "missed_show": "Näita", + "missed_dialog_title": "Kasutamata meeldetuletused", + "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.", + "open_calendar": "Ava kalender", + "done": "Valmis" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index de207bb10..8d5df86ea 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "رونوشت به‌صورت پرونده", "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", "copy_failed": "رونوشت پرونده ممکن نشد." + }, + "desktopReminders": { + "generic_title": "یادآوری", + "snooze": "تعویق {count} دقیقه", + "snooze_short": "{count} دقیقه", + "open": "باز کردن", + "join": "پیوستن", + "missed_title": "{count, plural, one {# یادآوری از دست‌رفته} other {# یادآوری از دست‌رفته}}", + "missed_more": "و {count} مورد دیگر", + "missed_show": "نمایش", + "missed_dialog_title": "یادآوری‌های از دست‌رفته", + "missed_dialog_description": "این یادآوری‌ها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلان‌ها متوقف بودند.", + "open_calendar": "باز کردن تقویم", + "done": "تمام" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index b51367064..93edc4bd6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copier en tant que fichier", "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", "copy_failed": "Le fichier n'a pas pu être copié." + }, + "desktopReminders": { + "generic_title": "Rappel", + "snooze": "Reporter de {count} min", + "snooze_short": "{count} min", + "open": "Ouvrir", + "join": "Rejoindre", + "missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}", + "missed_more": "et {count} de plus", + "missed_show": "Afficher", + "missed_dialog_title": "Rappels manqués", + "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.", + "open_calendar": "Ouvrir le calendrier", + "done": "Terminé" } } diff --git a/locales/he/common.json b/locales/he/common.json index 613e36392..6d227dd04 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6592,5 +6592,19 @@ "copy_as_file": "העתקה כקובץ", "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", "copy_failed": "לא ניתן להעתיק את הקובץ." + }, + "desktopReminders": { + "generic_title": "תזכורת", + "snooze": "דחייה ב-{count} דק'", + "snooze_short": "{count} דק'", + "open": "פתיחה", + "join": "הצטרפות", + "missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}", + "missed_more": "ועוד {count}", + "missed_show": "הצגה", + "missed_dialog_title": "תזכורות שהוחמצו", + "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.", + "open_calendar": "פתיחת היומן", + "done": "סיום" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b72bcfc0d..1794a8340 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Másolás fájlként", "copied": "Fájl másolva. Beillesztheti egy mappába.", "copy_failed": "A fájlt nem sikerült másolni." + }, + "desktopReminders": { + "generic_title": "Emlékeztető", + "snooze": "Halasztás {count} perccel", + "snooze_short": "{count} perc", + "open": "Megnyitás", + "join": "Csatlakozás", + "missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}", + "missed_more": "és még {count}", + "missed_show": "Megjelenítés", + "missed_dialog_title": "Elmulasztott emlékeztetők", + "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.", + "open_calendar": "Naptár megnyitása", + "done": "Kész" } } diff --git a/locales/it/common.json b/locales/it/common.json index 421714d43..aaa5bc1ed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia come file", "copied": "File copiato. Puoi incollarlo in una cartella.", "copy_failed": "Impossibile copiare il file." + }, + "desktopReminders": { + "generic_title": "Promemoria", + "snooze": "Rimanda di {count} min", + "snooze_short": "{count} min", + "open": "Apri", + "join": "Partecipa", + "missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}", + "missed_more": "e altri {count}", + "missed_show": "Mostra", + "missed_dialog_title": "Promemoria persi", + "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.", + "open_calendar": "Apri calendario", + "done": "Fatto" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 1b72f2fb8..b072304ec 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "ファイルとしてコピー", "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", "copy_failed": "ファイルをコピーできませんでした。" + }, + "desktopReminders": { + "generic_title": "リマインダー", + "snooze": "{count} 分後に再通知", + "snooze_short": "{count} 分", + "open": "開く", + "join": "参加", + "missed_title": "{count, plural, other {見逃したリマインダー # 件}}", + "missed_more": "ほか {count} 件", + "missed_show": "表示", + "missed_dialog_title": "見逃したリマインダー", + "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。", + "open_calendar": "カレンダーを開く", + "done": "完了" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index ce7ac48d8..9b06f3d24 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "파일로 복사", "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", "copy_failed": "파일을 복사할 수 없습니다." + }, + "desktopReminders": { + "generic_title": "알림", + "snooze": "{count}분 후 다시 알림", + "snooze_short": "{count}분", + "open": "열기", + "join": "참가", + "missed_title": "{count, plural, other {놓친 알림 #개}}", + "missed_more": "외 {count}개", + "missed_show": "보기", + "missed_dialog_title": "놓친 알림", + "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.", + "open_calendar": "캘린더 열기", + "done": "완료" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index b45fabbb4..891aeb6e2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopēt kā failu", "copied": "Fails nokopēts. Varat to ielīmēt mapē.", "copy_failed": "Failu neizdevās nokopēt." + }, + "desktopReminders": { + "generic_title": "Atgādinājums", + "snooze": "Atlikt par {count} min", + "snooze_short": "{count} min", + "open": "Atvērt", + "join": "Pievienoties", + "missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}", + "missed_more": "un vēl {count}", + "missed_show": "Rādīt", + "missed_dialog_title": "Nokavētie atgādinājumi", + "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.", + "open_calendar": "Atvērt kalendāru", + "done": "Gatavs" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 243f02639..2a182da0e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Файл болгож хуулах", "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "desktopReminders": { + "generic_title": "Сануулга", + "snooze": "{count} минут хойшлуулах", + "snooze_short": "{count} мин", + "open": "Нээх", + "join": "Нэгдэх", + "missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}", + "missed_more": "бас {count}", + "missed_show": "Харуулах", + "missed_dialog_title": "Алдсан сануулгууд", + "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.", + "open_calendar": "Хуанли нээх", + "done": "Болсон" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index f2f0c0de7..3573c7773 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopier som fil", "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påminnelse", + "snooze": "Utsett {count} min", + "snooze_short": "{count} min", + "open": "Åpne", + "join": "Bli med", + "missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}", + "missed_more": "og {count} til", + "missed_show": "Vis", + "missed_dialog_title": "Tapte påminnelser", + "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.", + "open_calendar": "Åpne kalender", + "done": "Ferdig" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 6ab31ef9b..17329f729 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiëren als bestand", "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "desktopReminders": { + "generic_title": "Herinnering", + "snooze": "{count} min uitstellen", + "snooze_short": "{count} min", + "open": "Openen", + "join": "Deelnemen", + "missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}", + "missed_more": "en nog {count}", + "missed_show": "Tonen", + "missed_dialog_title": "Gemiste herinneringen", + "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.", + "open_calendar": "Agenda openen", + "done": "Klaar" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 4590d48b3..6b50e2feb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiuj jako plik", "copied": "Plik skopiowany. Możesz wkleić go do folderu.", "copy_failed": "Nie udało się skopiować pliku." + }, + "desktopReminders": { + "generic_title": "Przypomnienie", + "snooze": "Odłóż o {count} min", + "snooze_short": "{count} min", + "open": "Otwórz", + "join": "Dołącz", + "missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}", + "missed_more": "i jeszcze {count}", + "missed_show": "Pokaż", + "missed_dialog_title": "Pominięte przypomnienia", + "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.", + "open_calendar": "Otwórz kalendarz", + "done": "Gotowe" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 97ed21d66..0b67e7311 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como ficheiro", "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "desktopReminders": { + "generic_title": "Lembrete", + "snooze": "Adiar {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Entrar", + "missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}", + "missed_more": "e mais {count}", + "missed_show": "Mostrar", + "missed_dialog_title": "Lembretes perdidos", + "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.", + "open_calendar": "Abrir calendário", + "done": "Concluído" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 0936d12f4..f03956657 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiază ca fișier", "copied": "Fișier copiat. Îl poți lipi într-un dosar.", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "desktopReminders": { + "generic_title": "Memento", + "snooze": "Amână {count} min", + "snooze_short": "{count} min", + "open": "Deschide", + "join": "Participă", + "missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}", + "missed_more": "și încă {count}", + "missed_show": "Afișează", + "missed_dialog_title": "Mementouri ratate", + "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.", + "open_calendar": "Deschide calendarul", + "done": "Gata" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index eb1641895..32c3a46a1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копировать как файл", "copied": "Файл скопирован. Его можно вставить в папку.", "copy_failed": "Не удалось скопировать файл." + }, + "desktopReminders": { + "generic_title": "Напоминание", + "snooze": "Отложить на {count} мин", + "snooze_short": "{count} мин", + "open": "Открыть", + "join": "Подключиться", + "missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}", + "missed_more": "и ещё {count}", + "missed_show": "Показать", + "missed_dialog_title": "Пропущенные напоминания", + "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.", + "open_calendar": "Открыть календарь", + "done": "Готово" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 4431aa2a7..cb16abd32 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovať ako súbor", "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "desktopReminders": { + "generic_title": "Pripomienka", + "snooze": "Odložiť o {count} min", + "snooze_short": "{count} min", + "open": "Otvoriť", + "join": "Pripojiť sa", + "missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}", + "missed_more": "a ďalšie ({count})", + "missed_show": "Zobraziť", + "missed_dialog_title": "Zmeškané pripomienky", + "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.", + "open_calendar": "Otvoriť kalendár", + "done": "Hotovo" } } diff --git a/locales/th/common.json b/locales/th/common.json index 47156ac85..594e81712 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6599,5 +6599,19 @@ "copy_as_file": "คัดลอกเป็นไฟล์", "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "desktopReminders": { + "generic_title": "การแจ้งเตือน", + "snooze": "เลื่อน {count} นาที", + "snooze_short": "{count} นาที", + "open": "เปิด", + "join": "เข้าร่วม", + "missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}", + "missed_more": "และอีก {count} รายการ", + "missed_show": "แสดง", + "missed_dialog_title": "การแจ้งเตือนที่พลาด", + "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว", + "open_calendar": "เปิดปฏิทิน", + "done": "เสร็จ" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 391215501..946a1ab7b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Dosya olarak kopyala", "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", "copy_failed": "Dosya kopyalanamadı." + }, + "desktopReminders": { + "generic_title": "Anımsatıcı", + "snooze": "{count} dk ertele", + "snooze_short": "{count} dk", + "open": "Aç", + "join": "Katıl", + "missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}", + "missed_more": "ve {count} tane daha", + "missed_show": "Göster", + "missed_dialog_title": "Kaçırılan anımsatıcılar", + "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.", + "open_calendar": "Takvimi aç", + "done": "Bitti" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index ebed90c4e..45a4898b1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копіювати як файл", "copied": "Файл скопійовано. Його можна вставити в папку.", "copy_failed": "Не вдалося скопіювати файл." + }, + "desktopReminders": { + "generic_title": "Нагадування", + "snooze": "Відкласти на {count} хв", + "snooze_short": "{count} хв", + "open": "Відкрити", + "join": "Приєднатися", + "missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}", + "missed_more": "і ще {count}", + "missed_show": "Показати", + "missed_dialog_title": "Пропущені нагадування", + "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.", + "open_calendar": "Відкрити календар", + "done": "Готово" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 86de8e353..51222b054 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6603,5 +6603,19 @@ "copy_as_file": "Sao chép dưới dạng tệp", "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", "copy_failed": "Không thể sao chép tệp." + }, + "desktopReminders": { + "generic_title": "Lời nhắc", + "snooze": "Báo lại sau {count} phút", + "snooze_short": "{count} phút", + "open": "Mở", + "join": "Tham gia", + "missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}", + "missed_more": "và {count} lời nhắc khác", + "missed_show": "Hiển thị", + "missed_dialog_title": "Lời nhắc đã bỏ lỡ", + "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.", + "open_calendar": "Mở lịch", + "done": "Xong" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f3fb5a71a..8004dc647 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "複製為檔案", "copied": "檔案已複製。您可以將其貼到資料夾中。", "copy_failed": "無法複製檔案。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分鐘後再提醒", + "snooze_short": "{count} 分鐘", + "open": "開啟", + "join": "加入", + "missed_title": "{count, plural, other {# 則錯過的提醒}}", + "missed_more": "還有 {count} 則", + "missed_show": "顯示", + "missed_dialog_title": "錯過的提醒", + "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。", + "open_calendar": "開啟日曆", + "done": "完成" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 3058a98c8..58a804589 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "复制为文件", "copied": "文件已复制。你可以将其粘贴到文件夹中。", "copy_failed": "无法复制文件。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分钟后再提醒", + "snooze_short": "{count} 分钟", + "open": "打开", + "join": "加入", + "missed_title": "{count, plural, other {# 条错过的提醒}}", + "missed_more": "还有 {count} 条", + "missed_show": "显示", + "missed_dialog_title": "错过的提醒", + "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。", + "open_calendar": "打开日历", + "done": "完成" } } diff --git a/public/sw.js b/public/sw.js index 22e4174ba..581108f36 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1096,6 +1096,7 @@ function getReusableClientScore(state) { function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; + if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts index da0a266ea..8e7d41983 100644 --- a/stores/calendar-notification-store.ts +++ b/stores/calendar-notification-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { clearClaims } from '@/lib/calendar-reminders/claims'; const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000; @@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create()( clearAll: () => { set({ acknowledgedAlerts: {} }); + clearClaims(); }, }), { From 580a7a645d7967eb07febb6444c6eccc3dbd4de6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:50:21 +0100 Subject: [PATCH 13/36] test: check that signing out clears what the desktop app shows outside its windows --- lib/desktop/__tests__/os-signout.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/desktop/__tests__/os-signout.test.ts diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts new file mode 100644 index 000000000..d640b50b7 --- /dev/null +++ b/lib/desktop/__tests__/os-signout.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** A sign-out leaves nothing of the account on the OS's surfaces. */ + +vi.setConfig({ testTimeout: 60_000 }); + +describe('sign-out', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + it('clears what the desktop app shows outside its windows', async () => { + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup'); + purgeSignedOutData(); + expect(clearSurfaces).toHaveBeenCalled(); + }); + + it('in a browser there is nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + }); +}); 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 14/36] 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": { From 0872e094a2befe07b929cc27e9fb78c515a495e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:57:03 +0100 Subject: [PATCH 15/36] test: check that wiping one of several accounts clears what the desktop app shows --- lib/desktop/__tests__/os-store.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index e09aee90d..5b793cd9f 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -55,6 +55,9 @@ describe('the store and the OS integration', () => { const clearSurfaces = vi.fn(); window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; const { wipeAccount } = await import('@/lib/offline/offline-mode'); + const { useOfflineStore } = await import('@/lib/offline/state'); + // Not the last account, so this isn't the wipe of everything, which clears as well. + useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] }); await wipeAccount('alice@one').catch(() => {}); expect(clearSurfaces).toHaveBeenCalled(); window.bulwarkDesktop = undefined; From 8c0a45b865a4628697b660f9fe05ba7d841e393f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:57:28 +0100 Subject: [PATCH 16/36] test: leave no enabled accounts behind in the store and OS integration test --- lib/desktop/__tests__/os-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index 5b793cd9f..925e4d024 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -60,6 +60,7 @@ describe('the store and the OS integration', () => { useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] }); await wipeAccount('alice@one').catch(() => {}); expect(clearSurfaces).toHaveBeenCalled(); + useOfflineStore.setState({ enabled: [] }); window.bulwarkDesktop = undefined; }); From 099e3fa8875a90b08dd43eb43d30edc3b55f0755 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:37 +0100 Subject: [PATCH 17/36] test: check calendar reminders in the browser on both servers: offline, one tab, system notification, missed summary --- integration/tests/24-do-reminders.spec.ts | 226 ++++++++++++++++++++ lib/store/__tests__/calendar-no-dom.test.ts | 2 +- 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 integration/tests/24-do-reminders.spec.ts diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts new file mode 100644 index 000000000..19699dcce --- /dev/null +++ b/integration/tests/24-do-reminders.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent'; + +/** + * Calendar reminders from the local store, in a real browser on a production build and + * a VM 106 test Stalwart, with real time passing: the store's worker has its own clock. + * + * The account is ent-dave, whom the other specs leave alone. Snooze and the native + * buttons belong to the desktop app: repos/desktop test/reminders-real.spec.ts. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(900_000); +// The headless shell denies notifications; full Chromium really shows them. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +const USER = ENT.dave; +const ZONE = 'Europe/Berlin'; +const CORE = 'urn:ietf:params:jmap:core'; +const CALENDARS = 'urn:ietf:params:jmap:calendars'; +/** ledger.ts AWAY_MS and a margin. */ +const AWAY_MS = 6.5 * 60_000; +/** How long a change on the server may take to reach the store. */ +const SYNC_MS = 30_000; + +type J = Awaited>; +let client: J; +let context: BrowserContext; +let page: Page; +const created: string[] = []; + +async function call(calls: [string, Record, string][]) { + const response = await client.request(calls, [CORE, CALENDARS]); + for (const [name, result] of response.methodResponses) { + if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`); + } + return response.methodResponses as [string, Record, string][]; +} + +/** A wall clock in the event's zone. */ +function wall(ms: number): string { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }).format(new Date(ms)); + return parts.replace(' ', 'T'); +} + +const OFFSET_MIN = 5; + +/** An event whose alert fires at `fireAt`. */ +async function eventFiringAt(title: string, fireAt: number, extra: Record = {}): Promise { + const [[, calendars]] = await call([['Calendar/get', { accountId: client.accountId, properties: ['isDefault'] }, '0']]); + const list = calendars.list as Array<{ id: string; isDefault: boolean }>; + const calendar = list.find((entry) => entry.isDefault) ?? list[0]; + const [[, result]] = await call([['CalendarEvent/set', { + accountId: client.accountId, + create: { + e: { + '@type': 'Event', + calendarIds: { [calendar.id]: true }, + timeZone: ZONE, + title, + start: wall(fireAt + OFFSET_MIN * 60_000), + duration: 'PT30M', + useDefaultAlerts: false, + alerts: { a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: `-PT${OFFSET_MIN}M`, relativeTo: 'start' } } }, + ...extra, + }, + }, + sendSchedulingMessages: false, + }, '0']]); + const id = (result.created as Record | undefined)?.e?.id; + if (!id) throw new Error(`Event not created: ${JSON.stringify(result)}`); + created.push(id); + return id; +} + +/** Settings > Backup & reset > Offline, switched on, and the first sync done. */ +async function enableOffline(target: Page): Promise { + await target.goto('/settings/backup/offline'); + const toggle = target.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(target.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +const toast = (target: Page, text: string) => target.locator('.toast-item').filter({ hasText: text }); + +test.beforeAll(async ({ browser }) => { + requireEntEnv(); + client = await jmapAs(USER); + page = await userPage(browser, USER); + context = page.context(); + await enableOffline(page); + await page.goto('/'); +}); + +test.afterAll(async () => { + if (created.length > 0) { + await call([['CalendarEvent/set', { accountId: client.accountId, destroy: created, sendSchedulingMessages: false }, '0']]).catch(() => {}); + } + await context?.close(); +}); + +test('an occurrence of a series fires from the store with the network gone', async () => { + const title = `rem-series-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 30_000; + // The series began weeks ago, so only an expansion finds today's occurrence. + const [day, time] = wall(fireAt + OFFSET_MIN * 60_000).split('T'); + const weeksAgo = new Date(Date.parse(`${day}T00:00:00Z`) - 28 * 24 * 3_600_000).toISOString().slice(0, 10); + await eventFiringAt(title, fireAt, { + start: `${weeksAgo}T${time}`, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + }); + await page.waitForTimeout(SYNC_MS); + await context.setOffline(true); + try { + expect(await page.evaluate(() => navigator.onLine)).toBe(false); + await page.waitForTimeout(Math.max(fireAt - Date.now() - 3000, 0)); + await expect(toast(page, title)).toHaveCount(0); + await expect(toast(page, title)).toBeVisible({ timeout: 30_000 }); + await shot(page, 'desktop-reminders-offline-toast'); + } finally { + await context.setOffline(false); + } + await toast(page, title).getByRole('button').first().click().catch(() => {}); +}); + +test('fires in one tab of two', async () => { + const second = await context.newPage(); + await second.goto('/'); + const title = `rem-tabs-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(title, fireAt); + // A reminder's toast leaves after a while, so note the most each tab ever showed. + const most = [0, 0]; + const deadline = fireAt + 40_000; + let lastSeen = 0; + while (Date.now() < deadline && (lastSeen === 0 || Date.now() < lastSeen + 8000)) { + const counts = await Promise.all([page, second].map((target) => toast(target, title).count())); + counts.forEach((count, index) => { + most[index] = Math.max(most[index], count); + }); + if (lastSeen === 0 && counts.some((count) => count > 0)) lastSeen = Date.now(); + await second.waitForTimeout(250); + } + expect(most[0] + most[1]).toBe(1); + await second.close(); + await page.bringToFront(); +}); + +test('adds a system notification when permission is granted and the tab is not in front', async () => { + await context.grantPermissions(['notifications']); + await page.reload(); + await page.evaluate(() => { + document.hasFocus = () => false; + }); + const title = `rem-system-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 15_000; + await eventFiringAt(title, fireAt); + await expect(toast(page, title)).toBeVisible({ timeout: fireAt - Date.now() + 30_000 }); + const shown = await page.evaluate(async (wanted) => { + const registration = await navigator.serviceWorker.ready; + return (await registration.getNotifications()).filter((item) => item.title === wanted).map((item) => ({ tag: item.tag, data: item.data as unknown })); + }, title); + expect(shown).toHaveLength(1); + expect(shown[0].tag).toContain('calendar-reminder:'); + expect(shown[0].data).toEqual({ kind: 'calendar-reminder' }); + await context.clearPermissions(); +}); + +test('what came due with the app closed is summarised once, and not as a burst', async () => { + const one = `rem-missed-a-${RUN}`; + const two = `rem-missed-b-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(one, fireAt); + await eventFiringAt(two, fireAt + 20_000, { virtualLocations: { v: { '@type': 'VirtualLocation', name: 'Meet', uri: 'https://meet.jit.si/rem-test' } } }); + await page.waitForTimeout(SYNC_MS); + + // Closed like an app: nothing of the page is left, and the store's data stays. + const closedAt = Date.now(); + await page.close(); + await new Promise((resolve) => setTimeout(resolve, Math.max(fireAt + 20_000 + AWAY_MS - closedAt, AWAY_MS))); + + page = await context.newPage(); + await page.goto('/'); + const summary = page.locator('.toast-item').filter({ hasText: one }); + await expect(summary).toBeVisible({ timeout: 60_000 }); + await expect(summary).toContainText(two); + await expect(toast(page, one)).toHaveCount(1); + await shot(page, 'desktop-reminders-missed-toast'); + + await summary.getByRole('button', { name: 'Show' }).click(); + const list = page.getByTestId('missed-reminders'); + await expect(list).toContainText(one); + await expect(list).toContainText(two); + await expect(list.getByRole('button', { name: 'Join' })).toHaveCount(1); + await shot(page, 'desktop-reminders-missed'); + await page.getByRole('button', { name: 'Done' }).click(); + await expect(list).toHaveCount(0); + + // Once: not after a reload, nor from a new tab. + await page.reload(); + await page.waitForTimeout(30_000); + await expect(page.locator('.toast-item').filter({ hasText: 'rem-missed' })).toHaveCount(0); + await expect(page.getByTestId('missed-reminders')).toHaveCount(0); +}); + +test('with the store off, the app reminds as it always did', async () => { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + await toggle.click(); + const confirm = page.getByRole('button', { name: /turn off|switch off|delete/i }).last(); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + await page.goto('/'); + const title = `rem-plain-${RUN}`; + const fireAt = Date.now() + 20_000; + await eventFiringAt(title, fireAt); + // The in-app path reads the server every five minutes, or on a push. + await expect(toast(page, title)).toBeVisible({ timeout: 120_000 }); + await page.waitForTimeout(5000); + await expect(toast(page, title)).toHaveCount(1); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts index 8cd3d10a1..1fcd8543f 100644 --- a/lib/store/__tests__/calendar-no-dom.test.ts +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -22,7 +22,7 @@ async function inputsOf(entry: string): Promise { } describe('what the worker pulls in', () => { - for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts', 'lib/store/reminders/index.ts']) { it(`${entry} reaches no UI code`, async () => { const inputs = await inputsOf(entry); expect(inputs.length).toBeGreaterThan(3); From 8523289a4babfe0536edc1e1e340dd16a28bf2d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:19:44 +0100 Subject: [PATCH 18/36] feat: let an admin wipe a lost device, and clear an account the server refuses for good Until now a lost laptop kept its offline mail for as long as anyone could open it, and an account whose login the server had ended kept its copy too. - `lib/devices`, `app/api/devices`: browsers with offline on and desktop apps register, in a durable collection with no expiry. A check-in goes by a per-device secret, so a revoked device still hears its wipe. - `app/api/admin/devices` and the org devices page: wipe one device or all of a user's, with audit entries for each request and each confirmation. - `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then waits up to a second for the instance before showing the local copy. A wipe takes the store, keys, plugin databases and handoffs, signs out, and reports back, again at the next contact if the report failed. - `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the session limit, or a 401 lasting past a re-auth count as refused. A 500, a proxy page or an outage never do. - Settings: "Remove everything from this device", with a confirmation. `lib/shared-store/__tests__/import-data.test.ts` changes its assertion because it lists every collection the import copies, and `devices` is new. --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/[locale]/login/page.tsx | 10 + .../__tests__/device-registry-panel.test.tsx | 93 +++ .../admin/_pages/device-registry-panel.tsx | 250 ++++++++ app/(main)/admin/_pages/logs.tsx | 4 + app/(main)/admin/_pages/org-devices.tsx | 2 + app/api/admin/devices/route.ts | 114 ++++ app/api/devices/__tests__/route.test.ts | 203 ++++++ app/api/devices/route.ts | 71 +++ app/api/devices/wiped/route.ts | 63 ++ app/api/plugin-secrets/route.ts | 14 + .../__tests__/wipe-device-section.test.tsx | 49 ++ components/offline/device-wipe-host.tsx | 31 + components/offline/offline-settings.tsx | 2 + components/offline/wipe-device-section.tsx | 70 +++ integration/tests/24-do-remote-wipe.spec.ts | 364 +++++++++++ lib/admin/admin-nav.ts | 1 + lib/devices/__tests__/client.test.ts | 94 +++ lib/devices/__tests__/registry.test.ts | 177 ++++++ lib/devices/client.ts | 90 +++ lib/devices/registry.ts | 231 +++++++ lib/devices/types.ts | 30 + lib/devices/wipe-state.ts | 135 ++++ .../__tests__/credentials-refused.test.ts | 167 +++++ lib/jmap/client-interface.ts | 6 + lib/jmap/client.ts | 44 +- lib/offline/__tests__/device-wipe.test.ts | 593 ++++++++++++++++++ lib/offline/credentials-rejected.ts | 6 +- lib/offline/device-wipe.ts | 491 +++++++++++++++ lib/offline/wipe-registry.ts | 39 ++ lib/plugin-secrets/vault.ts | 5 + .../__tests__/import-data.test.ts | 9 +- lib/shared-store/export.ts | 2 + lib/shared-store/import/data.ts | 2 + lib/store/client/keys.ts | 49 ++ locales/ar/common.json | 17 + locales/ca/common.json | 17 + locales/cs/common.json | 17 + locales/da/common.json | 17 + locales/de/common.json | 17 + locales/en/common.json | 17 + locales/es/common.json | 17 + locales/et/common.json | 17 + locales/fa/common.json | 17 + locales/fr/common.json | 17 + locales/he/common.json | 17 + locales/hu/common.json | 17 + locales/it/common.json | 17 + locales/ja/common.json | 17 + locales/ko/common.json | 17 + locales/lv/common.json | 17 + locales/mn/common.json | 17 + locales/nb/common.json | 17 + locales/nl/common.json | 17 + locales/pl/common.json | 17 + locales/pt/common.json | 17 + locales/ro/common.json | 17 + locales/ru/common.json | 17 + locales/sk/common.json | 17 + locales/th/common.json | 17 + locales/tr/common.json | 17 + locales/uk/common.json | 17 + locales/vi/common.json | 17 + locales/zh-TW/common.json | 17 + locales/zh/common.json | 17 + scripts/lite/lib.mjs | 2 + .../__tests__/auth-store-device-wipe.test.ts | 271 ++++++++ stores/auth-store.ts | 66 +- 68 files changed, 4352 insertions(+), 10 deletions(-) create mode 100644 app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx create mode 100644 app/(main)/admin/_pages/device-registry-panel.tsx create mode 100644 app/api/admin/devices/route.ts create mode 100644 app/api/devices/__tests__/route.test.ts create mode 100644 app/api/devices/route.ts create mode 100644 app/api/devices/wiped/route.ts create mode 100644 components/offline/__tests__/wipe-device-section.test.tsx create mode 100644 components/offline/device-wipe-host.tsx create mode 100644 components/offline/wipe-device-section.tsx create mode 100644 integration/tests/24-do-remote-wipe.spec.ts create mode 100644 lib/devices/__tests__/client.test.ts create mode 100644 lib/devices/__tests__/registry.test.ts create mode 100644 lib/devices/client.ts create mode 100644 lib/devices/registry.ts create mode 100644 lib/devices/types.ts create mode 100644 lib/devices/wipe-state.ts create mode 100644 lib/jmap/__tests__/credentials-refused.test.ts create mode 100644 lib/offline/__tests__/device-wipe.test.ts create mode 100644 lib/offline/device-wipe.ts create mode 100644 lib/offline/wipe-registry.ts create mode 100644 stores/__tests__/auth-store-device-wipe.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..b552b240d 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; +import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -115,6 +116,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bf9b69c44..e5ea02def 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store"; import { consumeLost } from "@/lib/offline/lost"; +import { consumeWipeNote } from "@/lib/devices/wipe-state"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -194,6 +195,7 @@ function LoginPageContent() { const [desktopWaiting, setDesktopWaiting] = useState(false); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); + const tOfflineWipe = useTranslations("offlineWipe"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); @@ -282,8 +284,11 @@ function LoginPageContent() { const [sessionEnd, setSessionEnd] = useState(null); // Offline outbox messages a sign-out deleted unsent (#523), named once. const [outboxLost, setOutboxLost] = useState([]); + // Unsent mail a wipe took with it: how many, never which. + const [wipeLost, setWipeLost] = useState(null); useEffect(() => { setOutboxLost(consumeLost()); + setWipeLost(consumeWipeNote()?.unsent ?? null); }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -1455,6 +1460,11 @@ function LoginPageContent() { {/* Form section */}
+ {wipeLost !== null && ( +
+ {wipeLost > 0 ? tOfflineWipe("note_unsent", { count: wipeLost }) : tOfflineWipe("note")} +
+ )} {outboxLost.length > 0 && (
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} diff --git a/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx new file mode 100644 index 000000000..535f76f13 --- /dev/null +++ b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DeviceView } from '@/lib/devices/types'; +import { DeviceRegistryPanel } from '../device-registry-panel'; + +const device = (over: Partial): DeviceView => ({ + id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, + userKey: 'a'.repeat(64), + user: 'alice@example.org', + server: 'https://mail.example.org', + platform: 'Chrome, Windows', + appVersion: '1.2.3', + firstSeen: '2026-11-01T10:00:00.000Z', + lastSeen: '2026-11-03T10:00:00.000Z', + wipe: null, + wipedAt: null, + wipeReason: null, + ...over, +}); + +let rows: DeviceView[]; +let posts: Array>; + +beforeEach(() => { + posts = []; + rows = [device({})]; + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + if (init?.method === 'POST') { + posts.push(JSON.parse(String(init.body))); + return new Response('{}', { status: 200 }); + } + expect(String(input)).toContain('/api/admin/devices'); + return new Response(JSON.stringify({ devices: rows, total: rows.length }), { status: 200 }); + })); +}); + +describe('the registered devices', () => { + it('lists user, platform, version and last seen', async () => { + render(); + const row = await screen.findByTestId('device-row'); + expect(screen.getByText('alice@example.org')).toBeTruthy(); + expect(row.textContent).toContain('Chrome, Windows'); + expect(row.textContent).toContain('Version 1.2.3'); + expect(row.textContent).toContain('Last seen'); + }); + + it('wipes only after the confirmation, naming who and what', async () => { + render(); + fireEvent.click(await screen.findByTestId('device-wipe')); + expect(posts).toEqual([]); + const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog')); + expect(dialog.textContent).toContain('alice@example.org'); + expect(dialog.textContent).toContain('Mail that was waiting to be sent is lost'); + expect(dialog.textContent).toContain('S/MIME and OpenPGP keys imported in that browser or app go for every account there'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' })); + await waitFor(() => expect(posts).toEqual([{ action: 'wipe', id: rows[0].id }])); + }); + + it('offers all of a user\'s devices once there are several', async () => { + rows = [device({}), device({ id: `${'a'.repeat(64)}/${'c'.repeat(32)}`, platform: 'Desktop app, Windows' })]; + render(); + fireEvent.click(await screen.findByTestId('devices-wipe-user')); + const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' })); + await waitFor(() => expect(posts).toEqual([{ action: 'wipe-user', userKey: 'a'.repeat(64) }])); + }); + + it('says a wipe is waiting and lets the admin take it back', async () => { + rows = [device({ wipe: { requestedAt: '2026-11-03T11:00:00.000Z', by: 'admin' } })]; + render(); + const row = await screen.findByTestId('device-row'); + expect(row.textContent).toContain('Wipe waiting'); + expect(screen.queryByTestId('device-wipe')).toBeNull(); + fireEvent.click(screen.getByTestId('device-cancel')); + await waitFor(() => expect(posts).toEqual([{ action: 'cancel', id: rows[0].id }])); + }); + + it('shows a wiped device as such, with nothing more to wipe', async () => { + rows = [device({ wipedAt: '2026-11-03T12:00:00.000Z', wipeReason: 'user' })]; + render(); + const row = await screen.findByTestId('device-row'); + expect(row.textContent).toContain('Wiped'); + expect(row.textContent).toContain('by the user'); + expect(screen.queryByTestId('device-wipe')).toBeNull(); + expect(screen.getByTestId('device-forget')).toBeTruthy(); + }); + + it('says so when nothing registered', async () => { + rows = []; + render(); + expect(await screen.findByTestId('devices-empty')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx new file mode 100644 index 000000000..db4ef63fa --- /dev/null +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Trash2, X } from '@/components/icons'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { Badge } from '@/components/admin/badge'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { DeviceView } from '@/lib/devices/types'; + +const PAGE_SIZE = 100; + +type Pending = + | { kind: 'device'; device: DeviceView } + | { kind: 'user'; userKey: string; user: string; count: number } + | { kind: 'forget'; device: DeviceView }; + +function when(iso: string | null): string { + return iso ? new Date(iso).toLocaleString() : ''; +} + +/** + * Browsers with offline mode on and desktop apps. A wipe waits for the device's + * next contact, and the row says so until the device confirms. Its own section, + * with no draft and no save bar: each action happens when it's confirmed. + */ +export function DeviceRegistryPanel() { + const [devices, setDevices] = useState(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [query, setQuery] = useState(''); + const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [pending, setPending] = useState(null); + + const load = useCallback(async () => { + try { + const params = new URLSearchParams({ offset: String(offset), limit: String(PAGE_SIZE) }); + if (search) params.set('q', search); + const res = await apiFetch(`/api/admin/devices?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { devices: DeviceView[]; total: number }; + setDevices(data.devices); + setTotal(data.total); + setError(null); + } catch (e) { + setError(`Could not load the devices (${(e as Error).message})`); + } + }, [offset, search]); + + useEffect(() => { void load(); }, [load]); + + const act = async (key: string, body: Record) => { + setBusy(key); + try { + const res = await apiFetch('/api/admin/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + } catch (e) { + setError(`That didn't work (${(e as Error).message})`); + } finally { + setBusy(null); + } + }; + + const groups = useMemo(() => { + const byUser = new Map(); + for (const device of devices ?? []) { + const rows = byUser.get(device.userKey) ?? []; + rows.push(device); + byUser.set(device.userKey, rows); + } + return [...byUser.entries()].map(([userKey, rows]) => ({ + userKey, + user: rows[0].user, + server: rows[0].server, + rows: [...rows].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)), + })); + }, [devices]); + + const confirmPending = () => { + if (!pending) return; + setPending(null); + if (pending.kind === 'device') void act(pending.device.id, { action: 'wipe', id: pending.device.id }); + else if (pending.kind === 'forget') void act(pending.device.id, { action: 'forget', id: pending.device.id }); + else void act(pending.userKey, { action: 'wipe-user', userKey: pending.userKey }); + }; + + return ( + +
{ event.preventDefault(); setOffset(0); setSearch(query.trim()); }} + > + setQuery(event.target.value)} + placeholder="Search by user" + aria-label="Search by user" + className="h-8 min-w-48 flex-1 rounded-md border border-input bg-background px-2 text-sm" + data-testid="devices-search" + /> + +
+ {error &&

{error}

} + {devices !== null && devices.length === 0 && ( +

+ {search ? 'No device matches that search.' : 'No device has registered yet.'} +

+ )} +
+ {groups.map((group) => { + const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt); + return ( +
+
+

+ {group.user} + {group.server} +

+ {wipeable.length > 1 && ( + + )} +
+ + {group.rows.map((device) => ( + Wipe waiting + : device.wipedAt + ? Wiped + : null} + description={device.wipe + ? `Asked ${when(device.wipe.requestedAt)}. Happens the next time this device connects.` + : device.wipedAt + ? `Wiped ${when(device.wipedAt)}${device.wipeReason && device.wipeReason !== 'admin' ? ` (${wipeReasonLabel(device.wipeReason)})` : ''}` + : undefined} + meta={`${device.appVersion ? `Version ${device.appVersion} · ` : ''}Last seen ${when(device.lastSeen)} · First seen ${when(device.firstSeen)}`} + control={( +
+ {!device.wipe && !device.wipedAt && ( + + )} + {device.wipe && ( + + )} + +
+ )} + /> + ))} +
+
+ ); + })} +
+ {total > PAGE_SIZE && ( +
+ {offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total} + + + + +
+ )} + setPending(null)} + onConfirm={confirmPending} + title={pending?.kind === 'forget' ? 'Forget this device?' : 'Wipe this data?'} + message={confirmMessage(pending)} + confirmText={pending?.kind === 'forget' ? 'Forget' : 'Wipe'} + variant="destructive" + /> +
+ ); +} + +function wipeReasonLabel(reason: NonNullable): string { + switch (reason) { + case 'user': return 'by the user'; + case 'rejected': return 'the server refused the login'; + default: return reason; + } +} + +function confirmMessage(pending: Pending | null): string { + if (!pending) return ''; + const consequences = 'Mail, keys and files kept on it are deleted and the account is signed out the next time it connects. Mail that was waiting to be sent is lost. S/MIME and OpenPGP keys imported in that browser or app go for every account there, because they are not kept per account: anyone using them has to import them again. Keys in the OS certificate store stay. The mail on the server is not touched.'; + if (pending.kind === 'device') return `${pending.device.platform || 'This device'} of ${pending.device.user}. ${consequences}`; + if (pending.kind === 'user') return `All ${pending.count} devices of ${pending.user}. ${consequences}`; + return pending.device.wipe + ? `The wipe waiting for ${pending.device.platform || 'this device'} of ${pending.device.user} is dropped with its row. The device will not be told.` + : `${pending.device.platform || 'This device'} of ${pending.device.user} leaves this list. It comes back as a new row if it registers again.`; +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx index 4077e9732..d6930eeb2 100644 --- a/app/(main)/admin/_pages/logs.tsx +++ b/app/(main)/admin/_pages/logs.tsx @@ -63,6 +63,10 @@ export function LogsPage() { + + + + + setAsking(false)} + onConfirm={confirmed} + title={t('confirm_title')} + message={t('confirm_message')} + details={( +
    +
  • {t('confirm_mail')}
  • + {unsent > 0 &&
  • {t('confirm_unsent', { count: unsent })}
  • } +
  • {t('confirm_keys')}
  • +
  • {t('confirm_plugins')}
  • +
  • {t('confirm_files')}
  • +
  • {t('confirm_accounts')}
  • +
+ )} + confirmText={t('confirm_button')} + variant="destructive" + /> +
+ ); +} diff --git a/integration/tests/24-do-remote-wipe.spec.ts b/integration/tests/24-do-remote-wipe.spec.ts new file mode 100644 index 000000000..d88ac072a --- /dev/null +++ b/integration/tests/24-do-remote-wipe.spec.ts @@ -0,0 +1,364 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, ENT_PASSWORD, adminContext, deliver, requireEntEnv, shot, userPage } from './helpers/ent'; +import { JMAP_URL } from './helpers/config'; + +/** + * Remote wipe, through the real UI on a production build, on both VM 106 + * Stalwarts, with the ent-* accounts: + * - a browser with offline on registers; the admin wipes it from + * Organisation > Devices; at its next connect the raw OPFS files, the key + * database, plugin databases and the handoff cache are gone, sign-in is + * required, the login page gives the count of lost mail, the instance has the + * confirmation and the audit log has both entries; + * - at a start the wipe comes before anything from the device is shown; + * - with the instance out of reach mail stays and the wipe keeps waiting, and it + * happens once the device is back; + * - "Remove everything from this device" in settings. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const RUN = Date.now().toString(36); + +interface Device { + id: string; + user: string; + platform: string; + firstSeen: string; + wipe: { requestedAt: string } | null; + wipedAt: string | null; + wipeReason: string | null; +} + +async function devicesOf(admin: BrowserContext, email: string): Promise { + const res = await admin.request.get(`/api/admin/devices?q=${encodeURIComponent(email)}`); + expect(res.ok(), `listing devices: ${res.status()}`).toBe(true); + return (await res.json() as { devices: Device[] }).devices; +} + +/** Rows earlier runs left. */ +async function forgetDevices(admin: BrowserContext, email: string): Promise { + for (const device of await devicesOf(admin, email)) { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'forget', id: device.id } }); + expect(res.ok()).toBe(true); + } +} + +async function theDevice(admin: BrowserContext, email: string): Promise { + let found: Device | undefined; + await expect.poll(async () => { + found = (await devicesOf(admin, email))[0]; + return found?.id ?? ''; + }, { timeout: 60_000, message: `${email} registers` }).not.toBe(''); + return found!; +} + +async function adminWipe(admin: BrowserContext, id: string): Promise { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'wipe', id } }); + expect(res.ok()).toBe(true); +} + +async function enableOffline(page: Page): Promise { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +async function toMail(page: Page, subject: string): Promise { + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); +} + +async function seedMail(to: string, subject: string): Promise { + await deliver({ from: ENT.bob, to: [to], subject, text: `Body of ${subject}` }); +} + +/** What's in the browser's storage for this origin, read raw, opening nothing that would create it. */ +async function rawStorage(page: Page): Promise<{ databases: string[]; storeFiles: string[]; caches: string[]; keyRecords: number }> { + return page.evaluate(async () => { + const databases = (await indexedDB.databases()).map((database) => database.name ?? ''); + const storeFiles: string[] = []; + const root = await navigator.storage.getDirectory(); + const list = async (dir: FileSystemDirectoryHandle, prefix: string): Promise => { + for await (const handle of (dir as unknown as { values(): AsyncIterable }).values()) { + if (handle.kind === 'directory') await list(handle as FileSystemDirectoryHandle, `${prefix}${handle.name}/`); + else storeFiles.push(`${prefix}${handle.name}`); + } + }; + await list(root, ''); + let keyRecords = 0; + if (databases.includes('bulwark-store')) { + keyRecords = await new Promise((resolve) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => resolve(-1); + open.onsuccess = () => { + const db = open.result; + const count = db.transaction('accounts').objectStore('accounts').count(); + count.onsuccess = () => { db.close(); resolve(count.result); }; + count.onerror = () => { db.close(); resolve(-1); }; + }; + }); + } + return { databases, storeFiles, caches: await caches.keys(), keyRecords }; + }); +} + +async function expectStoreGone(page: Page): Promise { + await expect.poll(async () => { + const raw = await rawStorage(page); + return { + keys: raw.keyRecords, + store: raw.databases.includes('bulwark-store'), + legacy: raw.databases.includes('bulwark-offline'), + files: raw.storeFiles, + }; + }, { timeout: 30_000, message: 'nothing of the store is left' }).toEqual({ keys: 0, store: false, legacy: false, files: [] }); +} + +async function expectSignedOut(page: Page): Promise { + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + const cookies = (await page.context().cookies()).map((cookie) => cookie.name); + expect(cookies.filter((name) => /^jmap_(session|stalwart_ctx|rt|at)/.test(name))).toEqual([]); + // And it stays that way. + await page.reload(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); +} + +test.beforeAll(() => { + requireEntEnv(); +}); + +test('the admin wipes a device: at its next connect everything on it goes, and the instance hears', async ({ browser }) => { + const subject = `Wipe me ${RUN}`; + const unsent = `Unsent secret ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.dave); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.dave, subject); + + // A second user, so the page shows what an admin really sees. + const other = await userPage(browser, ENT.alice); + await enableOffline(other); + const otherDevice = await theDevice(admin, ENT.alice); + + const page = await userPage(browser, ENT.dave); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.dave); + expect(device.user).toBe(ENT.dave); + expect(device.platform).toContain('Chrom'); + expect(device.wipe).toBeNull(); + + const before = await rawStorage(page); + expect(before.keyRecords).toBe(1); + expect(before.storeFiles.length).toBeGreaterThan(0); + + // What plugins and the launch handoff would have left. + await page.evaluate(async (run) => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', `plugin-made-${run}`]) { + await new Promise((resolve, reject) => { + const open = indexedDB.open(name, 1); + open.onupgradeneeded = () => { open.result.createObjectStore('keys'); }; + open.onsuccess = () => { open.result.close(); resolve(); }; + open.onerror = () => reject(open.error); + }); + } + const cache = await caches.open('bulwark-launch-handoff-v1'); + await cache.put('/handoff-test', new Response('secret file')); + }, RUN); + + // A message waits in the outbox, so the note has something to count. + await page.context().setOffline(true); + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + const to = page.locator('[data-testid="composer-to"] input').first(); + await to.click(); + await to.fill(ENT.bob); + await to.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(unsent); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type('Never sent'); + await page.locator('[data-testid="composer-subject"]').first().focus(); + await page.keyboard.press('Control+Enter'); + await expect(page.getByTestId('offline-banner-outbox')).toHaveText('1 message in the outbox', { timeout: 30_000 }); + + // The admin's side, through the page. + const adminPage = await admin.newPage(); + await adminPage.goto('/admin/org-devices'); + const section = adminPage.locator('[data-user="ent-dave@example.org"]'); + await expect(section.getByTestId('device-row')).toBeVisible({ timeout: 30_000 }); + await expect(adminPage.locator('[data-user="ent-alice@example.org"]').getByTestId('device-row')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(adminPage, 'desktop-offline-admin-devices'); + await section.getByTestId('device-wipe').click(); + await expect(adminPage.getByRole('dialog').or(adminPage.getByRole('alertdialog')).first()).toContainText('imported in that browser or app go for every account'); + await adminPage.getByRole('button', { name: 'Wipe', exact: true }).last().click(); + await expect(section.getByTestId('device-row')).toContainText('Wipe waiting', { timeout: 15_000 }); + await shot(adminPage, 'desktop-offline-admin-devices-waiting'); + expect((await devicesOf(admin, ENT.dave))[0].wipe).not.toBeNull(); + + // Back on the network, with the mail server gone: the word comes from the + // instance and needs nothing of the mail server. The outbox can't leave either. + await page.context().route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + await page.context().setOffline(false); + + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByTestId('device-wipe-note')).toContainText('Unsent messages deleted: 1'); + expect(await page.getByTestId('device-wipe-note').textContent()).not.toContain(unsent); + + await expectStoreGone(page); + const raw = await rawStorage(page); + expect(raw.databases).not.toContain('smime-plugin-store'); + expect(raw.databases).not.toContain('bulwark-openpgp'); + expect(raw.databases).not.toContain(`plugin-made-${RUN}`); + expect(raw.caches).not.toContain('bulwark-launch-handoff-v1'); + const stored = await page.evaluate(() => JSON.stringify({ ...localStorage }) + JSON.stringify({ ...sessionStorage })); + expect(stored).not.toContain(unsent); + await page.context().unroute(`${new URL(JMAP_URL).origin}/**`); + + await expectSignedOut(page); + + // The note shows once. + await expect(page.getByTestId('device-wipe-note')).toHaveCount(0); + + const confirmed = (await devicesOf(admin, ENT.dave))[0]; + expect(confirmed.id).toBe(device.id); + expect(confirmed.wipe).toBeNull(); + expect(confirmed.wipedAt).not.toBeNull(); + expect(confirmed.wipeReason).toBe('admin'); + + // The other user's row isn't touched. + expect((await devicesOf(admin, ENT.alice)).find((row) => row.id === otherDevice.id)?.wipe).toBeNull(); + + const audit = async (action: string) => { + const res = await admin.request.get(`/api/admin/audit?action=${action}&limit=50`); + expect(res.ok()).toBe(true); + return (await res.json() as { entries: Array<{ detail: Record }> }).entries.filter((entry) => entry.detail.device === device.id); + }; + expect(await audit('device.wipe.request')).toHaveLength(1); + const confirmations = await audit('device.wipe.confirm'); + expect(confirmations).toHaveLength(1); + expect(confirmations[0].detail).toMatchObject({ user: ENT.dave, reason: 'admin', unsentDeleted: 1 }); + + await adminPage.reload(); + await expect(adminPage.locator('[data-user="ent-dave@example.org"]').getByTestId('device-row')).toContainText('Wiped', { timeout: 30_000 }); + + // A new sign-in is a new device. + await page.fill('#username', ENT.dave); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await expect(page.locator('#username')).toBeHidden({ timeout: 60_000 }); + await enableOffline(page); + await expect.poll(async () => (await devicesOf(admin, ENT.dave)).length, { timeout: 60_000 }).toBe(2); + + await Promise.all([page.context().close(), other.context().close(), admin.close()]); +}); + +test('at a start, the wipe comes before anything from the device is shown', async ({ browser }) => { + const subject = `Start wipe ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.alice, subject); + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.alice); + const context = page.context(); + + // The mail was on screen here a moment ago, and comes from the device at a start. + await context.addInitScript(() => { + const look = () => { + if (document.querySelector('[data-testid="email-list-item"]')) sessionStorage.setItem('wipe-test-saw-mail', 'yes'); + }; + new MutationObserver(look).observe(document, { childList: true, subtree: true }); + }); + await page.close(); + await adminWipe(admin, device.id); + + const next = await context.newPage(); + await next.goto('/mail'); + await expect(next.locator('#username')).toBeVisible({ timeout: 60_000 }); + expect(await next.evaluate(() => sessionStorage.getItem('wipe-test-saw-mail'))).toBeNull(); + await expect(next.getByTestId('device-wipe-note')).toContainText('removed this account'); + await expectStoreGone(next); + await expect.poll(async () => (await devicesOf(admin, ENT.alice))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([context.close(), admin.close()]); +}); + +test('with the instance out of reach the mail stays and the wipe waits, until the device is back', async ({ browser }) => { + const subject = `Waits ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + await seedMail(ENT.carol, subject); + const page = await userPage(browser, ENT.carol); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.carol); + await adminWipe(admin, device.id); + + // A start with no network at all: the local copy, as ever. + await page.context().setOffline(true); + await page.reload(); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); + expect((await rawStorage(page)).keyRecords).toBe(1); + + // The device never got the message, and the row keeps saying so. + const waiting = (await devicesOf(admin, ENT.carol))[0]; + expect(waiting.wipe).not.toBeNull(); + expect(waiting.wipedAt).toBeNull(); + + await page.context().setOffline(false); + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expectStoreGone(page); + await expect.poll(async () => (await devicesOf(admin, ENT.carol))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('the user removes everything from this device, from settings', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.bob); + const page = await userPage(browser, ENT.bob); + // Switched on and no more: this account has a lot of mail, so the wipe lands in + // the middle of its first sync, with the worker busy writing. + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect.poll(async () => (await rawStorage(page)).keyRecords, { timeout: 60_000 }).toBe(1); + await expect(page.getByTestId('offline-status')).toBeVisible({ timeout: 30_000 }); + const device = await theDevice(admin, ENT.bob); + + await page.getByTestId('wipe-device-button').scrollIntoViewIfNeeded(); + await page.getByTestId('wipe-device-button').click(); + const details = page.getByTestId('wipe-device-details'); + await expect(details).toContainText('S/MIME and OpenPGP keys'); + await expect(details).toContainText('All accounts are signed out'); + await shot(page, 'desktop-offline-remove-everything'); + // Nothing has happened yet. + expect((await rawStorage(page)).keyRecords).toBe(1); + await page.getByRole('button', { name: 'Remove everything', exact: true }).last().click(); + + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + await expectStoreGone(page); + await expectSignedOut(page); + await expect.poll(async () => (await devicesOf(admin, ENT.bob)).find((row) => row.id === device.id)?.wipeReason, { timeout: 30_000 }).toBe('user'); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('an account without offline mode has no row', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + const page = await userPage(browser, ENT.carol); + await page.waitForTimeout(3_000); + expect(await devicesOf(admin, ENT.carol)).toEqual([]); + await Promise.all([page.context().close(), admin.close()]); +}); diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index c0d5edf9a..b0c07c7ec 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -243,6 +243,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ sections: [ { id: 'devices', title: 'Downloads, printing and notifications', keywords: 'download attachment export print watermark install prompt push sender subject' }, { id: 'offline', title: 'Offline mode', keywords: 'offline cache device keep mail outbox' }, + { id: 'registered', title: 'Registered devices', keywords: 'device registry wipe remote lost stolen laptop remove desktop app browser' }, ], }, { diff --git a/lib/devices/__tests__/client.test.ts b/lib/devices/__tests__/client.test.ts new file mode 100644 index 000000000..6be4a6225 --- /dev/null +++ b/lib/devices/__tests__/client.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkInWithInstance, describePlatform, registerWithInstance, reportWipeToInstance } from '../client'; + +const record = { id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, secret: 'c'.repeat(64) }; + +function answer(status: number, body: string, type = 'application/json'): void { + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status, headers: { 'Content-Type': type } }))); +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('a check-in', () => { + it('hears a wipe only from a clear yes', async () => { + answer(200, JSON.stringify({ wipe: { requestedAt: '2026-11-03T00:00:00Z' } })); + expect(await checkInWithInstance(record)).toBe('wipe'); + }); + + it('reads no wipe as clear', async () => { + answer(200, JSON.stringify({ wipe: null })); + expect(await checkInWithInstance(record)).toBe('clear'); + }); + + it('reads the route\'s own 404 as a forgotten row', async () => { + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await checkInWithInstance(record)).toBe('forgotten'); + }); + + it.each([ + ['an older instance\'s 404', 404, 'Not found', 'text/html'], + ['a 500', 500, JSON.stringify({ error: 'Internal server error' }), 'application/json'], + ['a 503', 503, 'Bad gateway', 'text/html'], + ['a 200 from something else', 200, 'Login', 'text/html'], + ['a 200 with other JSON', 200, JSON.stringify({ ok: true }), 'application/json'], + ['a 401', 401, JSON.stringify({ wipe: { requestedAt: 'x' } }), 'application/json'], + ])('reads %s as nothing', async (_name, status, body, type) => { + answer(status, body, type); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); + + it('reads no network as nothing', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); +}); + +describe('registering', () => { + it('names the account\'s cookie slot', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(record), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + expect(await registerWithInstance(3)).toEqual(record); + const init = (fetchMock.mock.calls as unknown as [string, RequestInit][])[0][1]; + expect((init.headers as Record)['X-JMAP-Cookie-Slot']).toBe('3'); + }); + + it('gives null when it can\'t', async () => { + answer(401, '{}'); + expect(await registerWithInstance(0)).toBeNull(); + }); +}); + +describe('reporting a wipe', () => { + const entry = { ...record, reason: 'admin' as const, unsent: 1 }; + + it('is done on a yes, and when the row is gone', async () => { + answer(200, JSON.stringify({ ok: true })); + expect(await reportWipeToInstance(entry)).toBe(true); + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await reportWipeToInstance(entry)).toBe(true); + }); + + it('tries again after anything else', async () => { + answer(500, '{}'); + expect(await reportWipeToInstance(entry)).toBe(false); + answer(404, ''); + expect(await reportWipeToInstance(entry)).toBe(false); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await reportWipeToInstance(entry)).toBe(false); + }); +}); + +describe('what a row calls the device', () => { + it('names the desktop app', () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + expect(describePlatform()).toBe('Desktop app, Windows'); + }); + + it('names a browser', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0 Safari/537.36'); + expect(describePlatform()).toBe('Chrome, Windows'); + }); +}); diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts new file mode 100644 index 000000000..5c2afe014 --- /dev/null +++ b/lib/devices/__tests__/registry.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared'; +import { setStoreForTests } from '@/lib/shared-store'; + +type Registry = typeof import('../registry'); + +let dir: string; +const info = { platform: 'Chrome, Windows', appVersion: '1.2.3' }; + +/** A second server process: its own copy of every module, the one store in common. */ +async function instance(shared: ReturnType): Promise { + vi.resetModules(); + const store = await import('@/lib/shared-store'); + store.setStoreForTests(shared); + return import('../registry'); +} + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-devices-')); + vi.stubEnv('ADMIN_STATE_DIR', dir); +}); + +afterEach(() => { + setStoreForTests(null); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('two server instances sharing the store', () => { + it('see one registry: a wipe asked on one reaches the device through the other, and the confirmation comes back', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + expect(two).not.toBe(one); + + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + const listed = await two.listDevices(); + expect(listed.total).toBe(1); + expect(listed.devices[0]).toMatchObject({ id, user: 'alice@example.org', platform: 'Chrome, Windows', appVersion: '1.2.3', wipe: null }); + + expect((await one.checkInDevice(id, secret, info))?.wipe).toBeNull(); + await two.requestDeviceWipe(id, 'admin'); + expect((await one.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + + await one.confirmDeviceWipe(id, secret, 'admin'); + const after = (await two.listDevices()).devices[0]; + expect(after.wipe).toBeNull(); + expect(after.wipedAt).not.toBeNull(); + expect(after.wipeReason).toBe('admin'); + }); + + it('keeps writes from both when they answer at once', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + await Promise.all([ + two.requestDeviceWipe(id, 'admin'), + one.checkInDevice(id, secret, { ...info, appVersion: '1.2.4' }), + ]); + const device = await two.getDevice(id); + expect(device?.wipe).toMatchObject({ by: 'admin' }); + expect(device?.appVersion).toBe('1.2.4'); + }); +}); + +describe('a local store', () => { + it('keeps a pending wipe through a restart, in the state dir, with no expiry', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(registry.DEVICES_COLLECTION.ttlMs).toBeUndefined(); + + // A new process reads the file. + vi.resetModules(); + const restarted: Registry = await import('../registry'); + expect((await restarted.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + expect(readdirSync(path.join(dir, 'devices'), { recursive: true }).length).toBeGreaterThan(0); + }); + + it('stores only a hash of the secret, and never shows that either', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const file = readFileSync(path.join(dir, 'devices', `${id}.json`), 'utf8'); + expect(file).not.toContain(secret); + expect(JSON.stringify(await registry.listDevices())).not.toContain('secretHash'); + }); +}); + +describe('the rules', () => { + it('a wrong secret reads as no such device and changes nothing', async () => { + const registry: Registry = await import('../registry'); + const { id } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(await registry.checkInDevice(id, 'f'.repeat(64), info)).toBeNull(); + expect(await registry.confirmDeviceWipe(id, 'f'.repeat(64), 'admin')).toBeNull(); + expect((await registry.getDevice(id))?.wipe).not.toBeNull(); + }); + + it('a device that never answers keeps its pending wipe, also past the age where others go', async () => { + const registry: Registry = await import('../registry'); + const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const idle = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + + const later = Date.now() + registry.DEVICE_PRUNE_MS + 1000; + const { devices } = await registry.listDevices({ now: later }); + expect(devices.map((device) => device.id)).toEqual([waiting.id]); + expect(await registry.getDevice(idle.id)).toBeNull(); + expect((await registry.getDevice(waiting.id))?.wipe).not.toBeNull(); + }); + + it('one user\'s wipe reaches all of that user\'s devices and no one else\'s', async () => { + const registry: Registry = await import('../registry'); + const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const second = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const other = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + + const asked = await registry.requestUserWipe(first.id.split('/')[0], 'admin'); + expect(asked.map((device) => device.id).sort()).toEqual([first.id, second.id].sort()); + expect((await registry.getDevice(other.id))?.wipe).toBeNull(); + }); + + it('asks only once, and not of a device that already confirmed', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.requestDeviceWipe(id, 'admin')).not.toBeNull(); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + }); + + it('a wipe taken back no longer reaches the device', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + await registry.cancelDeviceWipe(id); + expect((await registry.checkInDevice(id, secret, info))?.wipe).toBeNull(); + }); + + it('a check-in soon after the last one doesn\'t write again, a later one does', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const first = (await registry.getDevice(id))!.lastSeen; + const soon = await registry.checkInDevice(id, secret, info, Date.parse(first) + 1000); + expect(soon?.lastSeen).toBe(first); + const later = await registry.checkInDevice(id, secret, info, Date.parse(first) + 60 * 60 * 1000); + expect(later?.lastSeen).not.toBe(first); + }); + + it('caps a user\'s rows by dropping the oldest without a wipe waiting', async () => { + const registry: Registry = await import('../registry'); + const oldest = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(oldest.id, 'admin'); + const next = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + for (let i = 2; i < registry.MAX_DEVICES_PER_USER; i += 1) { + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + } + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const ids = (await registry.listDevices({ limit: 200 })).devices.map((device) => device.id); + expect(ids).toHaveLength(registry.MAX_DEVICES_PER_USER); + expect(ids).toContain(oldest.id); + expect(ids).not.toContain(next.id); + }); + + it('finds a user by part of the address', async () => { + const registry: Registry = await import('../registry'); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + const { devices, total } = await registry.listDevices({ query: 'ALICE' }); + expect(total).toBe(1); + expect(devices[0].user).toBe('alice@example.org'); + }); +}); diff --git a/lib/devices/client.ts b/lib/devices/client.ts new file mode 100644 index 000000000..160f6a2d1 --- /dev/null +++ b/lib/devices/client.ts @@ -0,0 +1,90 @@ +/** + * This device's calls to the instance's registry. Only a clear answer counts: + * a wipe needs a 200 with a `wipe` in it, and "forgotten" needs the route's own + * word. An older instance without the routes, a proxy's error page or no + * network at all reads as `unavailable`, and nothing happens. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DeviceRecord } from '@/lib/store/client/keys'; +import type { WipeConfirmation } from './wipe-state'; + +export type CheckInAnswer = 'wipe' | 'clear' | 'forgotten' | 'unavailable'; + +/** Short enough for one row in a list. Nothing identifying beyond what a browser sends anyway. */ +export function describePlatform(): string { + const bridge = getDesktopBridge(); + if (bridge) return `Desktop app, ${({ win32: 'Windows', darwin: 'macOS', linux: 'Linux' } as const)[bridge.platform] ?? bridge.platform}`; + if (typeof navigator === 'undefined') return ''; + const ua = navigator.userAgent; + const os = /Android/.test(ua) ? 'Android' + : /iPhone|iPad/.test(ua) ? 'iOS' + : /Windows/.test(ua) ? 'Windows' + : /Mac OS X/.test(ua) ? 'macOS' + : /Linux/.test(ua) ? 'Linux' + : 'Unknown system'; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + return `${browser}, ${os}`; +} + +function deviceInfo() { + return { platform: describePlatform(), appVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? '' }; +} + +async function readJson(response: Response): Promise | null> { + try { + const body = await response.clone().json() as unknown; + return body && typeof body === 'object' ? body as Record : null; + } catch { + return null; + } +} + +function post(path: string, body: unknown, signal?: AbortSignal, headers: Record = {}): Promise { + return apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + signal, + }); +} + +/** A new row, for the account signed in on this cookie slot. Null if that can't be done now. */ +export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise<{ id: string; secret: string } | null> { + try { + const response = await post('/api/devices', deviceInfo(), signal, { 'X-JMAP-Cookie-Slot': String(slot) }); + if (!response.ok) return null; + const body = await readJson(response); + return typeof body?.id === 'string' && typeof body.secret === 'string' ? { id: body.id, secret: body.secret } : null; + } catch { + return null; + } +} + +export async function checkInWithInstance(record: Pick, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices', { id: record.id, secret: record.secret, ...deviceInfo() }, signal); + const body = await readJson(response); + if (response.status === 404) return body?.error === 'unknown_device' ? 'forgotten' : 'unavailable'; + if (!response.ok || !body || !('wipe' in body)) return 'unavailable'; + return body.wipe && typeof body.wipe === 'object' ? 'wipe' : 'clear'; + } catch { + return 'unavailable'; + } +} + +/** True when there's nothing more to say: it was taken, or the row no longer exists. */ +export async function reportWipeToInstance(entry: WipeConfirmation, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices/wiped', entry, signal); + if (response.ok) return true; + return response.status === 404 && (await readJson(response))?.error === 'unknown_device'; + } catch { + return false; + } +} diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts new file mode 100644 index 000000000..b7cea30b9 --- /dev/null +++ b/lib/devices/registry.ts @@ -0,0 +1,231 @@ +/** + * The devices that keep mail for this instance: browsers with offline mode on + * and desktop apps. One durable document per device and account, so every + * replica sees the same list and a pending wipe outlives a restart. + * + * No `ttlMs`: a machine that stays off for a year still has its wipe waiting. + * Old documents without a pending wipe go through `pruneDevices`. + * + * A check-in proves itself with the device's secret, not with a session, and + * nothing here asks the mail server. A device whose password was revoked has + * to be able to learn about its wipe. The secret only lets a device read and + * answer for its own row. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { getStatePath } from '@/lib/admin/paths'; +import { settingsId } from '@/lib/settings-sync'; +import { getStore, type CollectionOptions } from '@/lib/shared-store'; +import type { DeviceView, WipeReason } from './types'; + +export const DEVICE_ID_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}$/; +export const DEVICE_SECRET_RE = /^[0-9a-f]{64}$/; +const USER_KEY_RE = /^[0-9a-f]{64}$/; +const DEVICE_FILE_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}\.json$/; + +/** A user with more rows than this loses the oldest one that has no wipe waiting. */ +export const MAX_DEVICES_PER_USER = 100; +/** Not seen for this long and no wipe waiting: forgotten by the next admin listing. */ +export const DEVICE_PRUNE_MS = 180 * 24 * 60 * 60 * 1000; +/** A check-in within this time of the last write doesn't write again. */ +const LAST_SEEN_WRITE_MS = 5 * 60 * 1000; +const READ_CONCURRENCY = 50; + +export const DEVICES_COLLECTION: CollectionOptions = { + local: { + dir: () => getStatePath('devices'), + file: (id) => `${id}.json`, + idOf: (relative) => (DEVICE_FILE_RE.test(relative) ? relative.slice(0, -'.json'.length) : null), + mode: 0o600, + }, +}; + +interface StoredDevice extends Omit { + secretHash: string; +} + +function collection() { + return getStore().collection('devices', DEVICES_COLLECTION); +} + +function hashSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + +function secretMatches(device: StoredDevice, secret: string): boolean { + const given = Buffer.from(hashSecret(secret)); + const stored = Buffer.from(device.secretHash); + return given.length === stored.length && timingSafeEqual(given, stored); +} + +function parse(data: Buffer | null | undefined): StoredDevice | null { + if (!data) return null; + try { + return JSON.parse(data.toString('utf8')) as StoredDevice; + } catch { + return null; + } +} + +function toView(id: string, device: StoredDevice): DeviceView { + const { secretHash: _secretHash, ...rest } = device; + return { id, userKey: id.split('/')[0], ...rest }; +} + +async function readDevice(id: string): Promise { + return parse((await collection().get(id))?.data); +} + +async function readMany(ids: string[]): Promise> { + const found: Array<[string, StoredDevice]> = []; + for (let start = 0; start < ids.length; start += READ_CONCURRENCY) { + const batch = ids.slice(start, start + READ_CONCURRENCY); + const devices = await Promise.all(batch.map((id) => readDevice(id))); + devices.forEach((device, index) => { + if (device) found.push([batch[index], device]); + }); + } + return found; +} + +async function makeRoomFor(userKey: string): Promise { + const ids = await collection().list(`${userKey}/`); + if (ids.length < MAX_DEVICES_PER_USER) return; + const removable = (await readMany(ids)) + .filter(([, device]) => !device.wipe) + .sort(([, a], [, b]) => a.lastSeen.localeCompare(b.lastSeen)); + for (const [id] of removable.slice(0, ids.length - MAX_DEVICES_PER_USER + 1)) await collection().delete(id); +} + +export interface DeviceInfo { + platform: string; + appVersion: string; +} + +/** A new row for this browser profile and account. The secret goes back to the device once and is kept only as a hash. */ +export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string }> { + const userKey = settingsId(user, server); + await makeRoomFor(userKey); + const id = `${userKey}/${randomBytes(16).toString('hex')}`; + const secret = randomBytes(32).toString('hex'); + const now = new Date().toISOString(); + const device: StoredDevice = { + user, + server, + platform: info.platform, + appVersion: info.appVersion, + firstSeen: now, + lastSeen: now, + wipe: null, + wipedAt: null, + wipeReason: null, + secretHash: hashSecret(secret), + }; + await collection().put(id, JSON.stringify(device), { ifVersion: null }); + return { id, secret }; +} + +/** Null when the row is gone or the secret doesn't fit. Both read the same to the caller. */ +export async function checkInDevice(id: string, secret: string, info: DeviceInfo, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const stale = now - Date.parse(current.lastSeen) >= LAST_SEEN_WRITE_MS; + if (!stale && current.platform === info.platform && current.appVersion === info.appVersion) return toView(id, current); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device) return undefined; + return JSON.stringify({ ...device, ...info, lastSeen: new Date(now).toISOString() }); + }); + const device = parse(data); + return device ? toView(id, device) : toView(id, current); +} + +/** The device says it wiped. Null when the row is gone or the secret doesn't fit. */ +export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const at = new Date(now).toISOString(); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device) return undefined; + return JSON.stringify({ ...device, wipe: null, wipedAt: at, wipeReason: reason, lastSeen: at }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +/** Null when nothing changed: no such row, a wipe already waiting, or a row that confirmed and has nothing left. */ +export async function requestDeviceWipe(id: string, by: string, now = Date.now()): Promise { + const requestedAt = new Date(now).toISOString(); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device || device.wipedAt || device.wipe) return undefined; + return JSON.stringify({ ...device, wipe: { requestedAt, by } }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +/** Every device of a user that hasn't wiped already, one request each. */ +export async function requestUserWipe(userKey: string, by: string, now = Date.now()): Promise { + const ids = await collection().list(`${userKey}/`); + const results = await Promise.all(ids.map((id) => requestDeviceWipe(id, by, now))); + return results.filter((device): device is DeviceView => device !== null); +} + +export async function cancelDeviceWipe(id: string): Promise { + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device?.wipe) return undefined; + return JSON.stringify({ ...device, wipe: null }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +export async function forgetDevice(id: string): Promise { + return collection().delete(id); +} + +export async function getDevice(id: string): Promise { + const device = await readDevice(id); + return device ? toView(id, device) : null; +} + +export function isUserKey(value: unknown): value is string { + return typeof value === 'string' && USER_KEY_RE.test(value); +} + +export interface DevicePage { + devices: DeviceView[]; + total: number; +} + +/** One page, by user then device. `query` looks through every row, so it reads them all. */ +export async function listDevices(options: { offset?: number; limit?: number; query?: string; now?: number } = {}): Promise { + const { offset = 0, limit = 100, query, now = Date.now() } = options; + const ids = await collection().list(''); + const needle = query?.trim().toLowerCase(); + if (!needle) { + const page = await readMany(ids.slice(offset, offset + limit)); + const kept = await pruned(page, now); + return { devices: kept.map(([id, device]) => toView(id, device)), total: ids.length }; + } + const everything = await pruned(await readMany(ids), now); + const matching = everything.filter(([, device]) => device.user.toLowerCase().includes(needle)); + return { + devices: matching.slice(offset, offset + limit).map(([id, device]) => toView(id, device)), + total: matching.length, + }; +} + +/** What the admin is about to see: old rows go, a row with a wipe waiting never does. */ +async function pruned(rows: Array<[string, StoredDevice]>, now: number): Promise> { + const kept: Array<[string, StoredDevice]> = []; + for (const row of rows) { + const [id, device] = row; + if (!device.wipe && now - Date.parse(device.lastSeen) > DEVICE_PRUNE_MS) await collection().delete(id); + else kept.push(row); + } + return kept; +} diff --git a/lib/devices/types.ts b/lib/devices/types.ts new file mode 100644 index 000000000..367b6bef7 --- /dev/null +++ b/lib/devices/types.ts @@ -0,0 +1,30 @@ +/** + * The device registry as both sides see it: what the instance lists for its + * admin and what a device says about its own wipe. No secrets, no store. + */ + +/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */ +export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const; +export type WipeReason = (typeof WIPE_REASONS)[number]; + +export interface DeviceView { + /** `/`. */ + id: string; + /** The first part of `id`: every device of this user on this server. */ + userKey: string; + user: string; + server: string; + platform: string; + appVersion: string; + firstSeen: string; + lastSeen: string; + /** Asked for and not confirmed yet. Stays until the device answers or the admin takes it back. */ + wipe: { requestedAt: string; by: string } | null; + wipedAt: string | null; + wipeReason: WipeReason | null; +} + +export const AUDIT_DEVICE_WIPE_REQUEST = 'device.wipe.request'; +export const AUDIT_DEVICE_WIPE_CANCEL = 'device.wipe.cancel'; +export const AUDIT_DEVICE_WIPE_CONFIRM = 'device.wipe.confirm'; +export const AUDIT_DEVICE_FORGET = 'device.forget'; diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts new file mode 100644 index 000000000..72ab7b653 --- /dev/null +++ b/lib/devices/wipe-state.ts @@ -0,0 +1,135 @@ +/** + * What a wipe has to remember in localStorage, in a store-free module the auth + * store can import. Nothing here names a subject, and what stays after a wipe + * is a count, a row id and the secret for that one row. + */ + +import type { WipeReason } from './types'; + +const PENDING_KEY = 'bulwark-wipe-pending'; +const CONFIRM_KEY = 'bulwark-wipe-confirm'; +const NOTE_KEY = 'bulwark-wipe-note'; +const REFUSED_KEY = 'bulwark-credentials-refused'; +const DATABASES_KEY = 'bulwark-wipe-databases'; + +/** The pending entry for a wipe of everything on this device. */ +export const WHOLE_DEVICE = '*'; + +/** + * A 401 after re-auth has to last this long before the login counts as refused. + * Two answers a moment apart can be one bad moment on the server. + */ +export const CREDENTIALS_REFUSED_CONFIRM_MS = 60_000; + +/** + * How long a refused login's data waits for a sign-in with the new password. + * Nothing but this 401 says the login is over, since a password login has no + * `invalid_grant`. A server being restored from a backup says the same for hours, + * and someone who changed their password elsewhere is about to type the new one. + */ +export const REFUSED_KEEP_MS = 24 * 60 * 60 * 1000; + +function readJson(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown, empty: boolean): void { + try { + if (empty) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage unavailable */ + } +} + +// ---- A wipe under way: finished at the next page load if this one dies ---- + +export function readPendingWipes(): Record { + return readJson>(PENDING_KEY, {}); +} + +export function markWipePending(account: string, reason: WipeReason): void { + writeJson(PENDING_KEY, { ...readPendingWipes(), [account]: reason }, false); +} + +export function clearWipePending(account: string): void { + const pending = readPendingWipes(); + delete pending[account]; + writeJson(PENDING_KEY, pending, Object.keys(pending).length === 0); +} + +// ---- Telling the instance: sent until it gets through ---- + +export interface WipeConfirmation { + id: string; + secret: string; + reason: WipeReason; + unsent: number; +} + +export function readWipeConfirmations(): WipeConfirmation[] { + const list = readJson(CONFIRM_KEY, []); + return Array.isArray(list) ? list : []; +} + +export function queueWipeConfirmation(entry: WipeConfirmation): void { + writeJson(CONFIRM_KEY, [...readWipeConfirmations().filter((other) => other.id !== entry.id), entry], false); +} + +export function dropWipeConfirmation(id: string): void { + const rest = readWipeConfirmations().filter((entry) => entry.id !== id); + writeJson(CONFIRM_KEY, rest, rest.length === 0); +} + +// ---- The note at the next sign-in: a count, never subjects ---- + +export function addWipeNote(unsent: number): void { + const before = readJson<{ unsent?: number }>(NOTE_KEY, {}); + writeJson(NOTE_KEY, { unsent: (before.unsent ?? 0) + unsent }, false); +} + +/** Once: whoever shows it takes it. Null when no wipe left a word. */ +export function consumeWipeNote(): { unsent: number } | null { + const note = readJson<{ unsent?: number } | null>(NOTE_KEY, null); + writeJson(NOTE_KEY, null, true); + return note && typeof note.unsent === 'number' ? { unsent: note.unsent } : null; +} + +// ---- A refused password: when the wait started ---- + +export function readRefusedLogins(): Record { + return readJson>(REFUSED_KEY, {}); +} + +export function markLoginRefused(account: string, now = Date.now()): void { + writeJson(REFUSED_KEY, { ...readRefusedLogins(), [account]: now }, false); +} + +export function dropRefusedLogin(account: string): void { + const refused = readRefusedLogins(); + delete refused[account]; + writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0); +} + +// ---- Plugin databases a running frame kept open: tried again at the next load ---- + +export function readBlockedDatabases(): Record { + return readJson>(DATABASES_KEY, {}); +} + +export function writeBlockedDatabases(blocked: Record): void { + writeJson(DATABASES_KEY, blocked, Object.keys(blocked).length === 0); +} + +/** Whether anything is left over from an earlier wipe, so a start must look. */ +export function wipeWorkWaiting(): boolean { + return Object.keys(readPendingWipes()).length > 0 + || readWipeConfirmations().length > 0 + || Object.keys(readRefusedLogins()).length > 0 + || Object.keys(readBlockedDatabases()).length > 0; +} diff --git a/lib/jmap/__tests__/credentials-refused.test.ts b/lib/jmap/__tests__/credentials-refused.test.ts new file mode 100644 index 000000000..928915ed7 --- /dev/null +++ b/lib/jmap/__tests__/credentials-refused.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +// A 401 after a fresh try at signing in is the one signal a password login has +// that its credentials are over. So what else looks like one must not count. + +const SERVER = 'https://mail.example.test'; +const problem = () => new Response(JSON.stringify({ type: 'about:blank', status: 401, title: 'Unauthorized', detail: 'You have to authenticate first.' }), { status: 401, headers: { 'Content-Type': 'application/problem+json' } }); +const html401 = () => new Response('Sign in to the proxy', { status: 401, headers: { 'Content-Type': 'text/html' } }); +const session = () => new Response(JSON.stringify({ + apiUrl: `${SERVER}/jmap`, downloadUrl: `${SERVER}/d`, uploadUrl: `${SERVER}/u`, eventSourceUrl: '', username: 'alice', + capabilities: { 'urn:ietf:params:jmap:core': {} }, accounts: { a: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, +}), { status: 200, headers: { 'Content-Type': 'application/json' } }); +const echo = () => new Response(JSON.stringify({ methodResponses: [['Core/echo', { ping: 'pong' }, '0']], sessionState: 's' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +let answers: { api: () => Response; session: () => Response }; + +function stubServer(): void { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/.well-known/jmap') ? answers.session() : answers.api()))); +} + +async function connectedBasic(): Promise<{ client: JMAPClient; refused: number[] }> { + answers = { session, api: echo }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + await client.connect(); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused }; +} + +beforeEach(() => { + vi.useRealTimers(); + stubServer(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('a password login', () => { + it('counts when the session URL refuses too', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('counts when the session answers and the retry is refused', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('doesn\'t count when the session URL gives a 500: that says nothing about the password', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: () => new Response('oops', { status: 500 }) }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count when the network breaks on the way', async () => { + const { client, refused } = await connectedBasic(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + if (String(input).includes('/.well-known/jmap')) throw new TypeError('Failed to fetch'); + return problem(); + })); + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a proxy\'s page', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: html401, session: html401 }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a plain 500, or a 403', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: () => new Response('nope', { status: 500 }), session }; + await client.ping().catch(() => {}); + answers = { api: () => new Response(JSON.stringify({ status: 403 }), { status: 403 }), session }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('counts at connect, so a restore can tell', async () => { + answers = { api: echo, session: problem }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow('Invalid username or password'); + expect(refused).toHaveLength(1); + }); + + it('never counts with a one-time code: those go stale by themselves', async () => { + const { client, refused } = await connectedBasic(); + client.enableTotpReauth('secret', async () => null); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('measures from the first refusal, and a good answer starts it over', async () => { + const { client, refused } = await connectedBasic(); + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(1_000_000); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + now.mockReturnValue(1_090_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000]); + + answers = { api: echo, session }; + now.mockReturnValue(1_100_000); + await client.ping(); + answers = { api: problem, session: problem }; + now.mockReturnValue(1_200_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000, 0]); + client.disconnect(); + }); +}); + +describe('a token login', () => { + function bearer(renewed: string | null): { client: JMAPClient; refused: number[]; renew: ReturnType } { + const renew = vi.fn(async () => renewed); + const client = JMAPClient.withBearer(SERVER, 'old', 'alice', renew); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused, renew }; + } + + it('counts when a token issued just now is refused', async () => { + answers = { api: problem, session: problem }; + const { client, refused, renew } = bearer('new'); + await expect(client.connect()).rejects.toThrow(); + expect(renew).toHaveBeenCalled(); + expect(refused).toHaveLength(1); + }); + + it('doesn\'t count when no new token came: the renewal has its own rules', async () => { + answers = { api: problem, session: problem }; + const { client, refused } = bearer(null); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toEqual([]); + }); + + it('counts for a pasted token, which nothing can renew', async () => { + answers = { api: problem, session: problem }; + const client = JMAPClient.withBearer(SERVER, 'pasted', 'alice'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toHaveLength(1); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 487b060e7..766631e37 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -171,6 +171,12 @@ export interface IJMAPClient { hasSSEStream(): boolean; onConnectionChange(callback: (connected: boolean) => void): void; onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void; + /** + * The mail server itself answered 401 after a fresh try at signing in, with + * `refusedForMs` since it first did with no good answer in between. Not called + * for an outage, a proxy's page or a token renewal that failed. + */ + onCredentialsRefused?(callback: (refusedForMs: number) => void): void; isRateLimited(): boolean; getRateLimitRemainingMs(): number; onStateChange(callback: (change: StateChange) => void): void; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1996dab17..ffe3b7c4d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1620,6 +1620,9 @@ export class JMAPClient implements IJMAPClient { private lastStates: AccountStates = {}; private reconnecting = false; private connectionChangeCallback: ((connected: boolean) => void) | null = null; + private credentialsRefusedCallback: ((refusedForMs: number) => void) | null = null; + /** When the server first refused us with no good answer since; 0 when it hasn't. */ + private refusedSince = 0; private rateLimitedUntil: number = 0; private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null; private rateLimitTimeout: NodeJS.Timeout | null = null; @@ -1907,6 +1910,7 @@ export class JMAPClient implements IJMAPClient { throw new RateLimitError(retryAfterMs); } + let reauthRefused = false; if (response.status === 401) { if (this.authMode === 'bearer' && this.onTokenRefresh) { const newToken = await this.onTokenRefresh(); @@ -1914,7 +1918,13 @@ export class JMAPClient implements IJMAPClient { this.updateAccessToken(newToken); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); + // A token issued just now was refused. No new token says nothing: + // the renewal has its own rules for that. + reauthRefused = true; } + } else if (this.authMode === 'bearer') { + // A pasted token: there's nothing to renew it with. + reauthRefused = true; } else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) { // JMAP session may have expired - re-establish and retry once this.reconnecting = true; @@ -1923,7 +1933,11 @@ export class JMAPClient implements IJMAPClient { this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); - } catch { + reauthRefused = true; + } catch (refreshError) { + // A 500 or a broken connection on the way says nothing against the + // password. Only the session URL's own 401 does. + reauthRefused = refreshError instanceof Error && refreshError.message === 'Session refresh failed: 401'; // Session refresh failed - if TOTP was used, try re-auth with fresh TOTP if (this.onTotpRequired && this.basePassword) { try { @@ -1945,10 +1959,34 @@ export class JMAPClient implements IJMAPClient { } } - if (response.status !== 401) this.authAcceptedAt = Date.now(); + if (response.status !== 401) { + this.authAcceptedAt = Date.now(); + this.refusedSince = 0; + } else if (reauthRefused && !response.redirected) { + await this.noteCredentialsRefused(response); + } return response; } + onCredentialsRefused(callback: (refusedForMs: number) => void): void { + this.credentialsRefusedCallback = callback; + } + + /** + * Only the mail server's own refusal counts: JMAP errors are problem documents + * (RFC 8620 section 3.6.1). A proxy or a login page in front of the server + * answers with something else, and says nothing about these credentials. + * A password with a one-time code goes stale by itself, so it never counts. + */ + private async noteCredentialsRefused(response: Response): Promise { + if (this.onTotpRequired || this.basePassword) return; + const body = await response.clone().json().catch(() => null) as { status?: unknown } | null; + if (body?.status !== 401) return; + const now = Date.now(); + if (!this.refusedSince) this.refusedSince = now; + this.credentialsRefusedCallback?.(now - this.refusedSince); + } + /** * Fetch the JMAP session, transparently handling servers that redirect * /.well-known/jmap to a canonical session URL (e.g. Stalwart → /jmap/session). @@ -2015,6 +2053,8 @@ export class JMAPClient implements IJMAPClient { if (!sessionResponse.ok) { if (sessionResponse.status === 401) { + // A token's refusal was counted on the way here, if it counts at all. + if (this.authMode === 'basic' && !sessionResponse.redirected) await this.noteCredentialsRefused(sessionResponse); throw new Error(this.authMode === 'bearer' ? 'Authentication failed - token may be expired' : 'Invalid username or password'); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts new file mode 100644 index 000000000..a0bac109f --- /dev/null +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -0,0 +1,593 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// What a wipe removes and what makes one happen. Real auth store, real offline +// mode, the store's real engine, real IndexedDB (fake-indexeddb): only the +// network is a stub, and the OPFS pool, which jsdom doesn't have. + +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) })); +const purge = vi.hoisted(() => ({ files: 0 })); +vi.mock('@/lib/store/client/purge', async () => { + const keys = await vi.importActual('@/lib/store/client/keys'); + return { purgeStoreFiles: async () => { purge.files += 1; keys.deleteKeyDatabase(); }, deleteStorePool: async () => {} }; +}); + +// A store and a full sign-out in each test: slow on a machine doing other work. +vi.setConfig({ testTimeout: 60_000 }); + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; +const ROW = `${'a'.repeat(64)}/${'b'.repeat(32)}`; +const SECRET = 'c'.repeat(64); +const BOB_ROW = `${'d'.repeat(64)}/${'e'.repeat(32)}`; + +let wipe: typeof import('../device-wipe'); +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let state: typeof import('../state'); +let keys: typeof import('@/lib/store/client/keys'); +let wipeState: typeof import('@/lib/devices/wipe-state'); +let rejected: typeof import('../credentials-rejected'); +let registry: typeof import('../wipe-registry'); +let queue: typeof import('../queue'); +let store: import('@/lib/store/__tests__/helpers/in-process-store').InProcessStore; + +type Respond = (url: string, init?: RequestInit) => Response | Promise; +let respond: Respond; +let sent: Array<{ url: string; body: Record; headers: Record }>; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +const noWipe = () => json(200, { wipe: null }); +const wipeNow = () => json(200, { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } }); + +function entry(id: string, cookieSlot: number) { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode: 'basic', rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: cookieSlot === 0, cookieSlot, avatarColor: '#000', + }; +} + +function openDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { request.result.createObjectStore('s'); }; + request.onsuccess = () => { request.result.close(); resolve(); }; + request.onerror = () => reject(request.error); + }); +} + +async function databases(): Promise { + return (await indexedDB.databases()).map((database) => database.name ?? '').sort(); +} + +async function freshIndexedDb(): Promise { + for (const { name } of await indexedDB.databases()) { + if (name) await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = () => resolve(null); }); + } +} + +beforeEach(async () => { + vi.resetModules(); + purge.files = 0; + localStorage.clear(); + sessionStorage.clear(); + await freshIndexedDb(); + sent = []; + respond = () => noWipe(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/devices')) sent.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {}, headers: (init?.headers ?? {}) as Record }); + return respond(url, init); + })); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + + ({ useAuthStore } = await import('@/stores/auth-store')); + ({ useAccountStore } = await import('@/stores/account-store')); + state = await import('../state'); + keys = await import('@/lib/store/client/keys'); + wipeState = await import('@/lib/devices/wipe-state'); + rejected = await import('../credentials-rejected'); + registry = await import('../wipe-registry'); + queue = await import('../queue'); + wipe = await import('../device-wipe'); + const browserNavigation = await import('@/lib/browser-navigation'); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + const { InProcessStore } = await import('@/lib/store/__tests__/helpers/in-process-store'); + const { setStoreBackend } = await import('@/lib/store/client/api'); + store = new InProcessStore(); + setStoreBackend(store); + + useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: ALICE, authMode: 'basic', serverUrl: 'https://mail.example.test', username: 'alice' }); + state.writeEnabled([ALICE]); + await keys.openAccountKey(ALICE); + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); +}, 60_000); + +/** The wipe goes on behind the start's budget, and takes its time under load. */ +async function waitForNoAccounts(): Promise { + await vi.waitFor(() => expect(useAccountStore.getState().accounts).toEqual([]), { timeout: 20_000 }); +} + +function expectUntouched(): void { + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + expect(wipeState.readPendingWipes()).toEqual({}); +} + +describe('wipe on the next connect', () => { + beforeEach(() => { + respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : wipeNow()); + }); + + it('removes the store and every key, signs out, and tells the instance', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + expect(purge.files).toBeGreaterThan(0); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).not.toContain('bulwark-store'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + const report = sent.find((call) => call.url.endsWith('/api/devices/wiped')); + expect(report?.body).toMatchObject({ id: ROW, secret: SECRET, reason: 'admin', unsent: 1 }); + expect(wipeState.readWipeConfirmations()).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('notes the count for the next sign-in, and no subject anywhere', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(JSON.stringify({ ...localStorage })).not.toContain('Secret plans'); + expect(JSON.stringify({ ...sessionStorage })).not.toContain('Secret plans'); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 1 }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('leaves a note with no count when nothing was waiting', async () => { + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); + }); + + it('deletes S/MIME and OpenPGP keys by name, plugin data and handoffs, keeping the plugins themselves', async () => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) await openDb(name); + const { fileStorage } = await import('@/lib/plugin-storage'); + const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue(); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'mine'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', managed: true, settings: { apiKey: 'k' } }] as never }); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + const left = await databases(); + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) expect(left).not.toContain(name); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull(); + expect(localStorage.getItem('notification-center') ?? '[]').toBe('[]'); + expect(usePluginStore.getState().plugins[0].settings).toEqual({}); + expect(clearFiles).toHaveBeenCalled(); + expect(clearHandoffs).toHaveBeenCalled(); + }); + + it('runs what features registered, edit-in-place\'s files for one, with the account', async () => { + const cleaned: Array = []; + registry.onDeviceWipe(async (account) => { cleaned.push(account); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(cleaned).toEqual([ALICE]); + }); + + it('a feature that fails or hangs doesn\'t stop the wipe', async () => { + registry.onDeviceWipe(() => { throw new Error('no'); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('only takes the account it was told, and keeps the other\'s store and row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + expect(store.removed).toContain(ALICE); + expect(store.removed).not.toContain(BOB); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('after the last of several accounts goes, the rest keep their row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + // Bob keeps nothing here but has a row: the desktop app, say. + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('reaches an account dropped earlier, whose data waits and which has no cookies', async () => { + useAccountStore.setState({ accounts: [] } as never); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null }); + state.markEvicted(ALICE); + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(state.useOfflineStore.getState().enabled).toEqual([])); + expect(purge.files).toBeGreaterThan(0); + expect(state.isEvicted(ALICE)).toBe(false); + }); +}); + +describe('the report', () => { + it('goes out again at the next contact when it failed, with no account and no cookies', async () => { + let reportWorks = false; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return reportWorks ? json(200, { ok: true }) : json(503, {}); + return wipeNow(); + }; + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + + // A new page load, the sign-in page: everything else about the account is gone. + reportWorks = true; + await wipe.checkInDue(true); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([])); + const reports = sent.filter((call) => call.url.endsWith('/api/devices/wiped')); + expect(reports.length).toBeGreaterThanOrEqual(2); + expect(reports.at(-1)?.body).toMatchObject({ id: ROW, secret: SECRET }); + }); +}); + +describe('what never wipes', () => { + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503 page', () => new Response('Bad gateway', { status: 503 })], + ['an older instance with no such route', () => new Response('Not found', { status: 404 })], + ['a 401', () => json(401, { error: 'Not authenticated' })], + ['a 200 that says nothing', () => json(200, {})], + ])('%s', async (_name, answer) => { + respond = answer; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + }); + + it('no network, and the row keeps waiting at the instance: nothing was reported', async () => { + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + expect(sent.filter((call) => call.url.endsWith('/api/devices/wiped'))).toEqual([]); + }); + + it('a browser that says it\'s offline doesn\'t even ask', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + await wipe.runDeviceGate(); + vi.restoreAllMocks(); + expect(sent).toEqual([]); + expectUntouched(); + }); + + it('a forgotten row: this device registers anew and keeps its data', async () => { + respond = (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id?: string }; + return id ? json(404, { error: 'unknown_device' }) : json(200, { id: BOB_ROW, secret: SECRET }); + }; + await wipe.runDeviceGate(); + expectUntouched(); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + await wipe.checkInDue(true); + await vi.waitFor(async () => expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW)); + }); +}); + +describe('a start', () => { + it('waits for the answer, at most for its budget, and a late yes still wipes', async () => { + let release: (response: Response) => void = () => {}; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return new Promise((resolve) => { release = resolve; }); + }; + const started = Date.now(); + await wipe.runDeviceGate(); + const waited = Date.now() - started; + expect(waited).toBeGreaterThanOrEqual(wipe.BOOT_CHECK_IN_BUDGET_MS - 50); + expect(waited).toBeLessThan(wipe.BOOT_CHECK_IN_BUDGET_MS + 1500); + expectUntouched(); + + release(wipeNow()); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('finishes a wipe a closed window cut short, with no network and before anything else', async () => { + wipeState.markWipePending(ALICE, 'admin'); + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); +}); + +describe('registering', () => { + beforeEach(async () => { + await keys.deleteDeviceRecord(ALICE); + }); + + it('a browser with offline on registers, once, naming its cookie slot', async () => { + respond = () => json(200, { id: ROW, secret: SECRET }); + await Promise.all([wipe.checkInDue(true), wipe.checkInDue(true)]); + expect(sent).toHaveLength(1); + expect(sent[0].headers['X-JMAP-Cookie-Slot']).toBe('0'); + expect(await keys.getDeviceRecord(ALICE)).toMatchObject({ id: ROW, secret: SECRET }); + }); + + it('a browser with offline off doesn\'t', async () => { + state.writeEnabled([]); + await wipe.checkInDue(true); + expect(sent).toEqual([]); + expect(await databases()).toContain('bulwark-store'); // From this test's own setup. + }); + + it('a desktop app on an instance does, with offline off too', async () => { + state.writeEnabled([]); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = () => json(200, { id: ROW, secret: SECRET }); + await wipe.checkInDue(true); + expect(sent).toHaveLength(1); + }); + + it('the desktop app on its own has no instance to ask', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone' }; + await wipe.checkInDue(true); + expect(sent).toEqual([]); + }); + + it('a cleared key database means a new row', async () => { + respond = () => json(200, { id: ROW, secret: SECRET }); + await wipe.checkInDue(true); + keys.deleteKeyDatabase(); + respond = () => json(200, { id: BOB_ROW, secret: SECRET }); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); + }); + + it('a device id doesn\'t survive a wipe: the next sign-in is a new row', async () => { + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(await keys.listDeviceRecords()).toEqual([]); + }); + + it('isn\'t tried again every minute when it can\'t', async () => { + respond = () => json(401, {}); + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 16 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +describe('check-ins while running', () => { + it('come at most once an hour for a row', async () => { + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 61 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +describe('a refused login (lib/offline/credentials-rejected.ts)', () => { + it('takes everything beyond the store too, and tells the instance why', async () => { + await openDb('smime-plugin-store'); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE); + await vi.waitFor(() => expect(sent.some((call) => call.url.endsWith('/wiped'))).toBe(true)); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW }); + await vi.waitFor(async () => expect(await databases()).not.toContain('smime-plugin-store')); + // The auth store is on its way with its own sign-out. + expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + }); + + it('names the lost mail, as for any sign-out nobody asked for, and leaves no count note', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + const lost = await import('../lost'); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(lost.consumeLost()).toEqual(['Unsent']); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + describe('from a running renewal', () => { + let heard: string[]; + + beforeEach(() => { + heard = []; + rejected.onCredentialsRejected((account) => heard.push(account)); + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), authMode: 'oauth' }] } as never); + useAuthStore.setState({ authMode: 'oauth' }); + }); + + const token = (next: () => Response) => { respond = (url) => (url.includes('/api/auth/token') ? next() : noWipe()); }; + + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503', () => json(503, { error: 'unavailable' })], + // What the route answers for a 400 invalid_client from the server. + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ])('%s leaves the hook alone', async (_name, answer) => { + wipe.registerDeviceWipe(); + token(answer); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it.each([ + ['invalid_grant', 'invalid_grant'], + ['the session limit', 'session_expired'], + ])('%s fires it and wipes', async (_name, error) => { + wipe.registerDeviceWipe(); + token(() => json(401, { error, maxHours: 8 })); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + expect(heard).toEqual([ALICE]); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + }); + }); + + it('a password refused for a day goes, unless it signed in again', async () => { + const heard: string[] = []; + rejected.onCredentialsRejected((account) => heard.push(account)); + state.markEvicted(ALICE); + state.writeEnabled([ALICE]); + const at = Date.now(); + wipeState.markLoginRefused(ALICE, at); + + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS - 1000); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + + state.clearEvicted(ALICE); + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + expect(wipeState.readRefusedLogins()).toEqual({}); + + state.markEvicted(ALICE); + wipeState.markLoginRefused(ALICE, at); + wipe.registerDeviceWipe(); + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); + expect(heard).toEqual([ALICE]); + expect(purge.files).toBeGreaterThan(0); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); +}); + +describe('other windows', () => { + it('a window showing the wiped account reloads, one showing another stays, and the wiping window keeps going', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + const other = new BroadcastChannel('bulwark-device-wipe'); + + other.postMessage({ account: BOB }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + + other.postMessage({ account: ALICE }); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + other.close(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); + + it('the window that wipes doesn\'t hear its own message', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); +}); + +describe('what the desktop app keeps outside the page', () => { + const shell = { clearSurfaces: vi.fn(), clearClipboardFiles: vi.fn(async () => {}) }; + + beforeEach(() => { + shell.clearSurfaces.mockClear(); + shell.clearClipboardFiles.mockClear(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + os: { clearSurfaces: shell.clearSurfaces }, + clipboard: { clearFiles: shell.clearClipboardFiles }, + }; + }); + + it('goes with a remote wipe', async () => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('goes with a wipe from settings', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('a shell without those calls wipes all the same', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('a call that fails leaves the wipe going', async () => { + shell.clearClipboardFiles.mockRejectedValueOnce(new Error('held by a program')); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + }); +}); + +describe('the wipe from settings', () => { + it('takes everything, reports to the instance, and leaves no note: the user was there', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'user', id: ROW }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('marks that the user signed out, so a provider session doesn\'t sign them straight back in', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(sessionStorage.getItem('signed_out')).toBe('true'); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index a42282998..29c5c6015 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,9 +1,9 @@ /** * The server really refused this login (`invalid_grant`), which the auth store * follows with a sign-out that wipes the account's local data. An outage or any - * other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe - * registers here for what else a permanent rejection should do. Free of the - * stores, so the auth store can call it. + * other refusal doesn't come here, and keeps the data. lib/offline/device-wipe.ts + * registers here for everything beyond the local store. Free of the stores, so + * the auth store can call it. */ type Listener = (accountId: string) => void; diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts new file mode 100644 index 000000000..abf524982 --- /dev/null +++ b/lib/offline/device-wipe.ts @@ -0,0 +1,491 @@ +/** + * Everything that takes an account's data off this device, and how a device + * hears that it should: + * + * - the instance's registry (lib/devices): a wipe an admin asked for, heard at + * check-in. At a start the check-in runs before the local copy is shown. + * - the server refusing the stored login for good, through + * lib/offline/credentials-rejected.ts. + * - the user, from settings. + * + * Only a clear word wipes. An outage, a 500, an unknown answer: nothing happens. + * + * The desktop app's `WipeOfflineData` policy isn't here: its main process clears + * every partition itself (repos/desktop, src/main/features/local-wipe.ts), with no + * page taking part, so nothing a page runs could say it was done without doing it. + * + * Why a device id doesn't survive a wipe: the row keeps the history at the + * instance, and after a sign-in this is a device starting over, which the admin + * can wipe again. Keeping the id here would leave something of the account on a + * device that was told to remove it. What stays until the instance has heard is + * the row's id and secret, in lib/devices/wipe-state.ts. + * + * Accounts without cookies: a session cookie goes with the browser and the + * sealed one lasts 30 days, but a check-in only needs the secret, so a device + * still hears its wipe. One that lost its key database has nothing left to wipe + * and can't be told. Its row keeps saying the wipe is waiting. + */ + +import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { fileStorage } from '@/lib/plugin-storage'; +import { clearHandoffs } from '@/lib/launch-handoff'; +import { apiFetch } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { + checkInWithInstance, + registerWithInstance, + reportWipeToInstance, +} from '@/lib/devices/client'; +import type { WipeReason } from '@/lib/devices/types'; +import { + addWipeNote, + clearWipePending, + dropRefusedLogin, + dropWipeConfirmation, + markWipePending, + queueWipeConfirmation, + readBlockedDatabases, + readPendingWipes, + readRefusedLogins, + readWipeConfirmations, + REFUSED_KEEP_MS, + WHOLE_DEVICE, + writeBlockedDatabases, +} from '@/lib/devices/wipe-state'; +import { + deleteDeviceRecord, + getDeviceRecord, + listDeviceRecords, + putDeviceRecord, + type DeviceRecord, +} from '@/lib/store/client/keys'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { noteCredentialsRejected, onCredentialsRejected } from './credentials-rejected'; +import { confirmLoss, registerOfflineHooks, unsentCount, wipeAccount, wipeEverything } from './offline-mode'; +import { clearEvicted, isEvicted, readEvicted, useOfflineStore } from './state'; +import { runWipeCleaners } from './wipe-registry'; + +/** How long a start holds the local copy back for the instance's answer. Offline, it doesn't wait at all. */ +export const BOOT_CHECK_IN_BUDGET_MS = 1_000; +/** A check-in of each device row, at most this often. */ +export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; +/** How often a running page looks whether something is due. */ +const TICK_MS = 60 * 1000; +/** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ +const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for one database a running plugin frame holds open. */ +const DATABASE_BUDGET_MS = 2_000; +const DATABASE_RETRIES = 5; + +const WIPE_CHANNEL = 'bulwark-device-wipe'; + +/** The webmail's own databases. Any other on this origin was made by a privileged plugin. */ +const APP_DATABASES = new Set([ + 'bulwark-store', + 'bulwark-plugins', + 'bulwark-company-calendars', + 'bulwark-mail-import', + 'bulwark-offline', +]); +/** Deleted by name too, for browsers without `indexedDB.databases()`. */ +const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe']; + +/** What every account shared and can't be told apart by account. */ +const SHARED_LOCAL_KEYS = ['notification-center', 'plugin-oauth-states', 'webmail_usernames', 'global-search', 'favicon-refresh']; + +const running = new Map>(); + +// ---- Removing it ---- + +function deleteDatabase(name: string): Promise { + return new Promise((resolve) => { + let request: IDBOpenDBRequest; + const timer = setTimeout(() => resolve(false), DATABASE_BUDGET_MS); + const finish = (done: boolean) => { + clearTimeout(timer); + resolve(done); + }; + try { + request = indexedDB.deleteDatabase(name); + } catch { + finish(true); + return; + } + request.onsuccess = () => finish(true); + request.onerror = () => finish(false); + }); +} + +/** + * Plugin-owned databases. S/MIME and OpenPGP keys aren't kept per account, so a + * wipe of one account takes everyone's: keeping them would leave a wiped user's + * private key behind. They're encrypted and come back from the user's own backup. + */ +async function deletePluginDatabases(): Promise { + if (typeof indexedDB === 'undefined') return; + const names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(readBlockedDatabases())]); + try { + for (const { name } of await indexedDB.databases()) { + if (name && !APP_DATABASES.has(name)) names.add(name); + } + } catch { + // No list in this browser: the names above. + } + const tries = readBlockedDatabases(); + const blocked: Record = {}; + await Promise.all([...names].map(async (name) => { + if (await deleteDatabase(name)) return; + const count = (tries[name] ?? 0) + 1; + if (count < DATABASE_RETRIES) blocked[name] = count; + })); + writeBlockedDatabases(blocked); +} + +/** + * What the desktop app keeps outside this page's storage: attachment copies, files + * on the clipboard, toasts, tray and jump list. Each is feature detected, since an + * older shell has none of them and a browser no bridge. + */ +async function clearShellFiles(): Promise { + const bridge = getDesktopBridge() as unknown as { + os?: { clearSurfaces?: () => void }; + clipboard?: { clearFiles?: () => void | Promise }; + } | null; + bridge?.os?.clearSurfaces?.(); + await bridge?.clipboard?.clearFiles?.(); +} + +/** A running frame is gone at the next load. Before any plugin starts again. */ +async function retryBlockedDatabases(): Promise { + if (Object.keys(readBlockedDatabases()).length > 0) await deletePluginDatabases(); +} + +/** The accounts' connected plugin accounts, which only sit on this device in the desktop app's own server. */ +async function forgetPluginAccounts(account: string | null): Promise { + if (!IS_DESKTOP_BUILD) return; + const accounts = useAccountStore.getState().accounts; + const slots = (account === null ? accounts : accounts.filter((entry) => entry.id === account)).map((entry) => entry.cookieSlot ?? 0); + await Promise.all(slots.map((slot) => apiFetch('/api/plugin-secrets?all=true', { + method: 'DELETE', + headers: { 'X-JMAP-Cookie-Slot': String(slot) }, + signal: AbortSignal.timeout(REPORT_BUDGET_MS), + }).catch(() => {}))); +} + +async function forgetPluginSettings(): Promise { + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState((state) => ({ plugins: state.plugins.map((plugin) => ({ ...plugin, settings: {} })) })); +} + +function removeLocalKeys(keys: string[]): void { + for (const key of keys) { + try { + localStorage.removeItem(key); + } catch { + /* storage unavailable */ + } + } +} + +/** Everything else that's the account's or that accounts share. `account` null: all of it. */ +async function scrub(account: string | null): Promise { + await runWipeCleaners(account); + await clearShellFiles().catch(() => {}); + await deletePluginDatabases(); + await forgetPluginAccounts(account); + await forgetPluginSettings().catch(() => {}); + if (account === null) clearAllPluginStorage(); + else clearPluginStorageForAccount(account); + await fileStorage.clearFiles().catch(() => {}); + await clearAllCheckpoints().catch(() => {}); + await sweepPrivateExports({ force: true }).catch(() => {}); + try { + indexedDB.deleteDatabase('bulwark-company-calendars'); + } catch { + /* storage unavailable */ + } + clearHandoffs(); + const { useNotificationCenterStore } = await import('@/stores/notification-center-store'); + useNotificationCenterStore.getState().clear(); + removeLocalKeys(SHARED_LOCAL_KEYS); +} + +async function flushConfirmations(): Promise { + await Promise.all(readWipeConfirmations().map(async (entry) => { + if (await reportWipeToInstance(entry, AbortSignal.timeout(REPORT_BUDGET_MS))) dropWipeConfirmation(entry.id); + })); +} + +function signalSignedOut(): void { + // The login page would otherwise sign straight back in through a provider session. + try { + sessionStorage.setItem('signed_out', 'true'); + } catch { + /* noop */ + } +} + +/** One object for sending and hearing: a channel doesn't hear itself, so this window doesn't reload. */ +let wipeChannel: BroadcastChannel | null = null; + +function otherWindows(): BroadcastChannel | null { + try { + wipeChannel ??= new BroadcastChannel(WIPE_CHANNEL); + return wipeChannel; + } catch { + return null; + } +} + +function tellOtherWindows(account: string | null): void { + otherWindows()?.postMessage({ account }); +} + +async function signOutAccount(account: string): Promise { + const auth = useAuthStore.getState(); + signalSignedOut(); + if (auth.activeAccountId === account) await auth.logout({ endProviderSession: false }); + else auth.removeAccount(account, 'explicit'); +} + +/** + * Takes one account off this device and signs it out. A refused login's sign-out + * is the auth store's own, already on its way, and its lost mail is named as for + * any sign-out nobody asked for. Everywhere else lost mail is a count. + */ +export function wipeAccountFromDevice(account: string, reason: WipeReason): Promise { + const under = running.get(account); + if (under) return under; + const work = eraseAccount(account, reason).finally(() => running.delete(account)); + running.set(account, work); + return work; +} + +async function eraseAccount(account: string, reason: WipeReason): Promise { + markWipePending(account, reason); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const record = records.find((entry) => entry.account === account); + const named = reason === 'rejected'; + const unsent = named ? 0 : (await unsentCount(account).catch(() => ({ messages: 0 }))).messages; + registerOfflineHooks(); + if (!named) confirmLoss(account); + await wipeAccount(account).catch(() => {}); + clearEvicted(account); + dropRefusedLogin(account); + await scrub(account).catch(() => {}); + + // The last account's wipe deletes the whole key database, with the rows of accounts that stay. + await deleteDeviceRecord(account).catch(() => {}); + for (const other of records) { + if (other.account !== account) await putDeviceRecord(other).catch(() => {}); + } + + if (record) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); + await flushConfirmations(); + if (!named) await signOutAccount(account); + clearWipePending(account); + tellOtherWindows(account); +} + +/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */ +export function wipeWholeDevice(reason: WipeReason): Promise { + const under = running.get(WHOLE_DEVICE); + if (under) return under; + const work = eraseDevice(reason).finally(() => running.delete(WHOLE_DEVICE)); + running.set(WHOLE_DEVICE, work); + return work; +} + +async function eraseDevice(reason: WipeReason): Promise { + markWipePending(WHOLE_DEVICE, reason); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const unsent = (await unsentCount('all').catch(() => ({ messages: 0 }))).messages; + registerOfflineHooks(); + confirmLoss('all'); + await scrub(null).catch(() => {}); + await wipeEverything().catch(() => {}); + for (const account of Object.keys(readEvicted())) clearEvicted(account); + for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account); + for (const record of records) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); + await flushConfirmations(); + signalSignedOut(); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + clearWipePending(WHOLE_DEVICE); + tellOtherWindows(null); +} + +// ---- Hearing about it ---- + +/** Whether this device has an instance to talk to. The desktop app's own server has no admin to ask. */ +function hasInstance(): boolean { + if (IS_DESKTOP_BUILD) return false; + return getDesktopBridge()?.mode !== 'standalone'; +} + +function wantsRow(account: { id: string; awaitingSignIn?: boolean; impersonated?: boolean }): boolean { + if (account.awaitingSignIn || account.impersonated) return false; + return getDesktopBridge()?.mode === 'instance' || useOfflineStore.getState().enabled.includes(account.id); +} + +const lastCheckIn = new Map(); +const lastRegisterTry = new Map(); +/** An older instance, or a session cookie that isn't back yet: not a request a minute. */ +const REGISTER_RETRY_MS = 15 * 60 * 1000; +let checkInFlight: Promise | null = null; + +async function checkInRecord(record: DeviceRecord, now: number): Promise { + lastCheckIn.set(record.account, now); + const answer = await checkInWithInstance(record, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (answer === 'wipe') { + await wipeAccountFromDevice(record.account, 'admin'); + return; + } + if (answer === 'forgotten') { + await deleteDeviceRecord(record.account).catch(() => {}); + return; + } + // Signed out here with nothing kept: the row has nothing left to look after. + if (answer === 'clear') { + const known = useAccountStore.getState().accounts.some((entry) => entry.id === record.account); + if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await deleteDeviceRecord(record.account).catch(() => {}); + } +} + +async function registerAccount(accountId: string, slot: number): Promise { + const register = async () => { + // Another window may have got here while this one waited. + if (await getDeviceRecord(accountId)) return; + const row = await registerWithInstance(slot, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (row) await putDeviceRecord({ account: accountId, id: row.id, secret: row.secret, createdAt: Date.now() }); + }; + if (typeof navigator !== 'undefined' && navigator.locks) await navigator.locks.request('bulwark-device-register', register); + else await register(); +} + +/** Each row that's due, and a row for each account that needs one. `force`: every row, now. One at a time. */ +export function checkInDue(force = false, now = Date.now()): Promise { + if (checkInFlight) return checkInFlight; + checkInFlight = runCheckIns(force, now).finally(() => { checkInFlight = null; }); + return checkInFlight; +} + +async function runCheckIns(force: boolean, now: number): Promise { + if (!hasInstance()) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + void flushConfirmations(); + const accounts = useAccountStore.getState().accounts; + const wanted = accounts.filter(wantsRow); + // No account with a reason and no database to look in: don't make one by looking. + const records = await listDeviceRecords(wanted.length === 0).catch(() => [] as DeviceRecord[]); + await Promise.all([ + ...records + .filter((record) => force || now - (lastCheckIn.get(record.account) ?? 0) >= CHECK_IN_EVERY_MS) + .map((record) => checkInRecord(record, now)), + ...wanted + .filter((account) => !records.some((record) => record.account === account.id)) + .filter((account) => force || now - (lastRegisterTry.get(account.id) ?? 0) >= REGISTER_RETRY_MS) + .map((account) => { + lastRegisterTry.set(account.id, now); + return registerAccount(account.id, account.cookieSlot ?? 0).catch(() => {}); + }), + ]); +} + +/** A wipe cut short by a crash or a closed window: finished before anything else. No network. */ +export async function finishInterruptedWipes(): Promise { + const pending = readPendingWipes(); + for (const [account, reason] of Object.entries(pending)) { + if (account === WHOLE_DEVICE) await wipeWholeDevice(reason); + else await wipeAccountFromDevice(account, reason); + } + await retryBlockedDatabases(); +} + +/** Refused passwords whose day has passed, with no sign-in since. */ +export async function sweepRefusedLogins(now = Date.now()): Promise { + for (const [account, at] of Object.entries(readRefusedLogins())) { + // Signed in again: the wait is over and nothing goes. + if (!isEvicted(account)) { + dropRefusedLogin(account); + continue; + } + if (now - at < REFUSED_KEEP_MS) continue; + dropRefusedLogin(account); + noteCredentialsRejected(account); + await running.get(account); + } +} + +let registered = false; + +/** The hooks every wipe needs, once per page. */ +export function registerDeviceWipe(): void { + if (registered) return; + registered = true; + onCredentialsRejected((account) => { + void wipeAccountFromDevice(account, 'rejected'); + }); +} + +/** + * What a start waits for, before any local data is shown: a wipe that was cut + * short, without limit, and the instance's answer, up to the budget. An answer + * that comes later still wipes, from wherever the page has got to. + */ +export async function runDeviceGate(): Promise { + registerDeviceWipe(); + await finishInterruptedWipes(); + await sweepRefusedLogins(); + const checks = checkInDue(true); + await Promise.race([ + checks.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, BOOT_CHECK_IN_BUDGET_MS)), + ]); +} + +function listenToOtherWindows(): () => void { + const channel = otherWindows(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent<{ account: string | null }>) => { + const { account } = event.data ?? {}; + // Its mail is in this window's memory. Another account's unsaved text isn't touched. + if (account === null || account === useAuthStore.getState().activeAccountId) window.location.reload(); + }; + return () => { channel.onmessage = null; }; +} + +/** For the host component: all of it, for as long as the page runs. */ +export function startDeviceWipe(): () => void { + registerDeviceWipe(); + if (isPopoutWindow()) return listenToOtherWindows(); + const stopOthers = listenToOtherWindows(); + void (async () => { + await finishInterruptedWipes(); + await sweepRefusedLogins(); + await checkInDue(); + })().catch(() => {}); + const tick = setInterval(() => { + void checkInDue().catch(() => {}); + void sweepRefusedLogins().catch(() => {}); + }, TICK_MS); + const onOnline = () => { void checkInDue(true).catch(() => {}); }; + window.addEventListener('online', onOnline); + const stopEnabled = useOfflineStore.subscribe((state, prev) => { + if (state.enabled === prev.enabled) return; + for (const account of state.enabled) lastRegisterTry.delete(account); + void checkInDue().catch(() => {}); + }); + return () => { + stopOthers(); + stopEnabled(); + clearInterval(tick); + window.removeEventListener('online', onOnline); + }; +} diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts new file mode 100644 index 000000000..2b9d8f393 --- /dev/null +++ b/lib/offline/wipe-registry.ts @@ -0,0 +1,39 @@ +/** + * Where a feature with data of its own on this device signs up for wipes: + * edit-in-place's opened files, for one. Free of the stores, so anything can + * import it. Registering is all a feature has to do. + * + * A cleaner runs on every remote wipe, policy wipe, wipe from settings and + * refused login, before the account signs out. `account` is an account id, or + * `null` when everything on this device goes. A cleaner should finish quickly + * and may not ask the user anything. Bridge calls are fine, network isn't: a + * wipe often runs because the server said no. + */ + +export type WipeCleaner = (account: string | null) => void | Promise; + +const cleaners = new Set(); + +export function onDeviceWipe(cleaner: WipeCleaner): () => void { + cleaners.add(cleaner); + return () => cleaners.delete(cleaner); +} + +/** How long a wipe waits for one cleaner. What's on screen waits with it. */ +const CLEANER_BUDGET_MS = 10_000; + +export async function runWipeCleaners(account: string | null): Promise { + await Promise.all([...cleaners].map(async (cleaner) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + cleaner(account), + new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), + ]); + } catch { + // One feature failing must not keep the others' data. + } finally { + if (timer) clearTimeout(timer); + } + })); +} diff --git a/lib/plugin-secrets/vault.ts b/lib/plugin-secrets/vault.ts index ce1e4d8de..c7c74329c 100644 --- a/lib/plugin-secrets/vault.ts +++ b/lib/plugin-secrets/vault.ts @@ -217,6 +217,11 @@ export function setUserCredentialSecret( }); } +/** Everything this user connected, for every plugin. */ +export async function deleteAllUserCredentials(user: VaultUser): Promise { + await collection().delete(userKey(user)); +} + /** Remove one credential, or all of a plugin's when credentialId is omitted. */ export function deleteUserCredentials(user: VaultUser, pluginId: string, credentialId?: string): Promise { return modify(user, (data) => { diff --git a/lib/shared-store/__tests__/import-data.test.ts b/lib/shared-store/__tests__/import-data.test.ts index 80e1c589c..9335c4b9d 100644 --- a/lib/shared-store/__tests__/import-data.test.ts +++ b/lib/shared-store/__tests__/import-data.test.ts @@ -14,6 +14,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; // An existing single instance's settings, plugin credentials, archives, // telemetry and update-check state go into an empty shared store once, as @@ -43,9 +44,12 @@ beforeEach(() => { vi.stubEnv('PLUGIN_SECRETS_DIR', path.join(data, 'plugin-secrets')); vi.stubEnv('TELEMETRY_DATA_DIR', path.join(data, 'telemetry')); vi.stubEnv('VERSION_CHECK_DATA_DIR', path.join(data, 'version-check')); + vi.stubEnv('ADMIN_STATE_DIR', path.join(data, 'admin-state')); for (const dir of ['settings/account-archives', 'plugin-secrets', 'telemetry', 'version-check']) { mkdirSync(path.join(data, dir), { recursive: true }); } + mkdirSync(path.join(data, 'admin-state', 'devices', hash('f')), { recursive: true }); + writeFileSync(path.join(data, 'admin-state', 'devices', hash('f'), `${'0'.repeat(32)}.json`), '{"user":"a"}'); writeFileSync(path.join(data, 'settings', `${hash('a')}.enc`), Buffer.from([1, 2, 3, 4])); writeFileSync(path.join(data, 'settings', `${hash('b')}.enc`), Buffer.from([5, 6])); writeFileSync(path.join(data, 'settings', `${hash('c')}.enc.tmp`), 'half written'); @@ -78,10 +82,10 @@ describe('importing part data', () => { const before = snapshot(data); expect(await runAll()).toEqual({ - settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, + settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, devices: 1, }); expect(await runAll()).toEqual({ - settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, + settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, devices: 0, }); expect(snapshot(data)).toEqual(before); @@ -92,6 +96,7 @@ describe('importing part data', () => { expect((await store.collection('account-archives', ARCHIVE_COLLECTION).get(hash('d')))?.data.toString()).toBe('{"version":1}'); expect((await store.collection('telemetry', TELEMETRY_COLLECTION).list()).sort()).toEqual(['.telemetry-id', 'logins.json', 'state.json']); expect((await store.collection('version-check', VERSION_CHECK_COLLECTION).get('state.json'))?.data.toString()).toBe('{"status":null}'); + expect((await store.collection('devices', DEVICES_COLLECTION).get(`${hash('f')}/${'0'.repeat(32)}`))?.data.toString()).toBe('{"user":"a"}'); }); it('keeps what the store already holds', async () => { diff --git a/lib/shared-store/export.ts b/lib/shared-store/export.ts index d14775c0f..9a7f2297f 100644 --- a/lib/shared-store/export.ts +++ b/lib/shared-store/export.ts @@ -11,6 +11,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; import { getStore } from './index'; import { exportSteps as extExportSteps } from './import/ext'; import { localCollection, localLog } from './local'; @@ -93,6 +94,7 @@ export const EXPORT_STEPS: ExportStep[] = [ exportCollection('account-archives', ARCHIVE_COLLECTION), exportCollection('telemetry', TELEMETRY_COLLECTION), exportCollection('version-check', VERSION_CHECK_COLLECTION), + exportCollection('devices', DEVICES_COLLECTION), ]; export async function exportSharedData(steps: ExportStep[] = EXPORT_STEPS): Promise> { diff --git a/lib/shared-store/import/data.ts b/lib/shared-store/import/data.ts index 9af9a1030..592cfe671 100644 --- a/lib/shared-store/import/data.ts +++ b/lib/shared-store/import/data.ts @@ -7,6 +7,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; /** * Part `data` of the shared-store import (#1066): every document of these @@ -39,4 +40,5 @@ export const steps: ImportStep[] = [ // state.json, logins.json and the instance id .telemetry-id copyCollection('telemetry', TELEMETRY_COLLECTION), copyCollection('version-check', VERSION_CHECK_COLLECTION), + copyCollection('devices', DEVICES_COLLECTION), ]; diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index 9723c6710..3e9ea856c 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -164,6 +164,55 @@ export async function forgetAccountKey(account: string): Promise { await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined)); } +/** + * This browser profile's row at the instance, for one account: its id, and the + * secret that lets a device answer for that row without a session. In this + * database and nowhere else, because a start has to read it before any store + * opens, and a copy in the store could never outlive the key that opens it. A + * cleared database means a new device at the next registration. + */ +export interface DeviceRecord { + account: string; + id: string; + secret: string; + createdAt: number; +} + +const DEVICE_RECORD_PREFIX = 'device:'; +// ':' + 1, so the range holds exactly the prefixed keys. +const DEVICE_RECORD_END = 'device;'; + +export async function getDeviceRecord(account: string): Promise { + return withDb(async (db) => { + const found = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).get(DEVICE_RECORD_PREFIX + account)) as { record: DeviceRecord } | undefined; + return found?.record ?? null; + }); +} + +export async function putDeviceRecord(record: DeviceRecord): Promise { + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).put({ id: DEVICE_RECORD_PREFIX + record.account, record })).then(() => undefined)); +} + +export async function deleteDeviceRecord(account: string): Promise { + if (!(await keyDatabaseExists())) return; + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).delete(DEVICE_RECORD_PREFIX + account)).then(() => undefined)); +} + +/** Opening the database makes it, so a browser that can say it isn't there doesn't open it. */ +async function keyDatabaseExists(): Promise { + if (typeof indexedDB.databases !== 'function') return true; + return (await indexedDB.databases()).some((database) => database.name === DB_NAME); +} + +export async function listDeviceRecords(onlyIfThere = true): Promise { + if (onlyIfThere && !(await keyDatabaseExists())) return []; + return withDb(async (db) => { + const range = IDBKeyRange.bound(DEVICE_RECORD_PREFIX, DEVICE_RECORD_END, false, true); + const rows = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).getAll(range)) as Array<{ record: DeviceRecord }>; + return rows.map((row) => row.record); + }); +} + export class StoreKeyLayerMissingError extends Error { constructor(readonly layer: string) { super(`The key needs the ${layer} layer`); diff --git a/locales/ar/common.json b/locales/ar/common.json index 654025307..9910df082 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5763,6 +5763,23 @@ "store_private": "لا يمكن للنوافذ الخاصة الاحتفاظ بالبريد للاستخدام دون اتصال.", "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, + "offlineWipe": { + "note": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. سجّل الدخول للبدء من جديد.", + "note_unsent": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. الرسائل غير المرسلة المحذوفة: {count}.", + "settings_title": "إزالة كل شيء من هذا الجهاز", + "settings_description": "عندما تعطي هذا الحاسوب لشخص آخر أو تتوقف عن استخدامه. يبقى بريدك على الخادم.", + "settings_button": "إزالة كل شيء…", + "settings_wiping": "جارٍ الإزالة…", + "confirm_title": "هل تريد إزالة كل شيء من هذا الجهاز؟", + "confirm_message": "سيحذف هذا ما يحتفظ به التطبيق على هذا الجهاز، لكل الحسابات هنا. لن يتأثر بريدك على الخادم.", + "confirm_mail": "البريد والتقاويم وجهات الاتصال والملفات المحفوظة هنا", + "confirm_unsent": "الرسائل غير المرسلة المنتظرة هنا: {count}. ستُفقد.", + "confirm_keys": "مفاتيح S/MIME وOpenPGP. ستحتاج إلى نسختك الاحتياطية لاستخدامها مجددًا.", + "confirm_plugins": "البيانات التي خزّنتها الإضافات هنا", + "confirm_files": "المرفقات المفتوحة للتحرير والملفات المنتظرة للإرفاق", + "confirm_accounts": "يتم تسجيل الخروج من كل الحسابات", + "confirm_button": "إزالة كل شيء" + }, "offlineDrafts": { "title": "المسودات على هذا الجهاز", "description": "هذه المسودات محفوظة على هذا الجهاز. ستنتقل إلى الخادم تلقائيًا فور إمكان الوصول إليه.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a9eb6fb2..db7de5235 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5763,6 +5763,23 @@ "store_private": "Les finestres privades no poden desar correu per fer-lo servir sense connexió.", "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, + "offlineWipe": { + "note": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Inicia la sessió per tornar a començar.", + "note_unsent": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Missatges sense enviar eliminats: {count}.", + "settings_title": "Treu-ho tot d'aquest dispositiu", + "settings_description": "Per quan regalis aquest ordinador o el deixis d'utilitzar. El teu correu al servidor es manté.", + "settings_button": "Treu-ho tot…", + "settings_wiping": "S'està traient…", + "confirm_title": "Vols treure-ho tot d'aquest dispositiu?", + "confirm_message": "Això elimina el que aquesta aplicació guarda en aquest dispositiu, per a tots els comptes d'aquí. El teu correu al servidor no es toca.", + "confirm_mail": "Correu, calendaris, contactes i fitxers guardats aquí", + "confirm_unsent": "Missatges sense enviar en espera aquí: {count}. Es perdran.", + "confirm_keys": "Claus S/MIME i OpenPGP. Necessitaràs la teva pròpia còpia de seguretat per tornar-les a utilitzar.", + "confirm_plugins": "Dades que els connectors han guardat aquí", + "confirm_files": "Adjunts oberts per editar i fitxers pendents d'adjuntar", + "confirm_accounts": "Es tanca la sessió de tots els comptes", + "confirm_button": "Treu-ho tot" + }, "offlineDrafts": { "title": "Esborranys en aquest dispositiu", "description": "Aquests esborranys estan desats en aquest dispositiu. Aniran al servidor sols tan bon punt s'hi pugui connectar.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 18f0c03ee..82b65a32e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5763,6 +5763,23 @@ "store_private": "Soukromá okna nemohou uchovávat poštu pro použití offline.", "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, + "offlineWipe": { + "note": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Přihlaste se a začněte znovu.", + "note_unsent": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Smazané neodeslané zprávy: {count}.", + "settings_title": "Odebrat vše z tohoto zařízení", + "settings_description": "Pro případ, že tento počítač předáte dál nebo ho přestanete používat. Vaše pošta na serveru zůstane.", + "settings_button": "Odebrat vše…", + "settings_wiping": "Odebírání…", + "confirm_title": "Odebrat vše z tohoto zařízení?", + "confirm_message": "Tím se smaže to, co tato aplikace uchovává na tomto zařízení, pro všechny zdejší účty. Vaší pošty na serveru se to nedotkne.", + "confirm_mail": "Pošta, kalendáře, kontakty a soubory uložené zde", + "confirm_unsent": "Neodeslané zprávy čekající zde: {count}. Budou ztraceny.", + "confirm_keys": "Klíče S/MIME a OpenPGP. Abyste je mohli znovu použít, budete potřebovat vlastní zálohu.", + "confirm_plugins": "Data, která sem uložily zásuvné moduly", + "confirm_files": "Přílohy otevřené k úpravám a soubory čekající na připojení", + "confirm_accounts": "Všechny účty budou odhlášeny", + "confirm_button": "Odebrat vše" + }, "offlineDrafts": { "title": "Koncepty v tomto zařízení", "description": "Tyto koncepty jsou uložené v tomto zařízení. Na server se odešlou samy, jakmile bude dostupný.", diff --git a/locales/da/common.json b/locales/da/common.json index 5578eeca0..96e55884f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private vinduer kan ikke gemme mail til brug offline.", "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, + "offlineWipe": { + "note": "Din organisation har fjernet denne kontos data fra denne enhed. Log ind for at starte forfra.", + "note_unsent": "Din organisation har fjernet denne kontos data fra denne enhed. Slettede ikke-sendte beskeder: {count}.", + "settings_title": "Fjern alt fra denne enhed", + "settings_description": "Til når du giver computeren væk eller holder op med at bruge den. Din post på serveren bliver.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Fjern alt fra denne enhed?", + "confirm_message": "Det sletter det, denne app gemmer på denne enhed, for alle konti her. Din post på serveren røres ikke.", + "confirm_mail": "Post, kalendere, kontakter og filer gemt her", + "confirm_unsent": "Ikke-sendte beskeder, der venter her: {count}. De går tabt.", + "confirm_keys": "S/MIME- og OpenPGP-nøgler. Du skal bruge din egen sikkerhedskopi for at bruge dem igen.", + "confirm_plugins": "Data, som plugins har gemt her", + "confirm_files": "Vedhæftede filer åbnet til redigering og filer, der venter på at blive vedhæftet", + "confirm_accounts": "Alle konti logges ud", + "confirm_button": "Fjern alt" + }, "offlineDrafts": { "title": "Kladder på denne enhed", "description": "Disse kladder er gemt på denne enhed. De sendes selv til serveren, så snart den kan nås.", diff --git a/locales/de/common.json b/locales/de/common.json index efb641f2b..5c74f7785 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private Fenster können keine E-Mails für die Offline-Nutzung behalten.", "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, + "offlineWipe": { + "note": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Melden Sie sich an, um neu zu beginnen.", + "note_unsent": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Gelöschte ungesendete Nachrichten: {count}.", + "settings_title": "Alles von diesem Gerät entfernen", + "settings_description": "Für den Fall, dass Sie diesen Computer weitergeben oder nicht mehr nutzen. Ihre E-Mails auf dem Server bleiben erhalten.", + "settings_button": "Alles entfernen…", + "settings_wiping": "Wird entfernt…", + "confirm_title": "Alles von diesem Gerät entfernen?", + "confirm_message": "Damit wird gelöscht, was diese App auf diesem Gerät speichert, für alle Konten hier. Ihre E-Mails auf dem Server bleiben unberührt.", + "confirm_mail": "Hier gespeicherte E-Mails, Kalender, Kontakte und Dateien", + "confirm_unsent": "Hier wartende ungesendete Nachrichten: {count}. Sie gehen verloren.", + "confirm_keys": "S/MIME- und OpenPGP-Schlüssel. Um sie wieder zu nutzen, brauchen Sie Ihre eigene Sicherung.", + "confirm_plugins": "Daten, die Plugins hier gespeichert haben", + "confirm_files": "Zum Bearbeiten geöffnete Anhänge und Dateien, die noch angehängt werden sollen", + "confirm_accounts": "Alle Konten werden abgemeldet", + "confirm_button": "Alles entfernen" + }, "offlineDrafts": { "title": "Entwürfe auf diesem Gerät", "description": "Diese Entwürfe sind auf diesem Gerät gespeichert. Sie gehen von selbst an den Server, sobald er erreichbar ist.", diff --git a/locales/en/common.json b/locales/en/common.json index 7728594e1..5656dcf16 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private windows can't keep mail for offline use.", "server_not_answering": "The mail server isn't answering. Try again in a moment." }, + "offlineWipe": { + "note": "Your organisation removed this account's data from this device. Sign in to start again.", + "note_unsent": "Your organisation removed this account's data from this device. Unsent messages deleted: {count}.", + "settings_title": "Remove everything from this device", + "settings_description": "For when you give this computer away or stop using it. Your mail on the server stays.", + "settings_button": "Remove everything…", + "settings_wiping": "Removing…", + "confirm_title": "Remove everything from this device?", + "confirm_message": "This deletes what this app keeps on this device, for every account here. Your mail on the server isn't touched.", + "confirm_mail": "Mail, calendars, contacts and files kept here", + "confirm_unsent": "Unsent messages waiting here: {count}. They will be lost.", + "confirm_keys": "S/MIME and OpenPGP keys. You need your own backup to use them again.", + "confirm_plugins": "Data that plugins stored here", + "confirm_files": "Attachments opened for editing and files waiting to be attached", + "confirm_accounts": "All accounts are signed out", + "confirm_button": "Remove everything" + }, "offlineDrafts": { "title": "Drafts on this device", "description": "These drafts are saved on this device. They go to the server by themselves once it can be reached.", diff --git a/locales/es/common.json b/locales/es/common.json index 5a2af15a4..20454525e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5763,6 +5763,23 @@ "store_private": "Las ventanas privadas no pueden guardar correo para usarlo sin conexión.", "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, + "offlineWipe": { + "note": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Inicia sesión para empezar de nuevo.", + "note_unsent": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Mensajes sin enviar eliminados: {count}.", + "settings_title": "Quitar todo de este dispositivo", + "settings_description": "Para cuando regales este ordenador o dejes de usarlo. Tu correo en el servidor se mantiene.", + "settings_button": "Quitar todo…", + "settings_wiping": "Quitando…", + "confirm_title": "¿Quitar todo de este dispositivo?", + "confirm_message": "Esto elimina lo que esta aplicación guarda en este dispositivo, para todas las cuentas de aquí. Tu correo en el servidor no se toca.", + "confirm_mail": "Correo, calendarios, contactos y archivos guardados aquí", + "confirm_unsent": "Mensajes sin enviar en espera aquí: {count}. Se perderán.", + "confirm_keys": "Claves S/MIME y OpenPGP. Necesitarás tu propia copia de seguridad para volver a usarlas.", + "confirm_plugins": "Datos que los plugins guardaron aquí", + "confirm_files": "Adjuntos abiertos para editar y archivos a la espera de adjuntarse", + "confirm_accounts": "Se cierra la sesión de todas las cuentas", + "confirm_button": "Quitar todo" + }, "offlineDrafts": { "title": "Borradores en este dispositivo", "description": "Estos borradores están guardados en este dispositivo. Irán al servidor por sí solos en cuanto se pueda conectar con él.", diff --git a/locales/et/common.json b/locales/et/common.json index b5ce8e291..7568cd70d 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privaatsed aknad ei saa kirju võrguühenduseta kasutamiseks hoida.", "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, + "offlineWipe": { + "note": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Alustamiseks logige uuesti sisse.", + "note_unsent": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Kustutatud saatmata kirju: {count}.", + "settings_title": "Eemalda sellest seadmest kõik", + "settings_description": "Juhuks, kui annate selle arvuti ära või lõpetate selle kasutamise. Teie kirjad serveris jäävad alles.", + "settings_button": "Eemalda kõik…", + "settings_wiping": "Eemaldamine…", + "confirm_title": "Kas eemaldada sellest seadmest kõik?", + "confirm_message": "See kustutab kõik, mida see rakendus selles seadmes hoiab, kõigi siinsete kontode jaoks. Teie kirju serveris see ei puuduta.", + "confirm_mail": "Siin hoitavad kirjad, kalendrid, kontaktid ja failid", + "confirm_unsent": "Siin ootavaid saatmata kirju: {count}. Need lähevad kaotsi.", + "confirm_keys": "S/MIME- ja OpenPGP-võtmed. Nende uuesti kasutamiseks on vaja teie enda varukoopiat.", + "confirm_plugins": "Pistikprogrammide siia salvestatud andmed", + "confirm_files": "Muutmiseks avatud manused ja lisamist ootavad failid", + "confirm_accounts": "Kõik kontod logitakse välja", + "confirm_button": "Eemalda kõik" + }, "offlineDrafts": { "title": "Mustandid selles seadmes", "description": "Need mustandid on salvestatud selles seadmes. Need lähevad serverisse ise, kui server on kättesaadav.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 22a2c1d43..25c3a7062 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5763,6 +5763,23 @@ "store_private": "پنجره‌های خصوصی نمی‌توانند ایمیل را برای استفادهٔ آفلاین نگه دارند.", "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, + "offlineWipe": { + "note": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. برای شروع دوباره وارد شوید.", + "note_unsent": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. پیام‌های ارسال‌نشدهٔ حذف‌شده: {count}.", + "settings_title": "حذف همه چیز از این دستگاه", + "settings_description": "برای زمانی که این رایانه را به کسی می‌دهید یا دیگر از آن استفاده نمی‌کنید. نامه‌های شما روی سرور می‌ماند.", + "settings_button": "حذف همه چیز…", + "settings_wiping": "در حال حذف…", + "confirm_title": "همه چیز از این دستگاه حذف شود؟", + "confirm_message": "این کار آنچه این برنامه روی این دستگاه نگه می‌دارد را برای همهٔ حساب‌های اینجا حذف می‌کند. به نامه‌های شما روی سرور دست زده نمی‌شود.", + "confirm_mail": "نامه‌ها، تقویم‌ها، مخاطبان و فایل‌های نگه‌داری‌شده در اینجا", + "confirm_unsent": "پیام‌های ارسال‌نشدهٔ در انتظار در اینجا: {count}. از بین خواهند رفت.", + "confirm_keys": "کلیدهای S/MIME و OpenPGP. برای استفادهٔ دوباره به نسخهٔ پشتیبان خودتان نیاز دارید.", + "confirm_plugins": "داده‌هایی که افزونه‌ها اینجا ذخیره کرده‌اند", + "confirm_files": "پیوست‌های بازشده برای ویرایش و فایل‌های در انتظار پیوست", + "confirm_accounts": "از همهٔ حساب‌ها خارج می‌شوید", + "confirm_button": "حذف همه چیز" + }, "offlineDrafts": { "title": "پیش‌نویس‌های این دستگاه", "description": "این پیش‌نویس‌ها روی این دستگاه ذخیره شده‌اند. به‌محض دسترسی به سرور، خودکار به آن می‌روند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index e30800b16..0c9189314 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5763,6 +5763,23 @@ "store_private": "Les fenêtres privées ne peuvent pas conserver le courrier pour une utilisation hors ligne.", "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, + "offlineWipe": { + "note": "Votre organisation a supprimé les données de ce compte de cet appareil. Connectez-vous pour recommencer.", + "note_unsent": "Votre organisation a supprimé les données de ce compte de cet appareil. Messages non envoyés supprimés : {count}.", + "settings_title": "Tout supprimer de cet appareil", + "settings_description": "Si vous donnez cet ordinateur ou cessez de l'utiliser. Vos messages sur le serveur restent en place.", + "settings_button": "Tout supprimer…", + "settings_wiping": "Suppression…", + "confirm_title": "Tout supprimer de cet appareil ?", + "confirm_message": "Cela supprime ce que cette application garde sur cet appareil, pour tous les comptes présents. Vos messages sur le serveur ne sont pas touchés.", + "confirm_mail": "Messages, agendas, contacts et fichiers gardés ici", + "confirm_unsent": "Messages non envoyés en attente ici : {count}. Ils seront perdus.", + "confirm_keys": "Clés S/MIME et OpenPGP. Il vous faudra votre propre sauvegarde pour les réutiliser.", + "confirm_plugins": "Données enregistrées ici par les plugins", + "confirm_files": "Pièces jointes ouvertes pour modification et fichiers en attente d'être joints", + "confirm_accounts": "Tous les comptes sont déconnectés", + "confirm_button": "Tout supprimer" + }, "offlineDrafts": { "title": "Brouillons sur cet appareil", "description": "Ces brouillons sont enregistrés sur cet appareil. Ils partent seuls vers le serveur dès qu'il est joignable.", diff --git a/locales/he/common.json b/locales/he/common.json index 571f30e34..2591789ac 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5764,6 +5764,23 @@ "store_private": "חלונות פרטיים לא יכולים לשמור דואר לשימוש לא מקוון.", "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, + "offlineWipe": { + "note": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. יש להיכנס כדי להתחיל מחדש.", + "note_unsent": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. הודעות שלא נשלחו ונמחקו: {count}.", + "settings_title": "הסרת הכול מהמכשיר הזה", + "settings_description": "למקרה שמעבירים את המחשב הזה למישהו אחר או מפסיקים להשתמש בו. הדואר שלך בשרת נשאר.", + "settings_button": "הסרת הכול…", + "settings_wiping": "מתבצעת הסרה…", + "confirm_title": "להסיר הכול מהמכשיר הזה?", + "confirm_message": "פעולה זו מוחקת את מה שהיישום הזה שומר במכשיר הזה, לכל החשבונות כאן. הדואר שלך בשרת לא ייפגע.", + "confirm_mail": "דואר, יומנים, אנשי קשר וקבצים השמורים כאן", + "confirm_unsent": "הודעות שלא נשלחו וממתינות כאן: {count}. הן יאבדו.", + "confirm_keys": "מפתחות S/MIME ו-OpenPGP. כדי להשתמש בהם שוב צריך גיבוי משלך.", + "confirm_plugins": "נתונים שתוספים שמרו כאן", + "confirm_files": "קבצים מצורפים שנפתחו לעריכה וקבצים שממתינים לצירוף", + "confirm_accounts": "מתבצעת יציאה מכל החשבונות", + "confirm_button": "הסרת הכול" + }, "offlineDrafts": { "title": "טיוטות במכשיר זה", "description": "הטיוטות האלה שמורות במכשיר זה. הן יעברו לשרת מעצמן ברגע שאפשר יהיה להגיע אליו.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2ab59129e..6df1dbb69 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5763,6 +5763,23 @@ "store_private": "A privát ablakok nem tudnak leveleket megőrizni offline használatra.", "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, + "offlineWipe": { + "note": "A szervezete eltávolította a fiók adatait erről az eszközről. Jelentkezzen be az újrakezdéshez.", + "note_unsent": "A szervezete eltávolította a fiók adatait erről az eszközről. Törölt, el nem küldött üzenetek: {count}.", + "settings_title": "Minden eltávolítása erről az eszközről", + "settings_description": "Arra az esetre, ha továbbadja ezt a számítógépet, vagy már nem használja. A levelei a kiszolgálón megmaradnak.", + "settings_button": "Minden eltávolítása…", + "settings_wiping": "Eltávolítás…", + "confirm_title": "Eltávolít mindent erről az eszközről?", + "confirm_message": "Ez törli mindazt, amit ez az alkalmazás ezen az eszközön tárol, az itteni összes fiókhoz. A kiszolgálón lévő leveleit nem érinti.", + "confirm_mail": "Az itt tárolt levelek, naptárak, névjegyek és fájlok", + "confirm_unsent": "Itt várakozó, el nem küldött üzenetek: {count}. Elvesznek.", + "confirm_keys": "S/MIME- és OpenPGP-kulcsok. Az újbóli használatukhoz saját biztonsági másolatra lesz szüksége.", + "confirm_plugins": "A bővítmények által itt tárolt adatok", + "confirm_files": "Szerkesztésre megnyitott mellékletek és csatolásra váró fájlok", + "confirm_accounts": "Minden fiók kijelentkezik", + "confirm_button": "Minden eltávolítása" + }, "offlineDrafts": { "title": "Piszkozatok ezen az eszközön", "description": "Ezek a piszkozatok ezen az eszközön vannak mentve. Maguktól a szerverre kerülnek, amint az elérhető.", diff --git a/locales/it/common.json b/locales/it/common.json index a861d3390..72eab5bb2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5763,6 +5763,23 @@ "store_private": "Le finestre private non possono conservare la posta per l'uso offline.", "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, + "offlineWipe": { + "note": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Accedi per ricominciare.", + "note_unsent": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Messaggi non inviati eliminati: {count}.", + "settings_title": "Rimuovi tutto da questo dispositivo", + "settings_description": "Per quando cedi questo computer o smetti di usarlo. La tua posta sul server resta.", + "settings_button": "Rimuovi tutto…", + "settings_wiping": "Rimozione…", + "confirm_title": "Rimuovere tutto da questo dispositivo?", + "confirm_message": "Questo elimina ciò che questa app conserva su questo dispositivo, per tutti gli account presenti. La tua posta sul server non viene toccata.", + "confirm_mail": "Posta, calendari, contatti e file conservati qui", + "confirm_unsent": "Messaggi non inviati in attesa qui: {count}. Andranno persi.", + "confirm_keys": "Chiavi S/MIME e OpenPGP. Per riusarle ti serve un tuo backup.", + "confirm_plugins": "Dati salvati qui dai plugin", + "confirm_files": "Allegati aperti per la modifica e file in attesa di essere allegati", + "confirm_accounts": "Tutti gli account vengono disconnessi", + "confirm_button": "Rimuovi tutto" + }, "offlineDrafts": { "title": "Bozze su questo dispositivo", "description": "Queste bozze sono salvate su questo dispositivo. Passano da sole al server appena è raggiungibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0606617f1..067d7339b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5763,6 +5763,23 @@ "store_private": "プライベートウィンドウではオフライン用にメールを保存できません。", "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, + "offlineWipe": { + "note": "組織がこのアカウントのデータをこのデバイスから削除しました。もう一度始めるにはサインインしてください。", + "note_unsent": "組織がこのアカウントのデータをこのデバイスから削除しました。削除された未送信メッセージ: {count} 件。", + "settings_title": "このデバイスからすべて削除", + "settings_description": "このコンピューターを譲渡するときや使わなくなるときのためのものです。サーバー上のメールは残ります。", + "settings_button": "すべて削除…", + "settings_wiping": "削除しています…", + "confirm_title": "このデバイスからすべて削除しますか?", + "confirm_message": "このアプリがこのデバイスに保存している内容を、ここにあるすべてのアカウントについて削除します。サーバー上のメールには影響しません。", + "confirm_mail": "ここに保存されたメール、カレンダー、連絡先、ファイル", + "confirm_unsent": "ここで待機中の未送信メッセージ: {count} 件。失われます。", + "confirm_keys": "S/MIME と OpenPGP の鍵。再度使うにはご自身のバックアップが必要です。", + "confirm_plugins": "プラグインがここに保存したデータ", + "confirm_files": "編集のために開いた添付ファイルと、添付待ちのファイル", + "confirm_accounts": "すべてのアカウントがサインアウトされます", + "confirm_button": "すべて削除" + }, "offlineDrafts": { "title": "このデバイスの下書き", "description": "これらの下書きはこのデバイスに保存されています。サーバーに接続できるようになると自動で送られます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0daa574..139a447fa 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5763,6 +5763,23 @@ "store_private": "개인 정보 보호 창에서는 오프라인용 메일을 보관할 수 없습니다.", "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, + "offlineWipe": { + "note": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 다시 시작하려면 로그인하세요.", + "note_unsent": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 삭제된 보내지 않은 메시지: {count}개.", + "settings_title": "이 기기에서 모두 제거", + "settings_description": "이 컴퓨터를 다른 사람에게 주거나 더 이상 사용하지 않을 때 사용합니다. 서버의 메일은 그대로 남습니다.", + "settings_button": "모두 제거…", + "settings_wiping": "제거하는 중…", + "confirm_title": "이 기기에서 모두 제거할까요?", + "confirm_message": "이 앱이 이 기기에 보관하는 내용을 여기 있는 모든 계정에 대해 삭제합니다. 서버의 메일은 건드리지 않습니다.", + "confirm_mail": "여기에 보관된 메일, 캘린더, 연락처, 파일", + "confirm_unsent": "여기에서 대기 중인 보내지 않은 메시지: {count}개. 사라집니다.", + "confirm_keys": "S/MIME 및 OpenPGP 키. 다시 사용하려면 직접 만든 백업이 필요합니다.", + "confirm_plugins": "플러그인이 여기에 저장한 데이터", + "confirm_files": "편집하려고 연 첨부 파일과 첨부를 기다리는 파일", + "confirm_accounts": "모든 계정이 로그아웃됩니다", + "confirm_button": "모두 제거" + }, "offlineDrafts": { "title": "이 기기의 임시 보관함", "description": "이 임시 보관 메일은 이 기기에 저장되어 있습니다. 서버에 연결되면 자동으로 올라갑니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 05939fbc0..74592824c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privātie logi nevar glabāt pastu lietošanai bezsaistē.", "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, + "offlineWipe": { + "note": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Piesakieties, lai sāktu no jauna.", + "note_unsent": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Dzēstie nenosūtītie ziņojumi: {count}.", + "settings_title": "Noņemt visu no šīs ierīces", + "settings_description": "Gadījumam, ja atdodat šo datoru vai vairs to neizmantojat. Jūsu pasts serverī paliek.", + "settings_button": "Noņemt visu…", + "settings_wiping": "Notiek noņemšana…", + "confirm_title": "Vai noņemt visu no šīs ierīces?", + "confirm_message": "Tiek izdzēsts tas, ko šī lietotne glabā šajā ierīcē, visiem šeit esošajiem kontiem. Jūsu pasts serverī netiek skarts.", + "confirm_mail": "Šeit glabātais pasts, kalendāri, kontakti un faili", + "confirm_unsent": "Šeit gaidošie nenosūtītie ziņojumi: {count}. Tie tiks zaudēti.", + "confirm_keys": "S/MIME un OpenPGP atslēgas. Lai tās izmantotu atkal, būs vajadzīga jūsu pašu dublējumkopija.", + "confirm_plugins": "Dati, ko šeit saglabājuši spraudņi", + "confirm_files": "Rediģēšanai atvērti pielikumi un faili, kas gaida pievienošanu", + "confirm_accounts": "Visi konti tiek izrakstīti", + "confirm_button": "Noņemt visu" + }, "offlineDrafts": { "title": "Melnraksti šajā ierīcē", "description": "Šie melnraksti ir saglabāti šajā ierīcē. Tie paši nonāks serverī, tiklīdz tas būs sasniedzams.", diff --git a/locales/mn/common.json b/locales/mn/common.json index dfa942d34..c325bdfd6 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5763,6 +5763,23 @@ "store_private": "Хувийн цонх офлайн ашиглах захидал хадгалж чадахгүй.", "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, + "offlineWipe": { + "note": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Дахин эхлэхийн тулд нэвтэрнэ үү.", + "note_unsent": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Устгасан илгээгээгүй зурвас: {count}.", + "settings_title": "Энэ төхөөрөмжөөс бүгдийг устгах", + "settings_description": "Энэ компьютерийг бусдад өгөх эсвэл ашиглахаа болих үед. Сервер дээрх таны шуудан хэвээр үлдэнэ.", + "settings_button": "Бүгдийг устгах…", + "settings_wiping": "Устгаж байна…", + "confirm_title": "Энэ төхөөрөмжөөс бүгдийг устгах уу?", + "confirm_message": "Энэ аппын энэ төхөөрөмж дээр хадгалсан бүх зүйлийг эндхийн бүх бүртгэлд устгана. Сервер дээрх таны шуудан хөндөгдөхгүй.", + "confirm_mail": "Энд хадгалсан шуудан, хуанли, харилцагч, файлууд", + "confirm_unsent": "Энд хүлээгдэж буй илгээгээгүй зурвас: {count}. Тэд алга болно.", + "confirm_keys": "S/MIME болон OpenPGP түлхүүрүүд. Дахин ашиглахын тулд өөрийн нөөц хуулбар хэрэгтэй.", + "confirm_plugins": "Өргөтгөлүүдийн энд хадгалсан өгөгдөл", + "confirm_files": "Засахаар нээсэн хавсралтууд болон хавсаргахаар хүлээж буй файлууд", + "confirm_accounts": "Бүх бүртгэлээс гарна", + "confirm_button": "Бүгдийг устгах" + }, "offlineDrafts": { "title": "Энэ төхөөрөмж дээрх ноорог", "description": "Эдгээр ноорог энэ төхөөрөмж дээр хадгалагдсан. Сервертэй холбогдмогц өөрсдөө илгээгдэнэ.", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0002acca..b0fe115da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private vinduer kan ikke beholde e-post for bruk uten nett.", "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, + "offlineWipe": { + "note": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Logg på for å begynne på nytt.", + "note_unsent": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Slettede usendte meldinger: {count}.", + "settings_title": "Fjern alt fra denne enheten", + "settings_description": "For når du gir bort denne datamaskinen eller slutter å bruke den. E-posten din på serveren blir værende.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Vil du fjerne alt fra denne enheten?", + "confirm_message": "Dette sletter det denne appen lagrer på denne enheten, for alle kontoer her. E-posten din på serveren blir ikke rørt.", + "confirm_mail": "E-post, kalendere, kontakter og filer som er lagret her", + "confirm_unsent": "Usendte meldinger som venter her: {count}. De går tapt.", + "confirm_keys": "S/MIME- og OpenPGP-nøkler. Du trenger din egen sikkerhetskopi for å bruke dem igjen.", + "confirm_plugins": "Data som programtillegg har lagret her", + "confirm_files": "Vedlegg som er åpnet for redigering, og filer som venter på å bli lagt ved", + "confirm_accounts": "Alle kontoer logges av", + "confirm_button": "Fjern alt" + }, "offlineDrafts": { "title": "Utkast på denne enheten", "description": "Disse utkastene er lagret på denne enheten. De går til serveren av seg selv så snart den kan nås.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5524a36da..296c240c3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privévensters kunnen geen mail bewaren voor offline gebruik.", "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, + "offlineWipe": { + "note": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Meld je aan om opnieuw te beginnen.", + "note_unsent": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Verwijderde onverzonden berichten: {count}.", + "settings_title": "Alles van dit apparaat verwijderen", + "settings_description": "Voor als je deze computer weggeeft of niet meer gebruikt. Je e-mail op de server blijft staan.", + "settings_button": "Alles verwijderen…", + "settings_wiping": "Verwijderen…", + "confirm_title": "Alles van dit apparaat verwijderen?", + "confirm_message": "Dit verwijdert wat deze app op dit apparaat bewaart, voor alle accounts hier. Je e-mail op de server blijft ongemoeid.", + "confirm_mail": "E-mail, agenda's, contacten en bestanden die hier bewaard zijn", + "confirm_unsent": "Onverzonden berichten die hier wachten: {count}. Die gaan verloren.", + "confirm_keys": "S/MIME- en OpenPGP-sleutels. Om ze weer te gebruiken heb je je eigen back-up nodig.", + "confirm_plugins": "Gegevens die plug-ins hier hebben opgeslagen", + "confirm_files": "Bijlagen die zijn geopend om te bewerken en bestanden die nog worden bijgevoegd", + "confirm_accounts": "Alle accounts worden afgemeld", + "confirm_button": "Alles verwijderen" + }, "offlineDrafts": { "title": "Concepten op dit apparaat", "description": "Deze concepten zijn op dit apparaat opgeslagen. Ze gaan vanzelf naar de server zodra die bereikbaar is.", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5b5481e..d9a95b794 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5763,6 +5763,23 @@ "store_private": "Okna prywatne nie mogą przechowywać poczty do użytku offline.", "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, + "offlineWipe": { + "note": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Zaloguj się, aby zacząć od nowa.", + "note_unsent": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Usunięte niewysłane wiadomości: {count}.", + "settings_title": "Usuń wszystko z tego urządzenia", + "settings_description": "Na wypadek, gdy oddajesz ten komputer lub przestajesz go używać. Twoja poczta na serwerze zostaje.", + "settings_button": "Usuń wszystko…", + "settings_wiping": "Usuwanie…", + "confirm_title": "Usunąć wszystko z tego urządzenia?", + "confirm_message": "Spowoduje to usunięcie tego, co ta aplikacja przechowuje na tym urządzeniu, dla wszystkich kont. Twoja poczta na serwerze pozostanie nietknięta.", + "confirm_mail": "Poczta, kalendarze, kontakty i pliki przechowywane tutaj", + "confirm_unsent": "Niewysłane wiadomości czekające tutaj: {count}. Zostaną utracone.", + "confirm_keys": "Klucze S/MIME i OpenPGP. Aby znów ich używać, potrzebujesz własnej kopii zapasowej.", + "confirm_plugins": "Dane zapisane tutaj przez wtyczki", + "confirm_files": "Załączniki otwarte do edycji i pliki czekające na dołączenie", + "confirm_accounts": "Wszystkie konta zostaną wylogowane", + "confirm_button": "Usuń wszystko" + }, "offlineDrafts": { "title": "Wersje robocze na tym urządzeniu", "description": "Te wersje robocze są zapisane na tym urządzeniu. Trafią na serwer same, gdy tylko będzie dostępny.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87a2e2ff1..a1e44c41f 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5763,6 +5763,23 @@ "store_private": "As janelas privadas não podem guardar correio para uso offline.", "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, + "offlineWipe": { + "note": "A sua organização removeu os dados desta conta deste dispositivo. Inicie sessão para começar de novo.", + "note_unsent": "A sua organização removeu os dados desta conta deste dispositivo. Mensagens não enviadas eliminadas: {count}.", + "settings_title": "Remover tudo deste dispositivo", + "settings_description": "Para quando oferecer este computador ou deixar de o usar. O seu correio no servidor mantém-se.", + "settings_button": "Remover tudo…", + "settings_wiping": "A remover…", + "confirm_title": "Remover tudo deste dispositivo?", + "confirm_message": "Isto elimina o que esta aplicação guarda neste dispositivo, para todas as contas aqui. O seu correio no servidor não é tocado.", + "confirm_mail": "Correio, calendários, contactos e ficheiros guardados aqui", + "confirm_unsent": "Mensagens não enviadas em espera aqui: {count}. Serão perdidas.", + "confirm_keys": "Chaves S/MIME e OpenPGP. Precisa da sua própria cópia de segurança para as voltar a usar.", + "confirm_plugins": "Dados que os plugins guardaram aqui", + "confirm_files": "Anexos abertos para edição e ficheiros à espera de serem anexados", + "confirm_accounts": "Todas as contas terminam sessão", + "confirm_button": "Remover tudo" + }, "offlineDrafts": { "title": "Rascunhos neste dispositivo", "description": "Estes rascunhos estão guardados neste dispositivo. Vão para o servidor por si assim que for possível contactá-lo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 80c31fa28..9e6b568d3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5763,6 +5763,23 @@ "store_private": "Ferestrele private nu pot păstra e-mailuri pentru utilizare offline.", "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, + "offlineWipe": { + "note": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Conectați-vă pentru a începe din nou.", + "note_unsent": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Mesaje netrimise șterse: {count}.", + "settings_title": "Elimină tot de pe acest dispozitiv", + "settings_description": "Pentru când dați acest computer altcuiva sau nu îl mai folosiți. E-mailurile de pe server rămân.", + "settings_button": "Elimină tot…", + "settings_wiping": "Se elimină…", + "confirm_title": "Eliminați tot de pe acest dispozitiv?", + "confirm_message": "Se șterge ceea ce această aplicație păstrează pe acest dispozitiv, pentru toate conturile de aici. E-mailurile de pe server nu sunt atinse.", + "confirm_mail": "E-mailuri, calendare, contacte și fișiere păstrate aici", + "confirm_unsent": "Mesaje netrimise care așteaptă aici: {count}. Se vor pierde.", + "confirm_keys": "Chei S/MIME și OpenPGP. Pentru a le folosi din nou aveți nevoie de propria copie de rezervă.", + "confirm_plugins": "Date stocate aici de pluginuri", + "confirm_files": "Atașamente deschise pentru editare și fișiere care așteaptă să fie atașate", + "confirm_accounts": "Toate conturile sunt deconectate", + "confirm_button": "Elimină tot" + }, "offlineDrafts": { "title": "Ciorne pe acest dispozitiv", "description": "Aceste ciorne sunt salvate pe acest dispozitiv. Ajung singure pe server imediat ce poate fi contactat.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 55510f4c8..748e8b21c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5763,6 +5763,23 @@ "store_private": "Приватные окна не могут хранить почту для работы офлайн.", "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, + "offlineWipe": { + "note": "Ваша организация удалила данные этой учётной записи с этого устройства. Войдите, чтобы начать заново.", + "note_unsent": "Ваша организация удалила данные этой учётной записи с этого устройства. Удалено неотправленных сообщений: {count}.", + "settings_title": "Удалить всё с этого устройства", + "settings_description": "На случай, если вы передаёте этот компьютер или больше им не пользуетесь. Ваша почта на сервере останется.", + "settings_button": "Удалить всё…", + "settings_wiping": "Удаление…", + "confirm_title": "Удалить всё с этого устройства?", + "confirm_message": "Будет удалено всё, что это приложение хранит на этом устройстве, для всех учётных записей здесь. Почта на сервере не будет затронута.", + "confirm_mail": "Почта, календари, контакты и файлы, хранящиеся здесь", + "confirm_unsent": "Неотправленных сообщений в ожидании: {count}. Они будут потеряны.", + "confirm_keys": "Ключи S/MIME и OpenPGP. Чтобы снова ими пользоваться, понадобится ваша собственная резервная копия.", + "confirm_plugins": "Данные, сохранённые здесь плагинами", + "confirm_files": "Вложения, открытые для редактирования, и файлы, ожидающие прикрепления", + "confirm_accounts": "Выполняется выход из всех учётных записей", + "confirm_button": "Удалить всё" + }, "offlineDrafts": { "title": "Черновики на этом устройстве", "description": "Эти черновики сохранены на этом устройстве. Они сами уйдут на сервер, как только он станет доступен.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e12b12d2..8d0de3334 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5763,6 +5763,23 @@ "store_private": "Súkromné okná nemôžu uchovávať poštu na používanie offline.", "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, + "offlineWipe": { + "note": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Prihláste sa a začnite znova.", + "note_unsent": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Odstránené neodoslané správy: {count}.", + "settings_title": "Odstrániť všetko z tohto zariadenia", + "settings_description": "Pre prípad, že tento počítač odovzdáte ďalej alebo ho prestanete používať. Vaša pošta na serveri zostane.", + "settings_button": "Odstrániť všetko…", + "settings_wiping": "Odstraňuje sa…", + "confirm_title": "Odstrániť všetko z tohto zariadenia?", + "confirm_message": "Tým sa odstráni to, čo táto aplikácia uchováva v tomto zariadení, pre všetky tunajšie účty. Vašej pošty na serveri sa to nedotkne.", + "confirm_mail": "Pošta, kalendáre, kontakty a súbory uložené tu", + "confirm_unsent": "Neodoslané správy čakajúce tu: {count}. Stratia sa.", + "confirm_keys": "Kľúče S/MIME a OpenPGP. Na ich ďalšie použitie budete potrebovať vlastnú zálohu.", + "confirm_plugins": "Údaje, ktoré sem uložili doplnky", + "confirm_files": "Prílohy otvorené na úpravy a súbory čakajúce na priloženie", + "confirm_accounts": "Všetky účty sa odhlásia", + "confirm_button": "Odstrániť všetko" + }, "offlineDrafts": { "title": "Koncepty v tomto zariadení", "description": "Tieto koncepty sú uložené v tomto zariadení. Na server sa odošlú samy, hneď ako bude dostupný.", diff --git a/locales/th/common.json b/locales/th/common.json index 0be3db9db..549007876 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2108,6 +2108,23 @@ "store_private": "หน้าต่างส่วนตัวเก็บอีเมลไว้ใช้แบบออฟไลน์ไม่ได้", "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, + "offlineWipe": { + "note": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ลงชื่อเข้าใช้เพื่อเริ่มใหม่", + "note_unsent": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ส่งที่ถูกลบ: {count}", + "settings_title": "นำทุกอย่างออกจากอุปกรณ์นี้", + "settings_description": "สำหรับเมื่อคุณจะยกคอมพิวเตอร์เครื่องนี้ให้ผู้อื่นหรือเลิกใช้ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "settings_button": "นำทุกอย่างออก…", + "settings_wiping": "กำลังนำออก…", + "confirm_title": "นำทุกอย่างออกจากอุปกรณ์นี้หรือไม่", + "confirm_message": "การทำเช่นนี้จะลบสิ่งที่แอปนี้เก็บไว้ในอุปกรณ์นี้ สำหรับทุกบัญชีที่นี่ อีเมลของคุณบนเซิร์ฟเวอร์จะไม่ถูกแตะต้อง", + "confirm_mail": "อีเมล ปฏิทิน รายชื่อ และไฟล์ที่เก็บไว้ที่นี่", + "confirm_unsent": "ข้อความที่ยังไม่ส่งที่รออยู่ที่นี่: {count} ข้อความเหล่านี้จะสูญหาย", + "confirm_keys": "คีย์ S/MIME และ OpenPGP คุณต้องมีข้อมูลสำรองของคุณเองเพื่อใช้อีกครั้ง", + "confirm_plugins": "ข้อมูลที่ปลั๊กอินเก็บไว้ที่นี่", + "confirm_files": "ไฟล์แนบที่เปิดเพื่อแก้ไขและไฟล์ที่รอแนบ", + "confirm_accounts": "ทุกบัญชีจะออกจากระบบ", + "confirm_button": "นำทุกอย่างออก" + }, "offlineDrafts": { "title": "ฉบับร่างในอุปกรณ์นี้", "description": "ฉบับร่างเหล่านี้บันทึกไว้ในอุปกรณ์นี้ และจะส่งไปยังเซิร์ฟเวอร์เองเมื่อเชื่อมต่อได้", diff --git a/locales/tr/common.json b/locales/tr/common.json index fa74d9864..f10139b57 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5763,6 +5763,23 @@ "store_private": "Gizli pencereler e-postaları çevrimdışı kullanım için saklayamaz.", "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, + "offlineWipe": { + "note": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Yeniden başlamak için oturum açın.", + "note_unsent": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Silinen gönderilmemiş iletiler: {count}.", + "settings_title": "Bu cihazdan her şeyi kaldır", + "settings_description": "Bu bilgisayarı başkasına verdiğinizde veya kullanmayı bıraktığınızda. Sunucudaki postalarınız kalır.", + "settings_button": "Her şeyi kaldır…", + "settings_wiping": "Kaldırılıyor…", + "confirm_title": "Bu cihazdan her şey kaldırılsın mı?", + "confirm_message": "Bu işlem, bu uygulamanın buradaki tüm hesaplar için bu cihazda tuttuklarını siler. Sunucudaki postalarınıza dokunulmaz.", + "confirm_mail": "Burada tutulan postalar, takvimler, kişiler ve dosyalar", + "confirm_unsent": "Burada bekleyen gönderilmemiş iletiler: {count}. Kaybolacaklar.", + "confirm_keys": "S/MIME ve OpenPGP anahtarları. Yeniden kullanmak için kendi yedeğiniz gerekir.", + "confirm_plugins": "Eklentilerin burada sakladığı veriler", + "confirm_files": "Düzenlemek için açılan ekler ve eklenmeyi bekleyen dosyalar", + "confirm_accounts": "Tüm hesapların oturumu kapatılır", + "confirm_button": "Her şeyi kaldır" + }, "offlineDrafts": { "title": "Bu cihazdaki taslaklar", "description": "Bu taslaklar bu cihaza kaydedildi. Sunucuya ulaşılır ulaşılmaz kendiliğinden gönderilir.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 391a5c203..752fcbec6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5763,6 +5763,23 @@ "store_private": "Приватні вікна не можуть зберігати пошту для роботи офлайн.", "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, + "offlineWipe": { + "note": "Ваша організація видалила дані цього облікового запису з цього пристрою. Увійдіть, щоб почати знову.", + "note_unsent": "Ваша організація видалила дані цього облікового запису з цього пристрою. Видалено ненадісланих повідомлень: {count}.", + "settings_title": "Видалити все з цього пристрою", + "settings_description": "На випадок, якщо ви передаєте цей комп'ютер або більше ним не користуєтеся. Ваша пошта на сервері залишиться.", + "settings_button": "Видалити все…", + "settings_wiping": "Видалення…", + "confirm_title": "Видалити все з цього пристрою?", + "confirm_message": "Буде видалено все, що цей застосунок зберігає на цьому пристрої, для всіх облікових записів тут. Пошту на сервері це не зачепить.", + "confirm_mail": "Пошта, календарі, контакти та файли, що зберігаються тут", + "confirm_unsent": "Ненадісланих повідомлень в очікуванні: {count}. Вони будуть втрачені.", + "confirm_keys": "Ключі S/MIME та OpenPGP. Щоб знову ними користуватися, потрібна ваша власна резервна копія.", + "confirm_plugins": "Дані, збережені тут плагінами", + "confirm_files": "Вкладення, відкриті для редагування, і файли, що очікують прикріплення", + "confirm_accounts": "Вихід з усіх облікових записів", + "confirm_button": "Видалити все" + }, "offlineDrafts": { "title": "Чернетки на цьому пристрої", "description": "Ці чернетки збережено на цьому пристрої. Вони самі підуть на сервер, щойно він стане доступним.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 347fe2cd0..d5fd42356 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5775,6 +5775,23 @@ "store_private": "Cửa sổ riêng tư không thể giữ thư để dùng ngoại tuyến.", "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, + "offlineWipe": { + "note": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Hãy đăng nhập để bắt đầu lại.", + "note_unsent": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Số thư chưa gửi đã xóa: {count}.", + "settings_title": "Xóa mọi thứ khỏi thiết bị này", + "settings_description": "Dành cho khi bạn tặng máy tính này hoặc không dùng nữa. Thư của bạn trên máy chủ vẫn còn.", + "settings_button": "Xóa mọi thứ…", + "settings_wiping": "Đang xóa…", + "confirm_title": "Xóa mọi thứ khỏi thiết bị này?", + "confirm_message": "Thao tác này xóa những gì ứng dụng này lưu trên thiết bị này, cho mọi tài khoản ở đây. Thư của bạn trên máy chủ không bị ảnh hưởng.", + "confirm_mail": "Thư, lịch, danh bạ và tệp được lưu ở đây", + "confirm_unsent": "Số thư chưa gửi đang chờ ở đây: {count}. Chúng sẽ bị mất.", + "confirm_keys": "Khóa S/MIME và OpenPGP. Bạn cần bản sao lưu của riêng mình để dùng lại.", + "confirm_plugins": "Dữ liệu mà các tiện ích đã lưu ở đây", + "confirm_files": "Tệp đính kèm mở để chỉnh sửa và tệp đang chờ đính kèm", + "confirm_accounts": "Tất cả tài khoản sẽ đăng xuất", + "confirm_button": "Xóa mọi thứ" + }, "offlineDrafts": { "title": "Bản nháp trên thiết bị này", "description": "Các bản nháp này được lưu trên thiết bị này. Chúng sẽ tự chuyển lên máy chủ ngay khi kết nối được.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 97dacbb06..79472b28c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5763,6 +5763,23 @@ "store_private": "私密視窗無法保留供離線使用的郵件。", "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, + "offlineWipe": { + "note": "您的組織已從此裝置移除此帳號的資料。請登入以重新開始。", + "note_unsent": "您的組織已從此裝置移除此帳號的資料。已刪除的未傳送郵件:{count} 封。", + "settings_title": "從此裝置移除所有內容", + "settings_description": "用於將這台電腦送人或不再使用時。伺服器上的郵件會保留。", + "settings_button": "移除所有內容…", + "settings_wiping": "正在移除…", + "confirm_title": "要從此裝置移除所有內容嗎?", + "confirm_message": "這會刪除此應用程式為這裡所有帳號儲存在此裝置上的內容。伺服器上的郵件不受影響。", + "confirm_mail": "儲存在這裡的郵件、行事曆、聯絡人和檔案", + "confirm_unsent": "在這裡等待的未傳送郵件:{count} 封。它們將遺失。", + "confirm_keys": "S/MIME 和 OpenPGP 金鑰。要再次使用,需要您自己的備份。", + "confirm_plugins": "外掛程式儲存在這裡的資料", + "confirm_files": "為編輯而開啟的附件和等待附加的檔案", + "confirm_accounts": "所有帳號都將登出", + "confirm_button": "移除所有內容" + }, "offlineDrafts": { "title": "此裝置上的草稿", "description": "這些草稿儲存在此裝置上。一旦能連線到伺服器,就會自動上傳。", diff --git a/locales/zh/common.json b/locales/zh/common.json index de9e40f3c..734a373bf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5763,6 +5763,23 @@ "store_private": "隐私窗口无法保留供离线使用的邮件。", "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, + "offlineWipe": { + "note": "您的组织已从此设备移除此帐户的数据。请登录以重新开始。", + "note_unsent": "您的组织已从此设备移除此帐户的数据。已删除的未发送邮件:{count} 封。", + "settings_title": "从此设备移除所有内容", + "settings_description": "用于将这台电脑送人或不再使用时。服务器上的邮件会保留。", + "settings_button": "移除所有内容…", + "settings_wiping": "正在移除…", + "confirm_title": "要从此设备移除所有内容吗?", + "confirm_message": "这会删除此应用为这里所有帐户保存在此设备上的内容。服务器上的邮件不受影响。", + "confirm_mail": "保存在这里的邮件、日历、联系人和文件", + "confirm_unsent": "在这里等待的未发送邮件:{count} 封。它们将丢失。", + "confirm_keys": "S/MIME 和 OpenPGP 密钥。要再次使用,需要您自己的备份。", + "confirm_plugins": "插件保存在这里的数据", + "confirm_files": "为编辑而打开的附件和等待添加的文件", + "confirm_accounts": "所有帐户都将退出登录", + "confirm_button": "移除所有内容" + }, "offlineDrafts": { "title": "此设备上的草稿", "description": "这些草稿保存在此设备上。一旦能连接服务器,就会自动上传。", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 9396192d3..088f3533a 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -100,6 +100,8 @@ export const LITE_API_STRING_ALLOWLIST = [ "/api/plugin-secrets", // stores/settings-store.ts - settingsSyncEnabled is pinned off. "/api/settings", + // lib/devices/client.ts - offline mode and its wipes aren't in Lite (no OfflineHost, IS_LITE guards in the auth store). + "/api/devices", // Stalwart's own account discovery (lib/auth/lite-oauth.ts), not a route of this app. "/api/discover", // OAuth / SSO / pairing - Lite discovers and redeems codes in the browser diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts new file mode 100644 index 000000000..3c9746234 --- /dev/null +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -0,0 +1,271 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Where the auth store meets wipes: the check at a start comes before any local +// data, a login the server ended fires the hook on every path, and a password +// it keeps refusing signs out and starts a wait instead of wiping. + +const clients = vi.hoisted(() => ({ + connect: (async () => {}) as (client: unknown) => Promise, + made: [] as Array<{ refused?: (ms: number) => void }>, +})); + +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + refused?: (ms: number) => void; + constructor(public serverUrl: string, public username: string) { clients.made.push(this); } + static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username); } + static fromOfflineSession(serverUrl: string, username: string) { return new JMAPClient(serverUrl, username); } + async connect() { await clients.connect(this); } + getAuthHeader() { return 'Basic x'; } + onConnectionChange() {} + onRateLimit() {} + onCredentialsRefused(callback: (ms: number) => void) { this.refused = callback; } + getRateLimitRemainingMs() { return 0; } + disconnect() {} + getUsername() { return this.username; } + async getIdentities() { return []; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + getSharedAccounts() { return []; } + getAccountInfo() { return undefined; } + } + class RateLimitError extends Error {} + class RequestTimeoutError extends Error {} + return { JMAPClient, RateLimitError, RequestTimeoutError, generateMessageId: () => '' }; +}); + +const order = vi.hoisted(() => [] as string[]); +const gate = vi.hoisted(() => ({ run: (async () => {}) as () => Promise })); +vi.mock('@/lib/offline/device-wipe', () => ({ + runDeviceGate: async () => { order.push('gate'); await gate.run(); order.push('gate done'); }, +})); +vi.mock('@/lib/offline/offline-mode', () => ({ + registerOfflineHooks: () => {}, + offlineSessionFor: async () => { order.push('local copy'); return {}; }, + markStartedOffline: () => {}, +})); + +const heard = vi.hoisted(() => [] as string[]); +vi.mock('@/lib/offline/credentials-rejected', () => ({ + noteCredentialsRejected: (account: string) => { heard.push(account); }, + onCredentialsRejected: () => () => {}, +})); + +import { readRefusedLogins, CREDENTIALS_REFUSED_CONFIRM_MS, markWipePending, clearWipePending } from '@/lib/devices/wipe-state'; + +// Fresh for each test: a sign-out leaves slots closing in the module for a while. +let useOfflineStore: typeof import('@/lib/offline/state').useOfflineStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; + +function account(id: string, slot: number, authMode: 'basic' | 'oauth') { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode, rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: false, hasError: false, isDefault: slot === 0, cookieSlot: slot, avatarColor: '#000', + }; +} + +type Answer = (url: string, method: string) => Response | null; +let answer: Answer; +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +beforeEach(async () => { + vi.resetModules(); + ({ useOfflineStore } = await import('@/lib/offline/state')); + ({ useAccountStore } = await import('@/stores/account-store')); + ({ useAuthStore } = await import('@/stores/auth-store')); + const nav = await import('@/lib/browser-navigation'); + localStorage.clear(); + sessionStorage.clear(); + order.length = 0; + heard.length = 0; + clients.made.length = 0; + clients.connect = async () => {}; + gate.run = async () => {}; + answer = () => null; + vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { + const url = String(input); + const given = answer(url, init?.method ?? 'GET'); + if (given) return given; + if (url.includes('/api/auth/session') && init?.method === 'PUT') return json(200, { serverUrl: 'https://mail.example.test', username: 'alice', password: 'p' }); + if (url.includes('/api/auth/token') && init?.method === 'PUT') return json(200, { access_token: 't', expires_in: 3600 }); + return json(200, {}); + })); + useOfflineStore.setState({ enabled: [] }); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null, client: null }); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null } as never); +}, 60_000); + +describe('a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('with mail kept here checks for a wipe before it reads the local copy', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + expect(order.slice(0, 3)).toEqual(['gate', 'gate done', 'local copy']); + }); + + it('with no mail here and nothing waiting doesn\'t load any of it', async () => { + await useAuthStore.getState().checkAuth(); + expect(order).toEqual([]); + }); + + it('with a wipe cut short looks too, with offline switched off by then', async () => { + markWipePending(ALICE, 'admin'); + await useAuthStore.getState().checkAuth(); + clearWipePending(ALICE); + expect(order).toContain('gate'); + }); + + it('in the desktop app on an instance looks too', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + await useAuthStore.getState().checkAuth(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(order).toContain('gate'); + }); + + it('shows nothing of an account the check removed, and stops when none is left', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { + useAccountStore.setState({ accounts: [], activeAccountId: null } as never); + }; + await useAuthStore.getState().checkAuth(); + expect(order).not.toContain('local copy'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().isLoading).toBe(false); + expect(clients.made).toEqual([]); + }); + + it('goes on when the check itself breaks', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { throw new Error('no'); }; + await useAuthStore.getState().checkAuth(); + expect(order).toContain('local copy'); + }); +}); + +describe('a login the server ended, at a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('invalid_grant fires the hook', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([ALICE]); + }); + + it('the session limit fires the hook, for a password login too', async () => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([ALICE]); + }); + + it.each([ + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ['a 401 it can\'t read', () => new Response('nope', { status: 401 })], + ['a 500', () => json(500, {})], + ['a 503', () => json(503, {})], + ])('%s doesn\'t', async (_name, make) => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? make() : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + }); + + it('a missing session cookie doesn\'t', async () => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'No session' }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + }); +}); + +describe('a login the server ended, at a switch', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('invalid_grant fires the hook and takes the account out for good', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([BOB]); + expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + }); + + it('another refusal doesn\'t', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'Refresh failed' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([]); + }); +}); + +describe('a password the server keeps refusing', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + }); + + it('at a start: the wait begins, and no wipe', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + throw new Error('Invalid username or password'); + }; + await useAuthStore.getState().checkAuth(); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(heard).toEqual([]); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + it('at a start, with a server that only failed: no wait', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async () => { throw new Error('Failed to get session: 500'); }; + await useAuthStore.getState().checkAuth(); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); + + describe('while running', () => { + async function running(): Promise<{ refused: (ms: number) => void }> { + useOfflineStore.setState({ enabled: [] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().client).not.toBeNull()); + const client = useAuthStore.getState().client as unknown as { refused: (ms: number) => void }; + return client; + } + + it('one answer changes nothing', async () => { + const client = await running(); + client.refused(0); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS - 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeDefined(); + expect(readRefusedLogins()).toEqual({}); + }); + + it('lasting a minute signs out and starts the wait, and fires no hook', async () => { + const client = await running(); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(heard).toEqual([]); + expect(sessionStorage.getItem('session_expired')).toBe('true'); + }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..f88d6530b 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -37,6 +37,7 @@ import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; import { readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; @@ -790,12 +791,50 @@ export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: st return target ? target.cookieSlot : null; } +/** Accounts the server refused during a connect that hasn't reported back yet. */ +const refusedAtConnect = new Set(); +const endingRefused = new Set(); + +/** + * The server kept refusing this login, after a fresh try each time. The account + * signs out and its data waits a day for a sign-in with the new password + * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). It has no parsed + * refusal like `invalid_grant` to go by, so nothing goes at once. + */ +async function endRefusedLogin(accountId: string, get: () => AuthState): Promise { + const account = useAccountStore.getState().getAccountById(accountId); + if (!account || endingRefused.has(accountId)) return; + endingRefused.add(accountId); + try { + markLoginRefused(accountId); + if (get().activeAccountId !== accountId) { + get().removeAccount(accountId, 'evicted'); + return; + } + await saveDraftsBeforeExpiry(account.cookieSlot ?? 0); + notifyParent('sso:session-expired'); + markSessionExpired(); + await get().logout({ endProviderSession: false, keepLocalData: true }); + } finally { + endingRefused.delete(accountId); + } +} + function bindClientStatusHandlers( client: IJMAPClient, set: (state: Partial) => void, get: () => AuthState, accountId?: string, ): void { + client.onCredentialsRefused?.((refusedForMs) => { + // A pop-out is on another window's session. Lite keeps no data to wait. + if (!accountId || IS_LITE || isPopoutWindow()) return; + refusedAtConnect.add(accountId); + // Still connecting: the restore that started this decides, when it fails. + if (clients.get(accountId) !== client) return; + if (refusedForMs >= CREDENTIALS_REFUSED_CONFIRM_MS) void endRefusedLogin(accountId, get); + }); + client.onConnectionChange((connected) => { if (!accountId || get().activeAccountId === accountId) { set({ connectionLost: !connected }); @@ -2670,6 +2709,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; + let targetLoginOver = false; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2686,6 +2726,7 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); + if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); @@ -2702,6 +2743,7 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); + if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); @@ -2753,8 +2795,11 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login + if (targetLoginOver) noteCredentialsRejected(accountId); + else if (refusedAtConnect.has(accountId)) markLoginRefused(accountId); + refusedAtConnect.delete(accountId); evictAccount(accountId); - accountStore.removeAccount(accountId, 'evicted'); + accountStore.removeAccount(accountId, targetLoginOver ? 'explicit' : 'evicted'); void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth'); // Restore the previous account if still available @@ -2938,6 +2983,17 @@ export const useAuthStore = create()( await import('@/lib/offline/offline-mode').then((offline) => offline.registerOfflineHooks()).catch(() => {}); } + // A wipe comes before anything from this device is shown: one a closed + // window cut short, and what the instance has to say, briefly. + if (!IS_LITE && (useOfflineStore.getState().enabled.length > 0 || getDesktopBridge()?.mode === 'instance' || wipeWorkWaiting())) { + await import('@/lib/offline/device-wipe').then((wipe) => wipe.runDeviceGate()).catch(() => {}); + accounts = useAccountStore.getState().accounts; + if (accounts.length === 0) { + set({ isLoading: false, isAuthenticated: false, client: null, activeAccountId: null }); + return; + } + } + // Determine which account to activate first const defaultAccount = accountStore.getDefaultAccount(); const activeId = get().activeAccountId; @@ -3013,7 +3069,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw new Error(`Session cookie missing: ${res.status}`); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { loginOver: await loginReallyOver(res) }); } } } catch (err) { @@ -3051,8 +3107,12 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its // local data waits, unless the server really ended the login. + const loginOver = (err as { loginOver?: boolean }).loginOver === true; + if (loginOver) noteCredentialsRejected(account.id); + else if (refusedAtConnect.has(account.id)) markLoginRefused(account.id); + refusedAtConnect.delete(account.id); evictAccount(account.id); - accountStore.removeAccount(account.id, (err as { loginOver?: boolean }).loginOver ? 'explicit' : 'evicted'); + accountStore.removeAccount(account.id, loginOver ? 'explicit' : 'evicted'); void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth'); } }; From ae6450e84de61ec8f5005959a58def04f536cace Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:13 +0100 Subject: [PATCH 19/36] fix: write the desktop app's English menus in sentence case like the rest of the app --- locales/en/common.json | 50 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 6112c3ddd..361962e6e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6567,10 +6567,10 @@ "go": "Go", "window": "Window", "help": "Help", - "new_window": "New Window", + "new_window": "New window", "print": "Print…", "save_pdf": "Save as PDF…", - "switch_connection": "Change Connection…", + "switch_connection": "Change connection…", "close": "Close", "quit": "Quit", "undo": "Undo", @@ -6578,28 +6578,28 @@ "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", + "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", + "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", + "next_window": "Next window", + "shortcuts": "Keyboard shortcuts", "docs": "Documentation", "settings": "Settings…", "about": "About Bulwark", @@ -6607,13 +6607,13 @@ "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" + "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", From 4e86e8189a626a54dfe9e0df0302cd0461d9be54 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:32:14 +0100 Subject: [PATCH 20/36] fix: stop automatic SSO towards a server off the device's list and say why in OAuth-only mode --- .../login-page-desktop-policy.test.tsx | 112 ++++++++++++++++++ app/(main)/[locale]/login/page.tsx | 17 +-- lib/desktop/policy-schema.ts | 2 +- 3 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx new file mode 100644 index 000000000..de689d054 --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; +import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema'; + +// The device's OS policy on the two ways into SSO: nothing starts towards a refused server. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const configOverrides = vi.hoisted(() => ({ value: {} as Record })); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + oauthEnabled: true, + oauthOnly: true, + oauthClientId: 'webmail', + ...configOverrides.value, + }), +})); + +const { default: LoginPage } = await import('../page'); + +const METADATA = { + issuer: 'https://mail.example.org', + authorization_endpoint: 'https://mail.example.org/authorize', + token_endpoint: 'https://mail.example.org/token', +}; + +function allowOnly(...servers: string[]) { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AllowedJmapServers: servers } }); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'standalone', + policy: { get: () => snapshot, onChange: () => () => {} }, + } as unknown as BulwarkDesktopBridge; +} + +let fetched: string[]; + +beforeEach(() => { + configOverrides.value = {}; + fetched = []; + sessionStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + fetched.push(url); + if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 }); + if (url.includes('/api/auth/sso/start')) return new Response(JSON.stringify({ authorize_url: 'about:blank#sso' }), { status: 200 }); + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + window.bulwarkDesktop = undefined; +}); + +const ssoButton = () => screen.findByRole('button', { name: /sign_in_sso/ }, { timeout: 10000 }); +const startedPkce = () => sessionStorage.getItem('oauth_code_verifier'); +const startedSso = () => fetched.some((url) => url.includes('/api/auth/sso/start')); + +describe('the SSO button', () => { + it('starts nothing towards a server off the list, and says why', async () => { + allowOnly('https://other.example.org'); + render(); + fireEvent.click(await ssoButton()); + expect(await screen.findByText('server_refused')).toBeInTheDocument(); + expect(startedPkce()).toBeNull(); + }, 30_000); + + it('starts as usual towards a server on the list', async () => { + allowOnly('https://mail.example.org'); + render(); + fireEvent.click(await ssoButton()); + await waitFor(() => expect(startedPkce()).not.toBeNull()); + expect(screen.queryByText('server_refused')).toBeNull(); + }, 30_000); +}); + +describe('automatic SSO', () => { + it('asks the server for nothing when its server is off the list, and says why', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://other.example.org'); + render(); + expect(await screen.findByText('server_refused', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(startedSso()).toBe(false); + }, 30_000); + + it('starts as usual when its server is on the list', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://mail.example.org'); + render(); + await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 }); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bd29046e7..4b1f3f439 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -640,7 +640,7 @@ function LoginPageContent() { const startServerSideSso = useCallback(async () => { // The device's OS policy, as for a password: nothing starts towards a refused server. - if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) { + if (serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) { setDesktopPolicyNotice(tDesktopPolicy("server_refused")); return; } @@ -700,7 +700,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); + }, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight @@ -1174,6 +1174,12 @@ function LoginPageContent() { // has to pick which one SSO targets before the button starts the flow, since // the selection drives OAuth discovery and the server_id sent to the callback // (issue #799). + const desktopPolicyAlert = desktopPolicyNotice && ( +

+ {desktopPolicyNotice} +

+ ); + const serverPicker = hasServerList && jmapServers.length > 1 ? (
{/* Mountain View: the account actions as rows closing the white card, diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx new file mode 100644 index 000000000..f2cf3373e --- /dev/null +++ b/components/offline/__tests__/device-wipe-host.test.tsx @@ -0,0 +1,62 @@ +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The one toast when an account beside this one signs out by itself. + +const warning = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { warning } })); +vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} })); + +const { DeviceWipeHost } = await import('../device-wipe-host'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state'); +const { useOfflineStore, markEvicted } = await import('@/lib/offline/state'); + +const A = 'a@example.com'; +const B = 'b@example.com'; + +beforeEach(() => { + warning.mockClear(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [A, B] }); + useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never); +}); + +function refuseB(): void { + markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B }); + markEvicted(B); +} + +describe('the toast', () => { + it('comes once, when the count is in, and names the account, the count and the date', () => { + render(); + act(() => refuseB()); + expect(warning).not.toHaveBeenCalled(); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][0]).toBe('sibling_waiting'); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + }); + + it('says the other thing with nothing unsent, or no number', () => { + render(); + act(() => { refuseB(); setRefusedCount(B, null); }); + expect(warning.mock.calls[0][0]).toBe('sibling_kept'); + }); + + it('isn\'t for someone signed in later', () => { + useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never); + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).not.toHaveBeenCalled(); + }); + + it('shows at the next page for the one it was meant for, if it got no chance before', () => { + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx index 3816ca38d..405e94a82 100644 --- a/components/offline/device-wipe-host.tsx +++ b/components/offline/device-wipe-host.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { consumeWipeNote, type WipeNote } from '@/lib/devices/wipe-state'; +import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state'; import { startDeviceWipe } from '@/lib/offline/device-wipe'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/stores/toast-store'; @@ -33,5 +33,17 @@ export function DeviceWipeHost() { if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 }); }, [signedIn]); + // An account beside this one signed out by itself: once, when it's known what waits. + useEffect(() => { + const look = () => { + for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) { + const values = { name: wait.label, date: waitDate(wait.until) }; + toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 }); + } + }; + look(); + return onRefusedLoginsChanged(look); + }, []); + return null; } diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx index cad5348f1..887ccbf2a 100644 --- a/components/session/__tests__/session-timeout-guard.test.tsx +++ b/components/session/__tests__/session-timeout-guard.test.tsx @@ -184,7 +184,7 @@ describe('session timeout guard', () => { await advance(500); expect(order).toEqual(['draft saved', 'signed out']); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -196,7 +196,7 @@ describe('session timeout guard', () => { await advance(DRAFT_FLUSH_TIMEOUT_MS - 1); expect(logoutAll).not.toHaveBeenCalled(); await advance(1); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }); it('uses the strictest of the signed-in accounts', async () => { @@ -258,7 +258,7 @@ describe('session timeout guard', () => { expect(screen.queryByText('max_title')).toBeNull(); await advance(60 * SECOND); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 }); }); @@ -276,7 +276,7 @@ describe('session timeout guard', () => { fireEvent.pointerDown(window); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -319,7 +319,7 @@ describe('session timeout guard', () => { // Later in the body than the overlay, so it paints on top at a higher z-index too. expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await advance(30 * SECOND); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); } finally { overlay.remove(); hasFocus.mockRestore(); diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx index 08b0dd983..c208a810a 100644 --- a/components/session/session-timeout-guard.tsx +++ b/components/session/session-timeout-guard.tsx @@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n * (policy.json `organisation.session`). Activity in any Bulwark tab keeps * every tab signed in; a warning counts down first; at zero the open and * minimised composers save their drafts and every account signs out, with - * the reason on the login page. + * the reason on the login page. What the accounts keep on the device hides until + * the next sign-in, unless the organisation asks for deletion at once. */ export function SessionTimeoutGuard() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -149,7 +150,8 @@ export function SessionTimeoutGuard() { rememberSessionEnd(endReason(kind, current, false)); const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS); if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true)); - await useAuthStore.getState().logoutAll({ endProviderSession: false }); + // Nobody asked: the mail hides until the next sign-in, and unsent messages with it. + await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }, []); // The clock, while there is a limit to count. diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts index 1b9ba98e4..1364b243b 100644 --- a/lib/admin/__tests__/server-device-offline-caps.test.ts +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy'; const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } }); -describe('wipeOnRefusedLogin', () => { +describe('wipeOnForcedSignOut', () => { it('is off unless asked for, and survives the sanitizer', () => { - expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true }); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true); + expect(DEFAULT_POLICY.organisation?.devices?.wipeOnForcedSignOut ?? false).toBe(false); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: true } }).devices).toMatchObject({ wipeOnForcedSignOut: true }); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: 'yes' } }).devices?.wipeOnForcedSignOut).not.toBe(true); }); it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => { - expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false); + expect(ALL_DEVICE_LIMITS.wipeOnForcedSignOut).toBe(false); }); }); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index e8e6f5d4f..0fe813574 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -176,11 +176,11 @@ export interface OrganisationPolicy { /** All attachments and files of one account on a device, in MB. 0 is no cap. */ offlineMaxTotalMb: number; /** - * A device deletes an account's offline data as soon as the server refuses its - * login, instead of hiding it for 30 days until the new password. Unsent mail - * goes with it. + * A device deletes an account's offline data as soon as it signs out with nobody + * asking (the server refused the login, or the idle or session limit ran out), + * instead of hiding it for 30 days until the next sign-in. Unsent mail goes with it. */ - wipeOnRefusedLogin: boolean; + wipeOnForcedSignOut: boolean; }; calendars: { /** Adds up across scopes by id. */ @@ -206,7 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false, offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0, - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, }, calendars: { feeds: [], freeBusyGroup: '' }, }; @@ -551,7 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial [key, typeof off === 'number' ? 1 : true]), ), - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, } as ServerDevicePolicy; /** diff --git a/lib/devices/types.ts b/lib/devices/types.ts index 367b6bef7..f4e5facf7 100644 --- a/lib/devices/types.ts +++ b/lib/devices/types.ts @@ -3,8 +3,12 @@ * admin and what a device says about its own wipe. No secrets, no store. */ -/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */ -export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const; +/** + * Who asked for a wipe. `rejected`: the server refused the stored login, and + * `session`: the idle or session limit ran out, both where the organisation asks + * for deletion at once. + */ +export const WIPE_REASONS = ['admin', 'user', 'rejected', 'session'] as const; export type WipeReason = (typeof WIPE_REASONS)[number]; export interface DeviceView { diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts index 2b8d9b749..5e4e97f82 100644 --- a/lib/devices/wipe-state.ts +++ b/lib/devices/wipe-state.ts @@ -4,6 +4,7 @@ * is a count, a row id and the secret for that one row. */ +import type { ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state'; import type { WipeReason } from './types'; @@ -107,7 +108,7 @@ export function consumeWipeNote(): WipeNote | null { return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent }; } -// ---- A refused login: what waits for the new password. A count, never subjects ---- +// ---- A login ended with nobody asking: what waits for the next sign-in. A count, never subjects ---- export interface RefusedLogin { at: number; @@ -115,6 +116,23 @@ export interface RefusedLogin { unsent: number | null; /** Not one bad answer at a start: what a deletion the organisation asked for needs. */ certain: boolean; + /** Absent: `refused`. */ + kind?: ForcedSignOutKind; + /** The count has been asked for, whatever came back. */ + counted?: boolean; + /** Accounts signed in beside it at the time: they may see its name, and nobody else does. */ + seenBy?: string[]; + label?: string; + username?: string; + toasted?: boolean; +} + +export interface RefusedLoginMark { + certain?: boolean; + kind?: ForcedSignOutKind; + seenBy?: string[]; + label?: string; + username?: string; } export function readRefusedLogins(): Record { @@ -134,14 +152,24 @@ function writeRefused(refused: Record): void { for (const listener of [...refusedListeners]) listener(); } -export function markLoginRefused(account: string, certain = true, now = Date.now()): void { +export function markLoginRefused(account: string, mark: RefusedLoginMark = {}, now = Date.now()): void { const before = readRefusedLogins(); - writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } }); + const { certain = true, kind = 'refused', seenBy, label, username } = mark; + writeRefused({ + ...before, + [account]: { + at: before[account]?.at ?? now, + unsent: null, + certain: certain || before[account]?.certain === true, + kind, + ...(seenBy?.length ? { seenBy, label, username } : {}), + }, + }); } export function setRefusedCount(account: string, unsent: number | null): void { const before = readRefusedLogins(); - if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } }); + if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent, counted: true } }); } export function dropRefusedLogin(account: string): void { @@ -155,6 +183,7 @@ export interface RefusedWait { unsent: number | null; /** When the data goes if nobody signs in. */ until: number; + kind: ForcedSignOutKind; } /** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */ @@ -162,7 +191,51 @@ export function refusedWaits(): RefusedWait[] { const evicted = readEvicted(); return Object.entries(readRefusedLogins()) .filter(([account, wait]) => account in evicted && wait.unsent !== 0) - .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS })); + .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS, kind: wait.kind ?? 'refused' })); +} + +export interface SiblingWait extends RefusedWait { + account: string; + label: string; + username: string; +} + +function asSiblingWait(account: string, wait: RefusedLogin, evictedAt: number): SiblingWait { + return { + account, + label: wait.label ?? account, + username: wait.username ?? '', + unsent: wait.unsent, + until: evictedAt + EVICTED_KEEP_MS, + kind: wait.kind ?? 'refused', + }; +} + +/** For the account switcher: accounts that left while `active` was signed in beside them. Nobody else sees their names. */ +export function siblingWaits(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + return Object.entries(readRefusedLogins()) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active)) + .map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** Once each: what the toast says when such an account leaves. Waits for the count, so it can say it. */ +export function takeSiblingToasts(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + const refused = readRefusedLogins(); + const due = Object.entries(refused) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active) && wait.counted && !wait.toasted); + if (due.length === 0) return []; + writeRefused({ ...refused, ...Object.fromEntries(due.map(([account, wait]) => [account, { ...wait, toasted: true }])) }); + return due.map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** A date for these notes, in the page's language. */ +export function waitDate(until: number): string { + const language = typeof document === 'undefined' ? undefined : document.documentElement.lang || undefined; + return new Date(until).toLocaleDateString(language, { dateStyle: 'medium' }); } // ---- Plugin databases a running frame kept open: tried again at the next load ---- diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index 8118687a6..96a0369b2 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -447,14 +447,14 @@ describe('check-ins while running', () => { /** What the auth store does around the hook. */ function refuse(account = ALICE, certain = true): void { - wipeState.markLoginRefused(account, certain); + wipeState.markLoginRefused(account, { certain }); rejected.noteCredentialsRejected(account, certain); useAccountStore.getState().removeAccount(account, 'evicted'); } async function policyAsksForDeletion(): Promise { const { usePolicyStore } = await import('@/stores/policy-store'); - const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } }; + const policy = { organisation: { devices: { wipeOnForcedSignOut: true } } }; usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never); } @@ -496,7 +496,7 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { // What the sign-in page says, in numbers only. const [wait] = wipeState.refusedWaits(); - expect(wait).toEqual({ unsent: 1, until: expect.any(Number) }); + expect(wait).toEqual({ unsent: 1, until: expect.any(Number), kind: 'refused' }); expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000); expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"'); @@ -682,15 +682,16 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); }); - it('the session limit stays out of it: no hook, and the data waits', async () => { + it('the session limit hides too, as what it is, not as a refusal', async () => { wipe.registerDeviceWipe(); token(() => json(401, { error: 'session_expired', maxHours: 8 })); await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); - expect(heard).toEqual([]); + expect(heard).toEqual([ALICE]); await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); expect(purge.files).toBe(0); expect(store.removed).toEqual([]); - expect(wipeState.readRefusedLogins()).toEqual({}); + expect(wipeState.readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session' }); }); }); }); @@ -1146,3 +1147,127 @@ describe('what else goes with a wipe', () => { expect(unsubscribed).toBe(0); }); }); + +describe('the session timers', () => { + const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); + + it('leave the outbox whole, and a sign-in brings it back', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const lost = await import('../lost'); + const at = Date.now(); + await timerSignOut(); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(state.isEvicted(ALICE)).toBe(true); + expect(state.visibleAccounts()).toEqual([]); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(lost.consumeLost()).toEqual([]); + await vi.waitFor(() => expect(wipeState.refusedWaits()).toEqual([{ unsent: 1, until: expect.any(Number), kind: 'session' }])); + + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 14 * DAY); + expect(purge.files).toBe(0); + + useAccountStore.getState().addAccount(entry(ALICE, 0) as never); + expect(state.visibleAccounts()).toEqual([ALICE]); + expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + expect(wipeState.refusedWaits()).toEqual([]); + await wipe.sweepRefusedLogins(); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); + + it('with no sign-in for 30 days it goes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + await timerSignOut(); + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 31 * DAY); + expect(purge.files).toBeGreaterThan(0); + }); + + it('keep S/MIME and OpenPGP keys and the row: an admin can still reach the device', async () => { + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + expect(await databases()).toContain('smime-plugin-store'); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + }); + + it('with the organisation asking for deletion, it goes at once, only that account\'s, named as the session limit', async () => { + await policyAsksForDeletion(); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'session', id: ROW })); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).toContain('smime-plugin-store'); + }); + + it('a sign-out the user asks for still deletes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + expect(state.isEvicted(ALICE)).toBe(false); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); +}); + +describe('a refused account with another still signed in', () => { + beforeEach(async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await queue.queueSend(BOB, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + }); + + /** What the auth store writes down, with who was there to read it. */ + function refuseBob(): void { + wipeState.markLoginRefused(BOB, { certain: true, seenBy: [ALICE], label: BOB, username: 'bob' }); + rejected.noteCredentialsRejected(BOB, true); + useAccountStore.getState().removeAccount(BOB, 'evicted'); + } + + it('is there for the one who was signed in beside it, with its count and date', async () => { + wipe.registerDeviceWipe(); + const at = Date.now(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + const [wait] = wipeState.siblingWaits(ALICE); + expect(wait).toMatchObject({ label: BOB, username: 'bob', kind: 'refused' }); + expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); + }); + + it('isn\'t for anyone who signs in here later, and goes when the account is back', async () => { + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)).toHaveLength(1)); + expect(wipeState.siblingWaits('someone-else')).toEqual([]); + useAccountStore.getState().addAccount(entry(BOB, 1) as never); + expect(wipeState.siblingWaits(ALICE)).toEqual([]); + }); + + it('says so once, when the count is known', async () => { + wipe.registerDeviceWipe(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + refuseBob(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ label: BOB, unsent: 1 })]); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + }); + + it('says so with no number when the store can\'t give one', async () => { + store.failArea = () => true; + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ unsent: null })])); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index 8a4162723..4fb1388f4 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,14 +1,17 @@ /** - * The server refused this login: `invalid_grant`, a lasting 401 after a fresh try - * (both `certain`), or a 401 at a connect that then failed (not, it's one answer). - * The auth store follows with a sign-out that hides the account's data, which - * waits for the new password. The organisation's session limit, an outage or any - * other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the - * count for the sign-in page and to delete at once where the organisation asks for - * that. Free of the stores, so the auth store can call it. + * A login ended with nobody asking. `refused`: the server refused it, by + * `invalid_grant` or a lasting 401 after a fresh try (both `certain`), or a 401 at a + * connect that then failed (not, it's one answer). `session`: the organisation's + * idle or session limit. The auth store follows with a sign-out that hides the + * account's data, which waits for the next sign-in. An outage or any other refusal + * doesn't come here. lib/offline/device-wipe.ts listens, to keep the count for the + * sign-in page and to delete at once where the organisation asks for that. Free of + * the stores, so the auth store can call it. */ -type Listener = (accountId: string, certain: boolean) => void; +export type ForcedSignOutKind = 'refused' | 'session'; + +type Listener = (accountId: string, certain: boolean, kind: ForcedSignOutKind) => void; const listeners = new Set(); @@ -17,10 +20,10 @@ export function onCredentialsRejected(listener: Listener): () => void { return () => listeners.delete(listener); } -export function noteCredentialsRejected(accountId: string, certain = true): void { +export function noteCredentialsRejected(accountId: string, certain = true, kind: ForcedSignOutKind = 'refused'): void { for (const listener of [...listeners]) { try { - listener(accountId, certain); + listener(accountId, certain, kind); } catch { // A sign-out must go on whatever a listener does. } diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index a1989aa44..c63c13cc6 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -5,11 +5,11 @@ * - the instance's registry (lib/devices): a wipe an admin asked for, heard at * check-in. At a start the check-in runs before the local copy is shown. * - the user, from settings. - * - the organisation, when it asks for deletion at once after a refused login - * (`organisation.devices.wipeOnRefusedLogin`). + * - the organisation, when it asks for deletion at once after a refused login or + * an idle or session limit (`organisation.devices.wipeOnForcedSignOut`). * - * A refused login on its own deletes nothing. The account signs out and its data - * hides until someone signs in with the new password, or for 30 days + * A refused login or an idle or session limit on its own deletes nothing. The + * account signs out and its data hides until the next sign-in, or for 30 days * (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page. * The usual cause is a password changed on another device, and deleting there * would lose unsent mail for very little protection against a thief. A deletion @@ -83,7 +83,7 @@ import { import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; -import { onCredentialsRejected } from './credentials-rejected'; +import { onCredentialsRejected, type ForcedSignOutKind } from './credentials-rejected'; import { forgetEvictedSecrets } from './evicted-secrets'; import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; @@ -385,7 +385,7 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc tellOtherWindows(account, 'start'); registerOfflineHooks(); - const ownOnly = reason === 'rejected'; + const ownOnly = reason === 'rejected' || reason === 'session'; const unsent = await unsentForWipe(account); if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) { queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent }); @@ -604,14 +604,16 @@ async function countRefused(account: string): Promise { setRefusedCount(account, await unsentForWipe(account)); } +const wipeReasonFor = (kind: ForcedSignOutKind | undefined): WipeReason => (kind === 'session' ? 'session' : 'rejected'); + /** - * The login was refused, and the auth store is signing it out and hiding the data. - * It goes now only if the organisation asked for that, and only for a refusal that - * is one for certain: a single bad answer at a start can be a server in trouble. + * The login ended with nobody asking, and the auth store is signing it out and + * hiding the data. It goes now only if the organisation asked for that, and only + * when it's certain: a single bad answer at a start can be a server in trouble. */ -async function heardRefusal(account: string, certain: boolean): Promise { - if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); +async function heardRefusal(account: string, certain: boolean, kind: ForcedSignOutKind): Promise { + if (certain && policyKnown() && devicesForAccount(account).wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(kind)); return; } await countRefused(account); @@ -625,8 +627,8 @@ export async function sweepRefusedLogins(): Promise { continue; } // Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it. - if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); + if (wait.certain && policyKnown() && devicesForNoOne().wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(wait.kind)); continue; } if (wait.unsent === null) await countRefused(account); @@ -639,8 +641,8 @@ let registered = false; export function registerDeviceWipe(): void { if (registered) return; registered = true; - onCredentialsRejected((account, certain) => { - void heardRefusal(account, certain); + onCredentialsRejected((account, certain, kind) => { + void heardRefusal(account, certain, kind); }); onBeforeSignOut(async (accountId, { keepLocalStore }) => { if (!keepLocalStore) await dropRows(accountId); diff --git a/locales/ar/common.json b/locales/ar/common.json index 94b392c9e..9922f5b33 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.", "refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.", "confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.", - "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز" + "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز", + "session_waiting": "رسائل لم تُرسل تنتظر تسجيل دخولك التالي: {count}. تبقى حتى {date}.", + "session_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر تسجيل دخولك التالي. تبقى حتى {date}.", + "needs_sign_in": "يلزم تسجيل الدخول", + "switcher_waiting": "رسائل لم تُرسل: {count}. ستُحذف من هذا الجهاز في {date}.", + "switcher_kept": "سيُحذف البريد الموجود على هذا الجهاز في {date}.", + "sibling_waiting": "تم تسجيل خروج {name}. رسائل لم تُرسل: {count}. سجّل الدخول قبل {date} وإلا فستُحذف من هذا الجهاز.", + "sibling_kept": "تم تسجيل خروج {name}. سجّل الدخول قبل {date} وإلا فسيُحذف البريد من هذا الجهاز." }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", diff --git a/locales/ca/common.json b/locales/ca/common.json index afb33a7f9..411394a07 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.", "refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.", "confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.", - "confirm_templates": "Plantilles de correu desades en aquest dispositiu" + "confirm_templates": "Plantilles de correu desades en aquest dispositiu", + "session_waiting": "Missatges sense enviar que esperen el teu proper inici de sessió: {count}. Es conserven fins al {date}.", + "session_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant el teu proper inici de sessió. Es conserven fins al {date}.", + "needs_sign_in": "Cal iniciar la sessió", + "switcher_waiting": "Missatges sense enviar: {count}. S'eliminaran d'aquest dispositiu el {date}.", + "switcher_kept": "El correu d'aquest dispositiu s'eliminarà el {date}.", + "sibling_waiting": "S'ha tancat la sessió de {name}. Missatges sense enviar: {count}. Inicia la sessió abans del {date} o s'eliminaran d'aquest dispositiu.", + "sibling_kept": "S'ha tancat la sessió de {name}. Inicia la sessió abans del {date} o el correu s'eliminarà d'aquest dispositiu." }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", diff --git a/locales/cs/common.json b/locales/cs/common.json index fb07e25a0..53568e499 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.", "refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.", "confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.", - "confirm_templates": "Šablony e-mailů uložené v tomto zařízení" + "confirm_templates": "Šablony e-mailů uložené v tomto zařízení", + "session_waiting": "Neodeslané zprávy čekající na další přihlášení: {count}. Zůstanou do {date}.", + "session_waiting_unknown": "Na další přihlášení možná čekají neodeslané zprávy. Zůstanou do {date}.", + "needs_sign_in": "Vyžaduje přihlášení", + "switcher_waiting": "Neodeslané zprávy: {count}. Budou smazány z tohoto zařízení {date}.", + "switcher_kept": "Pošta v tomto zařízení bude smazána {date}.", + "sibling_waiting": "Účet {name} byl odhlášen. Neodeslané zprávy: {count}. Přihlaste se do {date}, jinak budou z tohoto zařízení smazány.", + "sibling_kept": "Účet {name} byl odhlášen. Přihlaste se do {date}, jinak bude pošta z tohoto zařízení smazána." }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", diff --git a/locales/da/common.json b/locales/da/common.json index 554bbdbbe..9cfa5aa0e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.", "refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.", "confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.", - "confirm_templates": "E-mailskabeloner gemt på denne enhed" + "confirm_templates": "E-mailskabeloner gemt på denne enhed", + "session_waiting": "Usendte beskeder, der venter på din næste login: {count}. De bliver til {date}.", + "session_waiting_unknown": "Usendte beskeder venter måske på din næste login. De bliver til {date}.", + "needs_sign_in": "Kræver login", + "switcher_waiting": "Usendte beskeder: {count}. Slettes fra denne enhed den {date}.", + "switcher_kept": "Mailen på denne enhed slettes den {date}.", + "sibling_waiting": "{name} blev logget ud. Usendte beskeder: {count}. Log ind senest {date}, ellers slettes de fra denne enhed.", + "sibling_kept": "{name} blev logget ud. Log ind senest {date}, ellers slettes mailen fra denne enhed." }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", diff --git a/locales/de/common.json b/locales/de/common.json index 87c4fa0d7..df5a8374f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.", "refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.", "confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.", - "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen" + "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen", + "session_waiting": "Ungesendete Nachrichten, die auf Ihre nächste Anmeldung warten: {count}. Sie bleiben bis zum {date}.", + "session_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihre nächste Anmeldung. Sie bleiben bis zum {date}.", + "needs_sign_in": "Anmeldung nötig", + "switcher_waiting": "Ungesendete Nachrichten: {count}. Werden am {date} von diesem Gerät gelöscht.", + "switcher_kept": "Die E-Mails auf diesem Gerät werden am {date} gelöscht.", + "sibling_waiting": "{name} wurde abgemeldet. Ungesendete Nachrichten: {count}. Melden Sie sich bis zum {date} an, sonst werden sie von diesem Gerät gelöscht.", + "sibling_kept": "{name} wurde abgemeldet. Melden Sie sich bis zum {date} an, sonst werden die E-Mails von diesem Gerät gelöscht." }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", diff --git a/locales/en/common.json b/locales/en/common.json index fb167d8d3..f0ea6aed0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.", "refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.", "confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.", - "confirm_templates": "Email templates saved on this device" + "confirm_templates": "Email templates saved on this device", + "session_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for you to sign in again, until {date}.", + "session_waiting_unknown": "Unsent messages may be waiting for you to sign in again, until {date}.", + "needs_sign_in": "Needs sign-in", + "switcher_waiting": "Unsent messages: {count}. Deleted from this device on {date}.", + "switcher_kept": "Its mail on this device is deleted on {date}.", + "sibling_waiting": "{name} was signed out. Unsent messages: {count}. Sign in by {date} or they're deleted from this device.", + "sibling_kept": "{name} was signed out. Sign in by {date} or its mail is deleted from this device." }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", diff --git a/locales/es/common.json b/locales/es/common.json index 21946307b..c850f5716 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.", "refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.", "confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.", - "confirm_templates": "Plantillas de correo guardadas en este dispositivo" + "confirm_templates": "Plantillas de correo guardadas en este dispositivo", + "session_waiting": "Mensajes sin enviar que esperan tu próximo inicio de sesión: {count}. Se conservan hasta el {date}.", + "session_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu próximo inicio de sesión. Se conservan hasta el {date}.", + "needs_sign_in": "Requiere iniciar sesión", + "switcher_waiting": "Mensajes sin enviar: {count}. Se eliminarán de este dispositivo el {date}.", + "switcher_kept": "Su correo en este dispositivo se eliminará el {date}.", + "sibling_waiting": "Se ha cerrado la sesión de {name}. Mensajes sin enviar: {count}. Inicia sesión antes del {date} o se eliminarán de este dispositivo.", + "sibling_kept": "Se ha cerrado la sesión de {name}. Inicia sesión antes del {date} o su correo se eliminará de este dispositivo." }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", diff --git a/locales/et/common.json b/locales/et/common.json index 565e84f5e..eb4f6d999 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.", "refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.", "confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.", - "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid" + "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid", + "session_waiting": "Saatmata sõnumid ootavad järgmist sisselogimist: {count}. Need jäävad alles kuni {date}.", + "session_waiting_unknown": "Saatmata sõnumid võivad oodata järgmist sisselogimist. Need jäävad alles kuni {date}.", + "needs_sign_in": "Vajab sisselogimist", + "switcher_waiting": "Saatmata sõnumid: {count}. Kustutatakse sellest seadmest {date}.", + "switcher_kept": "Selle seadme e-kirjad kustutatakse {date}.", + "sibling_waiting": "{name} logiti välja. Saatmata sõnumid: {count}. Logige sisse hiljemalt {date}, muidu kustutatakse need sellest seadmest.", + "sibling_kept": "{name} logiti välja. Logige sisse hiljemalt {date}, muidu kustutatakse e-kirjad sellest seadmest." }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", diff --git a/locales/fa/common.json b/locales/fa/common.json index fb98c6508..44736a55a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.", "refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.", "confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.", - "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه" + "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه", + "session_waiting": "پیام‌های ارسال‌نشده در انتظار ورود بعدی شما: {count}. تا {date} نگه داشته می‌شوند.", + "session_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار ورود بعدی شما باشند. تا {date} نگه داشته می‌شوند.", + "needs_sign_in": "نیاز به ورود دارد", + "switcher_waiting": "پیام‌های ارسال‌نشده: {count}. در {date} از این دستگاه حذف می‌شوند.", + "switcher_kept": "ایمیل‌های این دستگاه در {date} حذف می‌شوند.", + "sibling_waiting": "{name} از حساب خارج شد. پیام‌های ارسال‌نشده: {count}. تا {date} وارد شوید، وگرنه از این دستگاه حذف می‌شوند.", + "sibling_kept": "{name} از حساب خارج شد. تا {date} وارد شوید، وگرنه ایمیل‌ها از این دستگاه حذف می‌شوند." }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", diff --git a/locales/fr/common.json b/locales/fr/common.json index d12e0fd67..543cb40ea 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.", "refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.", "confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.", - "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil" + "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil", + "session_waiting": "Messages non envoyés qui attendent votre prochaine connexion : {count}. Ils restent jusqu'au {date}.", + "session_waiting_unknown": "Des messages non envoyés attendent peut-être votre prochaine connexion. Ils restent jusqu'au {date}.", + "needs_sign_in": "Connexion requise", + "switcher_waiting": "Messages non envoyés : {count}. Supprimés de cet appareil le {date}.", + "switcher_kept": "Ses e-mails sur cet appareil seront supprimés le {date}.", + "sibling_waiting": "{name} a été déconnecté. Messages non envoyés : {count}. Connectez-vous avant le {date}, sinon ils seront supprimés de cet appareil.", + "sibling_kept": "{name} a été déconnecté. Connectez-vous avant le {date}, sinon ses e-mails seront supprimés de cet appareil." }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", diff --git a/locales/he/common.json b/locales/he/common.json index 1739d0d64..3422aa00b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5784,7 +5784,14 @@ "refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.", "refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.", "confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.", - "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה" + "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה", + "session_waiting": "הודעות שלא נשלחו ממתינות לכניסה הבאה שלך: {count}. הן יישמרו עד {date}.", + "session_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לכניסה הבאה שלך. הן יישמרו עד {date}.", + "needs_sign_in": "נדרשת כניסה", + "switcher_waiting": "הודעות שלא נשלחו: {count}. יימחקו מהמכשיר הזה ב-{date}.", + "switcher_kept": "הדואר במכשיר הזה יימחק ב-{date}.", + "sibling_waiting": "{name} נותק. הודעות שלא נשלחו: {count}. יש להיכנס עד {date}, אחרת הן יימחקו מהמכשיר הזה.", + "sibling_kept": "{name} נותק. יש להיכנס עד {date}, אחרת הדואר יימחק מהמכשיר הזה." }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9effe4bc5..8f3a503ee 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", "refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.", "confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.", - "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok" + "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok", + "session_waiting": "A következő bejelentkezésre váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", + "session_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak a következő bejelentkezésre. Eddig maradnak: {date}.", + "needs_sign_in": "Bejelentkezés szükséges", + "switcher_waiting": "El nem küldött üzenetek: {count}. Törlés erről az eszközről: {date}.", + "switcher_kept": "Az eszközön lévő levelek törlése: {date}.", + "sibling_waiting": "{name} kijelentkezett. El nem küldött üzenetek: {count}. Jelentkezzen be eddig: {date}, különben törlődnek erről az eszközről.", + "sibling_kept": "{name} kijelentkezett. Jelentkezzen be eddig: {date}, különben a levelek törlődnek erről az eszközről." }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", diff --git a/locales/it/common.json b/locales/it/common.json index 8bae2befc..662049227 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.", "refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.", "confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.", - "confirm_templates": "Modelli di email salvati su questo dispositivo" + "confirm_templates": "Modelli di email salvati su questo dispositivo", + "session_waiting": "Messaggi non inviati in attesa del prossimo accesso: {count}. Restano fino al {date}.", + "session_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa del prossimo accesso. Restano fino al {date}.", + "needs_sign_in": "Accesso richiesto", + "switcher_waiting": "Messaggi non inviati: {count}. Eliminati da questo dispositivo il {date}.", + "switcher_kept": "Le sue email su questo dispositivo verranno eliminate il {date}.", + "sibling_waiting": "{name} è stato disconnesso. Messaggi non inviati: {count}. Accedi entro il {date} o verranno eliminati da questo dispositivo.", + "sibling_kept": "{name} è stato disconnesso. Accedi entro il {date} o le sue email verranno eliminate da questo dispositivo." }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 5d337d274..53cd6fbf5 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", "refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。", "confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。", - "confirm_templates": "このデバイスに保存されたメールテンプレート" + "confirm_templates": "このデバイスに保存されたメールテンプレート", + "session_waiting": "次回のサインインを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", + "session_waiting_unknown": "未送信のメッセージが次回のサインインを待っている可能性があります。{date} まで保持されます。", + "needs_sign_in": "サインインが必要", + "switcher_waiting": "未送信メッセージ: {count} 件。{date} にこのデバイスから削除されます。", + "switcher_kept": "このデバイス上のメールは {date} に削除されます。", + "sibling_waiting": "{name} はサインアウトされました。未送信メッセージ: {count} 件。{date} までにサインインしないと、このデバイスから削除されます。", + "sibling_kept": "{name} はサインアウトされました。{date} までにサインインしないと、メールはこのデバイスから削除されます。" }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index a0cc9e7a9..de35b0ed0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", "refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", "confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.", - "confirm_templates": "이 기기에 저장된 이메일 템플릿" + "confirm_templates": "이 기기에 저장된 이메일 템플릿", + "session_waiting": "다음 로그인을 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", + "session_waiting_unknown": "보내지 않은 메시지가 다음 로그인을 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", + "needs_sign_in": "로그인 필요", + "switcher_waiting": "보내지 않은 메시지: {count}개. {date}에 이 기기에서 삭제됩니다.", + "switcher_kept": "이 기기의 메일은 {date}에 삭제됩니다.", + "sibling_waiting": "{name} 계정이 로그아웃되었습니다. 보내지 않은 메시지: {count}개. {date}까지 로그인하지 않으면 이 기기에서 삭제됩니다.", + "sibling_kept": "{name} 계정이 로그아웃되었습니다. {date}까지 로그인하지 않으면 메일이 이 기기에서 삭제됩니다." }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3d0c0a8b7..b6796c731 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.", "refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.", "confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.", - "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes" + "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes", + "session_waiting": "Nenosūtītie ziņojumi gaida nākamo pieteikšanos: {count}. Tie paliek līdz {date}.", + "session_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida nākamo pieteikšanos. Tie paliek līdz {date}.", + "needs_sign_in": "Nepieciešama pieteikšanās", + "switcher_waiting": "Nenosūtītie ziņojumi: {count}. Tiks izdzēsti no šīs ierīces {date}.", + "switcher_kept": "E-pasts šajā ierīcē tiks izdzēsts {date}.", + "sibling_waiting": "{name} tika izrakstīts. Nenosūtītie ziņojumi: {count}. Piesakieties līdz {date}, citādi tie tiks izdzēsti no šīs ierīces.", + "sibling_kept": "{name} tika izrakstīts. Piesakieties līdz {date}, citādi e-pasts tiks izdzēsts no šīs ierīces." }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4ede4b17f..bbd32ac3c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", "refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", "confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.", - "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд" + "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд", + "session_waiting": "Дараагийн нэвтрэлтийг хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", + "session_waiting_unknown": "Илгээгээгүй зурвасууд дараагийн нэвтрэлтийг хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", + "needs_sign_in": "Нэвтрэх шаардлагатай", + "switcher_waiting": "Илгээгээгүй зурвас: {count}. {date}-нд энэ төхөөрөмжөөс устгагдана.", + "switcher_kept": "Энэ төхөөрөмж дээрх имэйл {date}-нд устгагдана.", + "sibling_waiting": "{name} системээс гарсан. Илгээгээгүй зурвас: {count}. {date} хүртэл нэвтэрнэ үү, эс бөгөөс энэ төхөөрөмжөөс устгагдана.", + "sibling_kept": "{name} системээс гарсан. {date} хүртэл нэвтэрнэ үү, эс бөгөөс имэйл энэ төхөөрөмжөөс устгагдана." }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", diff --git a/locales/nb/common.json b/locales/nb/common.json index b507ee77d..05c122103 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.", "refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.", "confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.", - "confirm_templates": "E-postmaler lagret på denne enheten" + "confirm_templates": "E-postmaler lagret på denne enheten", + "session_waiting": "Usendte meldinger som venter på neste pålogging: {count}. De blir til {date}.", + "session_waiting_unknown": "Usendte meldinger venter kanskje på neste pålogging. De blir til {date}.", + "needs_sign_in": "Krever pålogging", + "switcher_waiting": "Usendte meldinger: {count}. Slettes fra denne enheten {date}.", + "switcher_kept": "E-posten på denne enheten slettes {date}.", + "sibling_waiting": "{name} ble logget ut. Usendte meldinger: {count}. Logg på innen {date}, ellers slettes de fra denne enheten.", + "sibling_kept": "{name} ble logget ut. Logg på innen {date}, ellers slettes e-posten fra denne enheten." }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0cec4efb0..0f8ff3e20 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.", "refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.", "confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.", - "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen" + "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen", + "session_waiting": "Niet-verzonden berichten die op uw volgende aanmelding wachten: {count}. Ze blijven tot {date}.", + "session_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw volgende aanmelding. Ze blijven tot {date}.", + "needs_sign_in": "Aanmelden nodig", + "switcher_waiting": "Niet-verzonden berichten: {count}. Op {date} van dit apparaat verwijderd.", + "switcher_kept": "De e-mail op dit apparaat wordt op {date} verwijderd.", + "sibling_waiting": "{name} is afgemeld. Niet-verzonden berichten: {count}. Meld u uiterlijk {date} aan, anders worden ze van dit apparaat verwijderd.", + "sibling_kept": "{name} is afgemeld. Meld u uiterlijk {date} aan, anders wordt de e-mail van dit apparaat verwijderd." }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", diff --git a/locales/pl/common.json b/locales/pl/common.json index ad520117b..2b1acf10f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.", "refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.", "confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.", - "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu" + "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu", + "session_waiting": "Niewysłane wiadomości czekające na następne logowanie: {count}. Pozostaną do {date}.", + "session_waiting_unknown": "Niewysłane wiadomości mogą czekać na następne logowanie. Pozostaną do {date}.", + "needs_sign_in": "Wymaga zalogowania", + "switcher_waiting": "Niewysłane wiadomości: {count}. Zostaną usunięte z tego urządzenia {date}.", + "switcher_kept": "Poczta na tym urządzeniu zostanie usunięta {date}.", + "sibling_waiting": "Konto {name} zostało wylogowane. Niewysłane wiadomości: {count}. Zaloguj się do {date}, inaczej zostaną usunięte z tego urządzenia.", + "sibling_kept": "Konto {name} zostało wylogowane. Zaloguj się do {date}, inaczej poczta zostanie usunięta z tego urządzenia." }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", diff --git a/locales/pt/common.json b/locales/pt/common.json index d7ccc37d4..3e1c1dbe3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.", "refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.", "confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.", - "confirm_templates": "Modelos de e-mail guardados neste dispositivo" + "confirm_templates": "Modelos de e-mail guardados neste dispositivo", + "session_waiting": "Mensagens por enviar à espera do próximo início de sessão: {count}. Ficam até {date}.", + "session_waiting_unknown": "Podem existir mensagens por enviar à espera do próximo início de sessão. Ficam até {date}.", + "needs_sign_in": "Requer início de sessão", + "switcher_waiting": "Mensagens por enviar: {count}. Eliminadas deste dispositivo em {date}.", + "switcher_kept": "O e-mail neste dispositivo será eliminado em {date}.", + "sibling_waiting": "A sessão de {name} foi terminada. Mensagens por enviar: {count}. Inicie sessão até {date} ou serão eliminadas deste dispositivo.", + "sibling_kept": "A sessão de {name} foi terminada. Inicie sessão até {date} ou o e-mail será eliminado deste dispositivo." }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a5693b91e..568831fed 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.", "refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.", "confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.", - "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv" + "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv", + "session_waiting": "Mesaje netrimise care așteaptă următoarea conectare: {count}. Rămân până la {date}.", + "session_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte următoarea conectare. Rămân până la {date}.", + "needs_sign_in": "Necesită conectare", + "switcher_waiting": "Mesaje netrimise: {count}. Vor fi șterse de pe acest dispozitiv la {date}.", + "switcher_kept": "E-mailurile de pe acest dispozitiv vor fi șterse la {date}.", + "sibling_waiting": "{name} a fost deconectat. Mesaje netrimise: {count}. Conectați-vă până la {date}, altfel vor fi șterse de pe acest dispozitiv.", + "sibling_kept": "{name} a fost deconectat. Conectați-vă până la {date}, altfel e-mailurile vor fi șterse de pe acest dispozitiv." }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 741db287f..5b2b66dc8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.", "refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.", "confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.", - "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве" + "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве", + "session_waiting": "Неотправленные сообщения ждут следующего входа: {count}. Они хранятся до {date}.", + "session_waiting_unknown": "Возможно, неотправленные сообщения ждут следующего входа. Они хранятся до {date}.", + "needs_sign_in": "Нужен вход", + "switcher_waiting": "Неотправленные сообщения: {count}. Будут удалены с этого устройства {date}.", + "switcher_kept": "Почта на этом устройстве будет удалена {date}.", + "sibling_waiting": "Выполнен выход из {name}. Неотправленные сообщения: {count}. Войдите до {date}, иначе они будут удалены с этого устройства.", + "sibling_kept": "Выполнен выход из {name}. Войдите до {date}, иначе почта будет удалена с этого устройства." }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 51853a98d..60bd9fd93 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.", "refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.", "confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.", - "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení" + "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení", + "session_waiting": "Neodoslané správy čakajúce na ďalšie prihlásenie: {count}. Zostanú do {date}.", + "session_waiting_unknown": "Na ďalšie prihlásenie možno čakajú neodoslané správy. Zostanú do {date}.", + "needs_sign_in": "Vyžaduje prihlásenie", + "switcher_waiting": "Neodoslané správy: {count}. Odstránia sa z tohto zariadenia {date}.", + "switcher_kept": "Pošta v tomto zariadení sa odstráni {date}.", + "sibling_waiting": "Účet {name} bol odhlásený. Neodoslané správy: {count}. Prihláste sa do {date}, inak sa z tohto zariadenia odstránia.", + "sibling_kept": "Účet {name} bol odhlásený. Prihláste sa do {date}, inak sa pošta z tohto zariadenia odstráni." }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", diff --git a/locales/th/common.json b/locales/th/common.json index 3daeb90b6..15c58391e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2128,7 +2128,14 @@ "refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}", "refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}", "confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย", - "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้" + "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้", + "session_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอการลงชื่อเข้าใช้ครั้งถัดไป: {count} เก็บไว้ถึง {date}", + "session_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอการลงชื่อเข้าใช้ครั้งถัดไป เก็บไว้ถึง {date}", + "needs_sign_in": "ต้องลงชื่อเข้าใช้", + "switcher_waiting": "ข้อความที่ยังไม่ได้ส่ง: {count} จะถูกลบออกจากอุปกรณ์นี้ในวันที่ {date}", + "switcher_kept": "อีเมลในอุปกรณ์นี้จะถูกลบในวันที่ {date}", + "sibling_waiting": "{name} ถูกออกจากระบบแล้ว ข้อความที่ยังไม่ได้ส่ง: {count} โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นจะถูกลบออกจากอุปกรณ์นี้", + "sibling_kept": "{name} ถูกออกจากระบบแล้ว โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นอีเมลจะถูกลบออกจากอุปกรณ์นี้" }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4b9b55b32..2b7142fec 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", "refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.", "confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.", - "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları" + "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları", + "session_waiting": "Bir sonraki oturum açmanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", + "session_waiting_unknown": "Gönderilmemiş iletiler bir sonraki oturum açmanızı bekliyor olabilir. {date} tarihine kadar kalır.", + "needs_sign_in": "Oturum açılması gerekiyor", + "switcher_waiting": "Gönderilmemiş iletiler: {count}. {date} tarihinde bu cihazdan silinecek.", + "switcher_kept": "Bu cihazdaki e-postalar {date} tarihinde silinecek.", + "sibling_waiting": "{name} oturumu kapatıldı. Gönderilmemiş iletiler: {count}. {date} tarihine kadar oturum açın, yoksa bu cihazdan silinecekler.", + "sibling_kept": "{name} oturumu kapatıldı. {date} tarihine kadar oturum açın, yoksa e-postalar bu cihazdan silinecek." }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 8b5819736..f226aa677 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.", "refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.", "confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.", - "confirm_templates": "Шаблони листів, збережені на цьому пристрої" + "confirm_templates": "Шаблони листів, збережені на цьому пристрої", + "session_waiting": "Ненадіслані повідомлення чекають на наступний вхід: {count}. Вони зберігаються до {date}.", + "session_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на наступний вхід. Вони зберігаються до {date}.", + "needs_sign_in": "Потрібен вхід", + "switcher_waiting": "Ненадіслані повідомлення: {count}. Буде видалено з цього пристрою {date}.", + "switcher_kept": "Пошту на цьому пристрої буде видалено {date}.", + "sibling_waiting": "Виконано вихід із {name}. Ненадіслані повідомлення: {count}. Увійдіть до {date}, інакше їх буде видалено з цього пристрою.", + "sibling_kept": "Виконано вихід із {name}. Увійдіть до {date}, інакше пошту буде видалено з цього пристрою." }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 8ba0c8d1e..46c8b3dec 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5795,7 +5795,14 @@ "refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.", "refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.", "confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.", - "confirm_templates": "Mẫu email đã lưu trên thiết bị này" + "confirm_templates": "Mẫu email đã lưu trên thiết bị này", + "session_waiting": "Thư chưa gửi đang chờ lần đăng nhập tiếp theo: {count}. Được giữ đến {date}.", + "session_waiting_unknown": "Có thể có thư chưa gửi đang chờ lần đăng nhập tiếp theo. Được giữ đến {date}.", + "needs_sign_in": "Cần đăng nhập", + "switcher_waiting": "Thư chưa gửi: {count}. Sẽ bị xóa khỏi thiết bị này vào {date}.", + "switcher_kept": "Email trên thiết bị này sẽ bị xóa vào {date}.", + "sibling_waiting": "{name} đã bị đăng xuất. Thư chưa gửi: {count}. Hãy đăng nhập trước {date}, nếu không chúng sẽ bị xóa khỏi thiết bị này.", + "sibling_kept": "{name} đã bị đăng xuất. Hãy đăng nhập trước {date}, nếu không email sẽ bị xóa khỏi thiết bị này." }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 01ce18e04..1f5630127 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。", "confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。", - "confirm_templates": "儲存在此裝置上的郵件範本" + "confirm_templates": "儲存在此裝置上的郵件範本", + "session_waiting": "等待下次登入的未傳送郵件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未傳送的郵件正在等待下次登入。保留至 {date}。", + "needs_sign_in": "需要登入", + "switcher_waiting": "未傳送郵件:{count} 封。將於 {date} 從此裝置刪除。", + "switcher_kept": "此裝置上的郵件將於 {date} 刪除。", + "sibling_waiting": "{name} 已登出。未傳送郵件:{count} 封。請在 {date} 前登入,否則將從此裝置刪除。", + "sibling_kept": "{name} 已登出。請在 {date} 前登入,否則郵件將從此裝置刪除。" }, "offlineStorage": { "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 54d3eac4b..0643bda66 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。", "confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。", - "confirm_templates": "保存在此设备上的邮件模板" + "confirm_templates": "保存在此设备上的邮件模板", + "session_waiting": "等待下次登录的未发送邮件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未发送的邮件在等待下次登录。保留至 {date}。", + "needs_sign_in": "需要登录", + "switcher_waiting": "未发送邮件:{count} 封。将于 {date} 从此设备删除。", + "switcher_kept": "此设备上的邮件将于 {date} 删除。", + "sibling_waiting": "{name} 已退出登录。未发送邮件:{count} 封。请在 {date} 前登录,否则将从此设备删除。", + "sibling_kept": "{name} 已退出登录。请在 {date} 前登录,否则邮件将从此设备删除。" }, "offlineStorage": { "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index 1f1d9a00b..52c036b00 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -51,8 +51,9 @@ vi.mock('@/lib/offline/offline-mode', () => ({ const heard = vi.hoisted(() => [] as string[]); const certain = vi.hoisted(() => [] as boolean[]); +const kinds = vi.hoisted(() => [] as string[]); vi.mock('@/lib/offline/credentials-rejected', () => ({ - noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); }, + noteCredentialsRejected: (account: string, sure = true, kind = 'refused') => { heard.push(account); certain.push(sure); kinds.push(kind); }, onCredentialsRejected: () => () => {}, })); @@ -97,6 +98,7 @@ beforeEach(async () => { order.length = 0; heard.length = 0; certain.length = 0; + kinds.length = 0; clients.made.length = 0; clients.connect = async () => {}; gate.run = async () => {}; @@ -189,14 +191,15 @@ describe('a login the server ended, at a start', () => { it.each([ ['a password login', 'basic', '/api/auth/session'], ['an OAuth login', 'oauth', '/api/auth/token'], - ])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => { + ])('the session limit goes the same way, as what it is, for %s: nothing about the password', async (_name, mode, route) => { useOfflineStore.setState({ enabled: [ALICE] }); useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never); answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().checkAuth(); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); - expect(heard).toEqual([]); - expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([ALICE]); + expect(kinds).toEqual(['session']); + expect(readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session', certain: true }); expect(isEvicted(ALICE)).toBe(true); expect(useOfflineStore.getState().enabled).toEqual([ALICE]); }); @@ -239,11 +242,12 @@ describe('a login the server ended, at a switch', () => { expect(useOfflineStore.getState().enabled).toContain(BOB); }); - it('the session limit doesn\'t fire it and only drops the account', async () => { + it('the session limit fires it as what it is, and only drops the account', async () => { useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().switchAccount(BOB); - expect(heard).toEqual([]); + expect(heard).toEqual([BOB]); + expect(kinds).toEqual(['session']); expect(isEvicted(BOB)).toBe(true); expect(useOfflineStore.getState().enabled).toContain(BOB); }); @@ -255,6 +259,33 @@ describe('a login the server ended, at a switch', () => { }); }); +describe('a refusal with another account signed in', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('writes down who was there, so the note reaches them and nobody later', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().refreshAccessToken({ accountId: BOB }); + expect(readRefusedLogins()[BOB]).toMatchObject({ seenBy: [ALICE], label: BOB, username: 'bob' }); + }); +}); + +describe('a login with no "Remember me", when the browser closed', () => { + it('still takes its data with it: that was the choice on a shared computer', async () => { + const reasons = await removalReasons(); + useAccountStore.setState({ accounts: [{ ...account(ALICE, 0, 'basic'), rememberMe: false }], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(reasons).toEqual(['explicit'])); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); +}); + describe('a password the server keeps refusing', () => { beforeEach(() => { useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 1b26004ee..8854e5197 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -39,7 +39,7 @@ import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; -import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { noteCredentialsRejected, type ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; @@ -176,6 +176,8 @@ export interface LogoutOptions { * hidden, for the next sign-in or 30 days. */ keepLocalData?: boolean; + /** It's the organisation's idle or session limit that ended it: with `keepLocalData`, what lib/offline/device-wipe.ts is told. */ + sessionLimit?: boolean; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -805,18 +807,27 @@ function holdClient(accountId: string, client: JMAPClient): void { } /** - * The server refused this login, and the data hides from here on. It waits for the - * new password, or goes after 30 days, or now if the organisation asks for that + * This login ended with nobody asking: the server refused it, or the organisation's + * idle or session limit ran out. The data hides from here on. It waits for the next + * sign-in, or goes after 30 days, or now if the organisation asks for that * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a * password changed on another device: nothing goes by itself, unsent mail included. - * `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect. + * `certain`: not one bad answer at a connect. */ -function noteRefusedLogin(accountId: string, certain: boolean): void { +function noteRefusedLogin(accountId: string, certain: boolean, kind: ForcedSignOutKind = 'refused'): void { if (useOfflineStore.getState().enabled.includes(accountId)) { - markLoginRefused(accountId, certain); + const { accounts } = useAccountStore.getState(); + const account = accounts.find((entry) => entry.id === accountId); + markLoginRefused(accountId, { + certain, + kind, + seenBy: accounts.filter((entry) => entry.id !== accountId).map((entry) => entry.id), + label: account?.email || account?.username, + username: account?.username, + }); markEvicted(accountId); } - noteCredentialsRejected(accountId, certain); + noteCredentialsRejected(accountId, certain, kind); } /** @@ -1315,17 +1326,19 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool } /** - * Whether the server refused the login itself: invalid_grant. That signs out and - * hides the data (noteRefusedLogin). Anything else keeps it the same way with no - * word to anyone, including a body we can't read and the organisation's session - * limit, which says the session is old and nothing about the password. + * What a refused renewal says about the login: the server refused it + * (invalid_grant), or the organisation's session limit was reached, which says + * nothing about the password. Both sign out and hide the data (noteRefusedLogin). + * Anything else keeps it the same way with no word to anyone, including a body + * we can't read. */ -async function loginRefused(res: Response): Promise { +async function forcedSignOutOf(res: Response): Promise { try { const { error } = await res.clone().json() as { error?: unknown }; - return error === 'invalid_grant'; + if (error === 'invalid_grant') return 'refused'; + return error === SESSION_TOO_OLD ? 'session' : null; } catch { - return false; + return null; } } @@ -2398,15 +2411,15 @@ export const useAuthStore = create()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); - const refused = await loginRefused(res); + const forced = await forcedSignOutOf(res); // Only this login's refresh token was refused. if (accountId && !isActive) { - if (refused) noteRefusedLogin(accountId, true); + if (forced) noteRefusedLogin(accountId, true, forced); get().removeAccount(accountId, 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); - if (refused && accountId) noteRefusedLogin(accountId, true); + if (forced && accountId) noteRefusedLogin(accountId, true, forced); notifyParent('sso:session-expired'); markSessionExpired(); get().logout({ endProviderSession: false, keepLocalData: true }); @@ -2669,7 +2682,12 @@ export const useAuthStore = create()( logoutAll: async (options) => { if (options?.endProviderSession !== false && !(await passSignOutGates('all'))) return; - await beforeLogoutHooks('all'); + const keepLocalData = options?.keepLocalData === true; + // Before anything goes, while the accounts are still there to be named. + if (options?.sessionLimit) { + for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); + } + await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; @@ -2696,11 +2714,11 @@ export const useAuthStore = create()( // Credentials are cleared before the page state - see logout(). const endSessionUrl = await cleanup; - performFullLogout(set); + performFullLogout(set, { keepLocalStore: keepLocalData }); // Clear all accounts from registry for (const account of allAccounts) { - accountStore.removeAccount(account.id); + accountStore.removeAccount(account.id, keepLocalData ? 'evicted' : 'explicit'); } clearKeystoreGrants(); @@ -2739,7 +2757,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; - let targetRefused = false; + let targetForced: ForcedSignOutKind | null = null; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2756,7 +2774,7 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); @@ -2773,7 +2791,7 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); @@ -2825,7 +2843,8 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login - if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused); + if (targetForced) noteRefusedLogin(accountId, true, targetForced); + else if (refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, false); refusedAtConnect.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId, 'evicted'); @@ -3086,7 +3105,7 @@ export const useAuthStore = create()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -3107,7 +3126,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } } catch (err) { @@ -3145,8 +3164,9 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its // local data waits. - const refused = (err as { refused?: boolean }).refused === true; - if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused); + const { forced } = err as { forced?: ForcedSignOutKind | null }; + if (forced) noteRefusedLogin(account.id, true, forced); + else if (refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, false); refusedAtConnect.delete(account.id); evictAccount(account.id); accountStore.removeAccount(account.id, 'evicted'); From 383e579eec5076d9cc4aa33eb54211520b2f82fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:52:42 +0100 Subject: [PATCH 33/36] feat: run the app to the top of the window, with the window's controls in its own toolbars The desktop app and an installed PWA had a row across the whole window with the title and the window controls. That row is gone. The rail, sidebar, list and reader run to the top of the window in their own colours, and the controls sit in the top row of whatever is rightmost. - `lib/window-controls-overlay.ts` finds and marks those rows before paint, and they reserve the controls' width through `--titlebar-right-inset` - the rows and the rail are the drag area, everything clickable is `no-drag`, and an invisible band lets a page with no row still move - settings and the admin shell get a row only under the overlay, Pro's tab strip and Mountain View's bar match the controls' height - Alt floats the menu over the top left, in `window-menu-bar.tsx` - skeletons carry the same marks, so nothing shifts when the app loads --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/[locale]/login/page.tsx | 4 +- app/(main)/admin/layout.tsx | 2 + app/(main)/layout.tsx | 4 +- app/desktop.css | 3 +- app/globals.css | 137 ++++++- app/manifest.ts | 2 +- components/admin/admin-shell.tsx | 4 + components/boot-skeleton/admin-skeleton.tsx | 3 + .../boot-skeleton/calendar-skeleton.tsx | 2 +- components/boot-skeleton/files-skeleton.tsx | 2 +- components/boot-skeleton/login-skeleton.tsx | 2 +- components/boot-skeleton/mail-skeleton.tsx | 2 +- components/boot-skeleton/pro-skeleton.tsx | 2 + .../boot-skeleton/settings-skeleton.tsx | 7 +- components/calendar/calendar-toolbar.tsx | 2 +- components/calendar/event-modal.tsx | 2 +- components/calendar/task-modal.tsx | 2 +- components/contacts/contact-detail.tsx | 6 +- components/contacts/contact-form.tsx | 2 +- components/contacts/contact-group-detail.tsx | 2 +- components/contacts/contact-group-form.tsx | 2 +- components/email/email-composer.tsx | 1 + components/email/email-viewer.tsx | 4 +- components/files/preview-shell.tsx | 1 + components/layout/inline-app-view.tsx | 2 +- components/layout/navigation-rail.tsx | 1 + components/layout/window-controls-host.tsx | 29 ++ components/layout/window-menu-bar.tsx | 100 ++++++ components/layout/window-title-bar.tsx | 138 ------- components/plugins/app-side-panel.tsx | 1 + components/plugins/plugin-side-panels.tsx | 2 + components/pro/pro-tab-bar.tsx | 1 + components/settings/settings-app.tsx | 4 +- components/ui/dialog.tsx | 4 +- integration/tests/23-do-pwa-windows.spec.ts | 38 +- lib/__tests__/window-controls-host.test.tsx | 340 ++++++++++++++++++ lib/__tests__/window-title-bar.test.tsx | 221 ------------ lib/desktop/boot.ts | 3 +- lib/window-controls-overlay.ts | 126 ++++++- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/vi/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 70 files changed, 843 insertions(+), 459 deletions(-) create mode 100644 components/layout/window-controls-host.tsx create mode 100644 components/layout/window-menu-bar.tsx delete mode 100644 components/layout/window-title-bar.tsx create mode 100644 lib/__tests__/window-controls-host.test.tsx delete mode 100644 lib/__tests__/window-title-bar.test.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 762f30ab9..3c03bd9be 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -38,6 +38,7 @@ import { MainWindowOnly } from "@/components/popout/main-window-only"; import { PopoutHost } from "@/components/popout/popout-host"; import { WindowSyncHost } from "@/components/popout/window-sync-host"; import { DesktopHost } from "@/components/desktop/desktop-host"; +import { WindowMenuBar } from "@/components/layout/window-menu-bar"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -90,6 +91,7 @@ export default async function LocaleLayout({ {!IS_LITE && } {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bf9b69c44..52d6f4be0 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1208,7 +1208,7 @@ function LoginPageContent() { return (
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} -