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 44a474b99..4c02f4eac 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -171,6 +171,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 @@ -2649,6 +2650,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..9dbde37b4 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; } @@ -684,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/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 a87fb51aa..3e4b2491e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -144,6 +144,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"; @@ -2936,6 +2937,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 0a03272bf..b87aea61f 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; import { DesktopKeystoreSettings } from './desktop-keystore-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'; @@ -25,6 +26,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..e3757d946 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -731,4 +731,32 @@ 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 nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); + 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..6136c4453 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 @@ -33,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 { @@ -40,6 +43,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 = { @@ -56,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 () => { @@ -70,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() @@ -87,7 +107,7 @@ export function useAttachmentDrag( .finally(() => { inFlightRef.current = null; }); - }, [enabled, source]); + }, [enabled, source, nativeDrag]); const handleDragStart = useCallback( (e: DragEvent) => { @@ -95,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 @@ -111,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(() => { @@ -125,6 +154,11 @@ export function useAttachmentDrag( } }, []); + const copyAsFile = useCallback(async () => { + const blob = blobRef.current ?? await source.getBlob?.(); + return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false; + }, [source]); + if (!enabled) return NOOP_HANDLERS; return { @@ -132,5 +166,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, + ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 25450a031..6debff52c 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, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -103,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 { @@ -126,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( @@ -224,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; @@ -295,6 +302,14 @@ 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. + const blob = e.altKey ? keptDragBlob(url) : undefined; + if (blob) { + e.preventDefault(); + void startNativeFileDrag([{ name, blob }]); + 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 +328,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { + const blob = e.altKey ? keptDragBlob(ready.url) : undefined; + if (blob) { + e.preventDefault(); + void startNativeFileDrag([{ name: ready.name, blob }]); + 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..ca3f25bf1 --- /dev/null +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -0,0 +1,360 @@ +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('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'); + 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 { 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') }); + 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-store.test.ts b/lib/desktop/__tests__/os-store.test.ts new file mode 100644 index 000000000..cd876cfd2 --- /dev/null +++ b/lib/desktop/__tests__/os-store.test.ts @@ -0,0 +1,79 @@ +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. */ + +// 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 () => {})); + +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 new file mode 100644 index 000000000..2e005476f --- /dev/null +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -0,0 +1,442 @@ +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, + 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 new Promise((resolve) => setTimeout(resolve, 0)); + }); + return hook; + } + + 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 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: '' } }; + 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'); + 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 () => { + 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..07084646c --- /dev/null +++ b/lib/desktop/file-drag.ts @@ -0,0 +1,67 @@ +/** + * 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'; + +/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */ +export interface DragFile { + name: string; + 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 { + return !!getDesktopBridge()?.os?.startDrag; +} + +async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { + return { name: file.name, data: new Uint8Array(await file.blob.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..05b3303ba --- /dev/null +++ b/lib/desktop/mail-notifications.ts @@ -0,0 +1,174 @@ +/** + * 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 { 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'; +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; +} + +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; + setStoreCreatedListener(null); + }, + }; + setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids)); + return handle; +} diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts new file mode 100644 index 000000000..efe23173d --- /dev/null +++ b/lib/desktop/new-mail.ts @@ -0,0 +1,151 @@ +/** + * 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; + 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)) + .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..44d429dfc --- /dev/null +++ b/lib/desktop/power.ts @@ -0,0 +1,48 @@ +/** + * 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 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'; + +/** A resume and an unlock usually come together. */ +const WAKE_REPEAT_MS = 2000; + +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(); + runWakeHandlers(); +} + +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') 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); + return () => { + stopPower(); + connection?.removeEventListener?.('change', wakeOnce); + }; +} 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/desktop/types.ts b/lib/desktop/types.ts index 5c60da8bf..696ab3183 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -182,6 +182,122 @@ export interface DesktopKeystoreBridge { revokeAccess: () => 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'; @@ -191,6 +307,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/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 33b5fff76..d71993589 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 { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -196,6 +197,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); @@ -957,6 +962,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; 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 654025307..169c04ec5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6561,5 +6561,35 @@ "label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.", "revoke": "سحب الإذن", "revoked": "تم سحب الإذن" + }, + "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 0a9eb6fb2..5dd448d8e 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6561,5 +6561,35 @@ "label_description": "Retira el permís. El servidor ho haurà de tornar a demanar.", "revoke": "Retira", "revoked": "Permís retirat" + }, + "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 18f0c03ee..567020087 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6561,5 +6561,35 @@ "label_description": "Odvolejte své povolení. Server se bude muset zeptat znovu.", "revoke": "Odvolat", "revoked": "Povolení bylo odvoláno" + }, + "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 5578eeca0..352cd2805 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6561,5 +6561,35 @@ "label_description": "Træk din tilladelse tilbage. Serveren skal så spørge igen.", "revoke": "Træk tilbage", "revoked": "Tilladelsen er trukket tilbage" + }, + "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 efb641f2b..65fa8c8dd 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6561,5 +6561,35 @@ "label_description": "Nehmen Sie Ihre Erlaubnis zurück. Der Server muss dann erneut fragen.", "revoke": "Zurücknehmen", "revoked": "Erlaubnis zurückgenommen" + }, + "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 7728594e1..4512e406e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6561,5 +6561,35 @@ "label_description": "Take back your permission. The server has to ask again.", "revoke": "Take back", "revoked": "Permission taken back" + }, + "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 5a2af15a4..6ed72a0f4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6561,5 +6561,35 @@ "label_description": "Retire su permiso. El servidor tendrá que volver a pedirlo.", "revoke": "Retirar", "revoked": "Permiso retirado" + }, + "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 b5ce8e291..d0d6356b6 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6561,5 +6561,35 @@ "label_description": "Võtke oma luba tagasi. Server peab siis uuesti küsima.", "revoke": "Võta tagasi", "revoked": "Luba võeti tagasi" + }, + "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 22a2c1d43..de207bb10 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6561,5 +6561,35 @@ "label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.", "revoke": "پس گرفتن", "revoked": "اجازه پس گرفته شد" + }, + "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 e30800b16..b51367064 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6561,5 +6561,35 @@ "label_description": "Retirez votre autorisation. Le serveur devra la redemander.", "revoke": "Retirer", "revoked": "Autorisation retirée" + }, + "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 571f30e34..613e36392 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6562,5 +6562,35 @@ "label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.", "revoke": "ביטול", "revoked": "ההרשאה בוטלה" + }, + "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 2ab59129e..b72bcfc0d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6561,5 +6561,35 @@ "label_description": "Vonja vissza az engedélyét. A kiszolgálónak újra kérnie kell.", "revoke": "Visszavonás", "revoked": "Engedély visszavonva" + }, + "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 a861d3390..421714d43 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6561,5 +6561,35 @@ "label_description": "Ritira il tuo permesso. Il server dovrà chiederlo di nuovo.", "revoke": "Ritira", "revoked": "Permesso ritirato" + }, + "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 0606617f1..1b72f2fb8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6561,5 +6561,35 @@ "label_description": "許可を取り消します。サーバーは再度確認する必要があります。", "revoke": "取り消す", "revoked": "許可を取り消しました" + }, + "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 7e0daa574..ce7ac48d8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6561,5 +6561,35 @@ "label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.", "revoke": "취소", "revoked": "허용을 취소했습니다" + }, + "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 05939fbc0..b45fabbb4 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6561,5 +6561,35 @@ "label_description": "Atsauciet savu atļauju. Serverim būs jālūdz vēlreiz.", "revoke": "Atsaukt", "revoked": "Atļauja atsaukta" + }, + "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 dfa942d34..243f02639 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6561,5 +6561,35 @@ "label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.", "revoke": "Буцааж авах", "revoked": "Зөвшөөрлийг буцааж авлаа" + }, + "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 a0002acca..f2f0c0de7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6561,5 +6561,35 @@ "label_description": "Trekk tilbake tillatelsen din. Serveren må da spørre på nytt.", "revoke": "Trekk tilbake", "revoked": "Tillatelsen er trukket tilbake" + }, + "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 5524a36da..6ab31ef9b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6561,5 +6561,35 @@ "label_description": "Trek uw toestemming in. De server moet dan opnieuw vragen.", "revoke": "Intrekken", "revoked": "Toestemming ingetrokken" + }, + "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 fa5b5481e..4590d48b3 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6561,5 +6561,35 @@ "label_description": "Cofnij swoją zgodę. Serwer będzie musiał zapytać ponownie.", "revoke": "Cofnij", "revoked": "Zgoda została cofnięta" + }, + "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 87a2e2ff1..97ed21d66 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6561,5 +6561,35 @@ "label_description": "Retire a sua permissão. O servidor terá de pedir novamente.", "revoke": "Retirar", "revoked": "Permissão retirada" + }, + "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 80c31fa28..0936d12f4 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6561,5 +6561,35 @@ "label_description": "Retrageți permisiunea. Serverul va trebui să ceară din nou.", "revoke": "Retrage", "revoked": "Permisiune retrasă" + }, + "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 55510f4c8..eb1641895 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6561,5 +6561,35 @@ "label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.", "revoke": "Отозвать", "revoked": "Разрешение отозвано" + }, + "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 9e12b12d2..4431aa2a7 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6561,5 +6561,35 @@ "label_description": "Odvolajte svoje povolenie. Server sa bude musieť opýtať znova.", "revoke": "Odvolať", "revoked": "Povolenie bolo odvolané" + }, + "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 0be3db9db..47156ac85 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6569,5 +6569,35 @@ "label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง", "revoke": "ถอน", "revoked": "ถอนการอนุญาตแล้ว" + }, + "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 fa74d9864..391215501 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6561,5 +6561,35 @@ "label_description": "İzninizi geri alın. Sunucunun yeniden sorması gerekir.", "revoke": "Geri al", "revoked": "İzin geri alındı" + }, + "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 391a5c203..ebed90c4e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6561,5 +6561,35 @@ "label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.", "revoke": "Відкликати", "revoked": "Дозвіл відкликано" + }, + "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 347fe2cd0..86de8e353 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6573,5 +6573,35 @@ "label_description": "Thu hồi quyền của bạn. Máy chủ sẽ phải hỏi lại.", "revoke": "Thu hồi", "revoked": "Đã thu hồi quyền" + }, + "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 97dacbb06..f3fb5a71a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6561,5 +6561,35 @@ "label_description": "收回你的許可。伺服器需要重新詢問。", "revoke": "收回", "revoked": "已收回許可" + }, + "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 de9e40f3c..3058a98c8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6561,5 +6561,35 @@ "label_description": "收回你的许可。服务器需要重新询问。", "revoke": "收回", "revoked": "已收回许可" + }, + "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": "无法复制文件。" } }