From 947743cc9e900334d805ffe62ad30a6beb535794 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:13:06 +0100
Subject: [PATCH 01/24] feat: connect the desktop app's tray, badge,
notifications, files and settings to the webmail
---
app/(main)/[locale]/layout.tsx | 2 +
components/desktop/desktop-os-host.tsx | 101 +++++
components/email/email-composer.tsx | 4 +
components/email/email-viewer.tsx | 1 +
components/email/reader-attachments.tsx | 26 +-
components/mail/mail-app.tsx | 12 +
.../protocol-launch-handler-provider.tsx | 16 +
components/settings/desktop-os-settings.tsx | 120 +++++
components/settings/general-settings.tsx | 3 +
.../__tests__/use-email-drag-routing.test.tsx | 29 ++
hooks/__tests__/use-new-mail-alerts.test.tsx | 8 +
hooks/use-app-badge.ts | 9 +
hooks/use-attachment-drag.ts | 16 +
hooks/use-email-drag.ts | 13 +
hooks/use-new-mail-alerts.ts | 4 +-
hooks/use-policy-refresh.ts | 7 +-
lib/desktop/__tests__/os-commands.test.ts | 245 ++++++++++
lib/desktop/__tests__/os-mail.test.ts | 338 ++++++++++++++
lib/desktop/__tests__/os-window.test.tsx | 425 ++++++++++++++++++
lib/desktop/app-lock-state.ts | 19 +
lib/desktop/file-drag.ts | 55 +++
lib/desktop/mail-links.ts | 44 ++
lib/desktop/mail-notifications.ts | 184 ++++++++
lib/desktop/new-mail.ts | 149 ++++++
lib/desktop/os-commands.ts | 181 ++++++++
lib/desktop/os-summary.ts | 130 ++++++
lib/desktop/power.ts | 70 +++
lib/desktop/types.ts | 117 +++++
lib/desktop/window-files.ts | 66 +++
lib/pwa-notify/__tests__/page-actions.test.ts | 38 +-
lib/pwa-notify/page-actions.ts | 22 +-
locales/ar/common.json | 30 ++
locales/ca/common.json | 30 ++
locales/cs/common.json | 30 ++
locales/da/common.json | 30 ++
locales/de/common.json | 30 ++
locales/en/common.json | 30 ++
locales/es/common.json | 30 ++
locales/et/common.json | 30 ++
locales/fa/common.json | 30 ++
locales/fr/common.json | 30 ++
locales/he/common.json | 30 ++
locales/hu/common.json | 30 ++
locales/it/common.json | 30 ++
locales/ja/common.json | 30 ++
locales/ko/common.json | 30 ++
locales/lv/common.json | 30 ++
locales/mn/common.json | 30 ++
locales/nb/common.json | 30 ++
locales/nl/common.json | 30 ++
locales/pl/common.json | 30 ++
locales/pt/common.json | 30 ++
locales/ro/common.json | 30 ++
locales/ru/common.json | 30 ++
locales/sk/common.json | 30 ++
locales/th/common.json | 30 ++
locales/tr/common.json | 30 ++
locales/uk/common.json | 30 ++
locales/vi/common.json | 30 ++
locales/zh-TW/common.json | 30 ++
locales/zh/common.json | 30 ++
61 files changed, 3348 insertions(+), 6 deletions(-)
create mode 100644 components/desktop/desktop-os-host.tsx
create mode 100644 components/settings/desktop-os-settings.tsx
create mode 100644 lib/desktop/__tests__/os-commands.test.ts
create mode 100644 lib/desktop/__tests__/os-mail.test.ts
create mode 100644 lib/desktop/__tests__/os-window.test.tsx
create mode 100644 lib/desktop/app-lock-state.ts
create mode 100644 lib/desktop/file-drag.ts
create mode 100644 lib/desktop/mail-links.ts
create mode 100644 lib/desktop/mail-notifications.ts
create mode 100644 lib/desktop/new-mail.ts
create mode 100644 lib/desktop/os-commands.ts
create mode 100644 lib/desktop/os-summary.ts
create mode 100644 lib/desktop/power.ts
create mode 100644 lib/desktop/window-files.ts
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 992bef2ec..fca72c365 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -28,6 +28,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { AppUpdatePrompt } from "@/components/app-update-prompt";
import { OfflineHost } from "@/components/offline/offline-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
+import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -85,6 +86,7 @@ export default async function LocaleLayout({
+
{!IS_LITE && }
diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx
new file mode 100644
index 000000000..c8d2755d8
--- /dev/null
+++ b/components/desktop/desktop-os-host.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { useArchiveActionEnabled } from "@/lib/archive-gate";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
+import { startOsSummary } from "@/lib/desktop/os-summary";
+import { startPowerWatch } from "@/lib/desktop/power";
+import { startWindowFiles } from "@/lib/desktop/window-files";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { calendarHooks } from "@/lib/plugin-hooks";
+import type { CalendarAlertView } from "@/lib/plugin-types";
+import { useAccountStore } from "@/stores/account-store";
+import { useAuthStore } from "@/stores/auth-store";
+import { useSettingsStore } from "@/stores/settings-store";
+
+/**
+ * The main window's end of the desktop app's OS integration: new mail as native
+ * notifications, what the tray and jump list show, waking up after sleep, and files
+ * dropped or pasted on the window. Renders nothing, and does nothing in a browser.
+ * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
+ */
+export function DesktopOsHost() {
+ const t = useTranslations("desktopOs");
+ const tNotify = useTranslations("notifications");
+ const tPush = useTranslations("pwaNotify");
+ const tCalendar = useTranslations("calendar.notifications");
+ const locale = useLocale();
+ const archiveEnabled = useArchiveActionEnabled();
+ const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n"));
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const mailRef = useRef(null);
+ const summaryRef = useRef<{ refresh: () => void } | null>(null);
+ const localeRef = useRef(locale);
+ localeRef.current = locale;
+
+ const labels: MailNotificationLabels = {
+ newMail: t("new_mail"),
+ newMailCount: (count) => tNotify("new_mail_count", { count }),
+ noSubject: t("no_subject"),
+ archive: archiveEnabled ? tPush("action_archive") : null,
+ markRead: tPush("action_mark_read"),
+ delete: t("action_delete"),
+ reply: tPush("action_reply"),
+ replyPlaceholder: tPush("reply_placeholder"),
+ };
+ const labelsRef = useRef(labels);
+ labelsRef.current = labels;
+
+ useEffect(() => {
+ if (!getDesktopBridge()?.os) return;
+ const mail = startMailNotifications(labelsRef.current);
+ mailRef.current = mail;
+ const summary = startOsSummary(() => localeRef.current);
+ summaryRef.current = summary;
+ const stopPower = startPowerWatch();
+ const stopFiles = startWindowFiles();
+ // Reminders' own native notification (snooze, join) comes with the reminders work.
+ // Until then a reminder must not fire into a hidden window unseen.
+ const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => {
+ const { calendarNotificationsEnabled } = useSettingsStore.getState();
+ if (!calendarNotificationsEnabled) return;
+ const event = "eventId" in view;
+ void getDesktopBridge()?.os?.notify({
+ id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`,
+ title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"),
+ body: "",
+ context: "{}",
+ });
+ });
+ return () => {
+ mail.stop();
+ summary.stop();
+ stopPower();
+ stopFiles();
+ reminders.dispose();
+ mailRef.current = null;
+ summaryRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ mailRef.current?.setLabels(labels);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [locale, archiveEnabled]);
+
+ // Every login needs a state to work from before its first push.
+ useEffect(() => {
+ const mail = mailRef.current;
+ if (!mail || !isAuthenticated) return;
+ const auth = useAuthStore.getState();
+ const clients = new Set(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? []));
+ if (auth.client) clients.add(auth.client);
+ for (const client of clients) mail.seed(client);
+ summaryRef.current?.refresh();
+ }, [isAuthenticated, clientKey]);
+
+ return null;
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index d4ff3a9a8..31341f1db 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -168,6 +168,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import type { RescuedDraft } from "@/lib/popout/channel";
import { toUnicodeEmail } from "@/lib/idn";
+import { setComposerFileTarget } from "@/lib/desktop/window-files";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -2642,6 +2643,9 @@ export function EmailComposer({
resetDragTimeout();
}, [resetDragTimeout]);
+ // The desktop app: files dropped or pasted anywhere on the window come here.
+ useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]);
+
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index e75a260ca..09c925b5e 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -677,6 +677,7 @@ interface DraggableAttachmentChipProps {
onPointerEnter: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: (e: React.DragEvent) => void;
+ onCopyAsFile?: () => Promise;
}) => React.ReactNode;
}
diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx
index 0e3aa1fbd..c92d2feca 100644
--- a/components/email/reader-attachments.tsx
+++ b/components/email/reader-attachments.tsx
@@ -2,12 +2,13 @@
import { useState, type DragEvent, type ReactNode } from "react";
import { useTranslations } from "next-intl";
-import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
+import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { cn, formatFileSize } from "@/lib/utils";
import { noMediaSavingProps } from "@/lib/device-frame";
+import { toast } from "@/stores/toast-store";
import { attachmentIcon } from "./attachment-chips";
export interface ReaderAttachmentItem {
@@ -26,6 +27,8 @@ export interface AttachmentDragProps {
onPointerEnter: () => void;
onDragStart: (e: DragEvent) => void;
onDragEnd: (e: DragEvent) => void;
+ /** The desktop app only. */
+ onCopyAsFile?: () => Promise;
}
/** Past this many tiles the bar shows one row less and a "Show N more" tile. */
@@ -159,6 +162,7 @@ export function ReaderAttachments({
)}
{pluginMenu?.(attachment)}
+ {!downloadsOff && drag.onCopyAsFile && }
{!downloadsOff && (
({
)}
{pluginMenu?.(attachment)}
+ {!downloadsOff && drag.onCopyAsFile && }
{!downloadsOff && (
onDownload(attachment)}>
@@ -332,6 +337,25 @@ export function ReaderAttachments({
* The image itself when there is a thumbnail, else the file type's icon over
* its extension, in the type's colour.
*/
+/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */
+function CopyAsFileButton({ copy }: { copy: () => Promise }) {
+ const t = useTranslations("desktopOs");
+ return (
+ {
+ void copy().then((ok) => {
+ if (ok) toast.success(t("copied"));
+ else toast.error(t("copy_failed"));
+ }, () => toast.error(t("copy_failed")));
+ }}
+ >
+
+
+ );
+}
+
function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) {
if (url) {
return
;
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index d475f4f2c..632c09b4f 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -143,6 +143,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
+import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
@@ -2922,6 +2923,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
+ // The desktop app's tray, jump list and toasts, like any other permalink. A live
+ // composer's text goes through its own "Save or discard?" first.
+ useEffect(() => {
+ return setMailLinkOpener((link, options) => {
+ const open = () => void applyMailDeepLinkRef.current(link, options);
+ const requestClose = composerRequestCloseRef.current;
+ if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open);
+ else open();
+ });
+ }, []);
+
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx
index d3d8f4942..0078cd8bf 100644
--- a/components/protocol/protocol-launch-handler-provider.tsx
+++ b/components/protocol/protocol-launch-handler-provider.tsx
@@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfig } from "@/hooks/use-config";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { startOsCommands } from "@/lib/desktop/os-commands";
import { flushComposers } from "@/lib/compose-flush-registry";
import { isPopoutPath } from "@/lib/popout/window-role";
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
@@ -201,6 +202,21 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
return subscribeHandoff("any", goToTarget);
}, [pathname, leaveFor]);
+ // The desktop app: what the OS asks for arrives over the bridge, not launchQueue.
+ const osEnv = useRef({ leaveFor, push: router.push as (path: string) => void, pathname });
+ osEnv.current = { leaveFor, push: router.push as (path: string) => void, pathname };
+ useEffect(() => {
+ if (isPopoutPath(window.location.pathname)) return;
+ return startOsCommands({
+ leaveFor: (path) => osEnv.current.leaveFor(path),
+ push: (path) => {
+ if (osEnv.current.pathname !== path) osEnv.current.push(path);
+ },
+ notifyFailed: () => toast.error(tLaunch("open_failed")),
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
useEffect(() => {
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx
new file mode 100644
index 000000000..1703487e6
--- /dev/null
+++ b/components/settings/desktop-os-settings.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types";
+import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
+
+/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */
+const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
+
+function shortcutLabel(platform: string | undefined): string {
+ return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
+}
+
+/**
+ * The desktop app's own settings, on the General page: only where there's an app to
+ * set. A browser never sees it, and it renders nothing until the bridge has answered.
+ * Quick compose adds its shortcut here later.
+ */
+export function DesktopOsSettings() {
+ const t = useTranslations("desktopOs");
+ const [status, setStatus] = useState(null);
+ const [defaultApp, setDefaultApp] = useState(null);
+ const bridge = getDesktopBridge();
+ const os = bridge?.os;
+
+ useEffect(() => {
+ if (!os) return;
+ let stale = false;
+ void os.getStatus().then((next) => {
+ if (!stale) setStatus(next);
+ });
+ void os.defaultAppStatus().then((next) => {
+ if (!stale) setDefaultApp(next);
+ });
+ const stop = os.onStatusChange(setStatus);
+ return () => {
+ stale = true;
+ stop();
+ };
+ }, [os]);
+
+ if (!os || !status) return null;
+ const { settings } = status;
+ const change = (patch: Partial) => {
+ void os.setSettings(patch).then(setStatus);
+ };
+
+ return (
+
+
+ change({ closeToTray })} />
+
+
+ change({ startAtLogin })} />
+
+ {settings.startAtLogin && (
+
+ change({ startMinimized })} />
+
+ )}
+
+ change({ attentionOnNewMail })} />
+
+
+ change({ quietHoursEnabled })} />
+
+ {settings.quietHoursEnabled && (
+
+
+ change({ quietHoursStart: event.target.value })}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm"
+ />
+ {t("quiet_hours_to")}
+ change({ quietHoursEnd: event.target.value })}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm"
+ />
+
+
+ )}
+
+ change({ showShortcut: on ? SHOW_SHORTCUT : null })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx
index 41d2ba072..3493681ca 100644
--- a/components/settings/general-settings.tsx
+++ b/components/settings/general-settings.tsx
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { LanguageSettings } from './language-settings';
import { ProtocolHandlerSettings } from './protocol-handler-settings';
+import { DesktopOsSettings } from './desktop-os-settings';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from '@/components/icons';
@@ -24,6 +25,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+
+
{!IS_LITE &&
}
diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx
index e53285031..242f40e5f 100644
--- a/hooks/__tests__/use-email-drag-routing.test.tsx
+++ b/hooks/__tests__/use-email-drag-routing.test.tsx
@@ -731,4 +731,33 @@ describe('message row drag hover wiring', () => {
expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/);
expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB));
});
+
+ it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
+ const email = mail({ subject: 'Hello world' });
+ const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
+ await warm(result);
+ const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!))));
+ try {
+ startDrag.mockClear();
+ // Plain: still a move to a folder, still DownloadURL for Explorer.
+ expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true);
+ expect(startDrag).toHaveBeenCalled();
+ expect(nativeDrag).not.toHaveBeenCalled();
+
+ startDrag.mockClear();
+ const preventDefault = vi.fn();
+ const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' };
+ act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent));
+ expect(preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') });
+ const [files] = nativeDrag.mock.calls[0];
+ expect(files[0].name).toBe('Hello world.eml');
+ expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA));
+ } finally {
+ window.bulwarkDesktop = undefined;
+ }
+ });
});
diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx
index c98b51dca..40c1fcee7 100644
--- a/hooks/__tests__/use-new-mail-alerts.test.tsx
+++ b/hooks/__tests__/use-new-mail-alerts.test.tsx
@@ -39,6 +39,14 @@ describe('new mail alerts', () => {
vi.useRealTimers();
});
+ it('in the desktop app leaves the sound to the notification path, so it plays once', () => {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never;
+ renderHook(() => useNewMailAlerts());
+ arrive('a');
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ window.bulwarkDesktop = undefined;
+ });
+
it('tells screen readers once how many messages came, never what they say', () => {
renderHook(() => useNewMailAlerts());
arrive('a');
diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts
index 927a869b5..d8a33b320 100644
--- a/hooks/use-app-badge.ts
+++ b/hooks/use-app-badge.ts
@@ -2,6 +2,8 @@
import { useEffect } from 'react';
import { debug } from '@/lib/debug';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isPopoutWindow } from '@/lib/popout/window-role';
/**
* Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to
@@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void {
const effectiveCount = enabled ? count : 0;
+ // The desktop app's own icon. A pop-out has its own account and no say in it.
+ const desktopOs = getDesktopBridge()?.os;
+ if (desktopOs) {
+ if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount);
+ return;
+ }
+
void (async () => {
try {
if (effectiveCount > 0) {
diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts
index 83cdce627..ce0047251 100644
--- a/hooks/use-attachment-drag.ts
+++ b/hooks/use-attachment-drag.ts
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
+import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag";
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
// drop to materialize a real file. Firefox and Safari ignore it, so we only
@@ -40,6 +41,8 @@ export interface UseAttachmentDragResult {
onPointerEnter: () => void;
onDragStart: (e: DragEvent) => void;
onDragEnd: (e: DragEvent) => void;
+ /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */
+ onCopyAsFile?: () => Promise;
}
const NOOP_HANDLERS: UseAttachmentDragResult = {
@@ -104,6 +107,13 @@ export function useAttachmentDrag(
return;
}
+ // In the desktop app the OS's own drag, with the real file on disk first.
+ if (canDragNativeFiles()) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name, url }]);
+ return;
+ }
+
// `DownloadURL` format: ::. The filename must be
// raw - URL-encoding it lands literally on disk (`%20` instead of a
// space). Callers are expected to sanitise reserved chars (`:` etc.)
@@ -125,6 +135,11 @@ export function useAttachmentDrag(
}
}, []);
+ const copyAsFile = useCallback(async () => {
+ const url = urlRef.current ?? await source.getBlobUrl();
+ return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false;
+ }, [source]);
+
if (!enabled) return NOOP_HANDLERS;
return {
@@ -132,5 +147,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
+ ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}),
};
}
diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts
index 25450a031..b60a4e910 100644
--- a/hooks/use-email-drag.ts
+++ b/hooks/use-email-drag.ts
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
+import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -295,6 +296,13 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const url = singleRef.current?.key === route.key ? singleRef.current.url : null;
if (url) {
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
+ // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
+ // to stay the page's, because it also moves the message to a folder.
+ if (e.altKey && canDragNativeFiles()) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name, url }]);
+ return;
+ }
// `DownloadURL` format: ::. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
@@ -313,6 +321,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
+ if (e.altKey && canDragNativeFiles()) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name: ready.name, url: ready.url }]);
+ return;
+ }
e.dataTransfer.setData(
"DownloadURL",
`application/zip:${ready.name}:${ready.url}`,
diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts
index 0b118372c..b2fd6801f 100644
--- a/hooks/use-new-mail-alerts.ts
+++ b/hooks/use-new-mail-alerts.ts
@@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound";
import { announce } from "@/lib/announce";
import { startTitleAlert } from "@/lib/title-alert";
import { debug } from "@/lib/debug";
+import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications";
/**
* Mail that arrives within this time is told about once ("3 new messages"),
@@ -51,7 +52,8 @@ export function useNewMailAlerts(): void {
const s = useSettingsStore.getState();
if (s.doNotDisturb) return;
if (batchTimer === null) {
- if (s.emailNotificationsEnabled && s.emailNotificationSound) {
+ // The desktop app sounds it with the notification, in Do Not Disturb not at all.
+ if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) {
playNotificationSound(s.notificationSoundChoice);
}
batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS);
diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts
index 3ae942946..f7115e422 100644
--- a/hooks/use-policy-refresh.ts
+++ b/hooks/use-policy-refresh.ts
@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
+import { isDesktopApp } from '@/lib/desktop/bridge';
import { usePolicyStore } from '@/stores/policy-store';
/** How often an open, visible tab fetches the policy again. */
@@ -13,7 +14,9 @@ let mounted = 0;
let interval: ReturnType | null = null;
function refresh(force = false): void {
- if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
+ // A browser tab out of view waits until it's back. The desktop app in the tray is
+ // out of view for days and still has to hear about a wipe or a changed policy.
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return;
const now = Date.now();
if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return;
const state = usePolicyStore.getState();
@@ -27,7 +30,7 @@ const onVisible = () => refresh();
/**
* Fetches the policy again when the tab comes back into view and every 15
- * minutes while it is visible, so an announcement (or any other change the
+ * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the
* admin saves) reaches tabs that stay open. One timer for the page however
* many components use it.
*/
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
new file mode 100644
index 000000000..465180c64
--- /dev/null
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -0,0 +1,245 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types';
+import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+
+/** What the OS asks for, carried out by the parts the app already has. */
+
+const runNotificationAction = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/pwa-notify/page-actions', () => ({ runNotificationAction }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
+let authenticated = true;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({ isAuthenticated: authenticated }),
+ subscribe: () => () => {},
+ },
+}));
+
+const session = vi.hoisted(() => ({
+ savePendingMailto: vi.fn(),
+ notifyPendingMailto: vi.fn(),
+ savePendingWebcal: vi.fn(),
+ notifyPendingWebcal: vi.fn(),
+}));
+vi.mock('@/lib/protocol-handlers/session', () => session);
+
+async function fingerprint() {
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ return loginFingerprint('https://one.example', 'alice@example.org');
+}
+
+const leaveFor = vi.fn();
+const push = vi.fn();
+const notifyFailed = vi.fn();
+const env = { leaveFor, push, notifyFailed };
+const focus = vi.fn();
+
+async function run(command: DesktopOsCommand) {
+ const { handleOsCommand } = await import('../os-commands');
+ await handleOsCommand(command, env);
+}
+
+const text = (value: string) => new TextEncoder().encode(value);
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ authenticated = true;
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as unknown as BulwarkDesktopBridge;
+ const { resetMailLinks } = await import('../mail-links');
+ resetMailLinks();
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ for (const kind of ['compose', 'eml', 'calendar', 'contacts'] as const) takeHandoff(kind);
+});
+
+afterEach(() => {
+ window.bulwarkDesktop = undefined;
+});
+
+describe('launches', () => {
+ it('New message goes through the launch handoff, with no question asked', async () => {
+ const { subscribeHandoff } = await import('@/lib/launch-handoff');
+ const fired = vi.fn();
+ const stop = subscribeHandoff('compose', fired);
+ await run({ type: 'new-message' });
+ expect(fired).toHaveBeenCalled();
+ stop();
+ });
+
+ it('a mailto goes through the protocol handler to the mail page', async () => {
+ await run({ type: 'mailto', url: 'mailto:bob@example.org?subject=Hi&body=Text' });
+ expect(session.savePendingMailto).toHaveBeenCalledTimes(1);
+ expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ to: ['bob@example.org'], subject: 'Hi' });
+ expect(session.notifyPendingMailto).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/');
+ });
+
+ it('a mailto that is not one does nothing', async () => {
+ await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) });
+ expect(push).not.toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a webcal goes to the calendar', async () => {
+ await run({ type: 'webcal', url: 'webcal://example.org/cal.ics' });
+ expect(session.savePendingWebcal).toHaveBeenCalledTimes(1);
+ expect(session.notifyPendingWebcal).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a double-clicked .eml, .ics and .vcf each reach their handler', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({
+ type: 'files',
+ reason: 'open',
+ failed: [],
+ files: [
+ { name: 'letter.eml', data: text('Subject: x') },
+ { name: 'party.ics', data: text('BEGIN:VCALENDAR') },
+ { name: 'bob.vcf', data: text('BEGIN:VCARD') },
+ ],
+ });
+ expect(await takeHandoff('eml')?.files[0].text()).toBe('Subject: x');
+ expect(takeHandoff('calendar')?.files[0].name).toBe('party.ics');
+ expect(takeHandoff('contacts')?.files[0].name).toBe('bob.vcf');
+ expect(notifyFailed).not.toHaveBeenCalled();
+ });
+
+ it('a file dropped on the icon becomes an attachment on a new draft', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] });
+ const draft = takeHandoff('compose');
+ expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']);
+ });
+
+ it('says so when a file could not be read', async () => {
+ await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] });
+ expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']);
+ });
+});
+
+describe('going somewhere', () => {
+ it('Calendar and Contacts leave through the save-first path', async () => {
+ await run({ type: 'navigate', target: 'calendar' });
+ expect(leaveFor).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('Inbox opens the folder on the mail page when it is mounted', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'navigate', target: 'inbox' });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ expect(leaveFor).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('with the mail page not mounted the link waits for it, and the app goes there', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ await run({ type: 'open-message', emailId: 'e1', accountId: 'acc' });
+ expect(leaveFor).toHaveBeenCalledWith('/');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ expect(opener).toHaveBeenCalledWith({ kind: 'message', id: 'e1', ownerAccountId: 'acc' }, undefined);
+ stop();
+ });
+});
+
+describe('a toast\'s buttons', () => {
+ const context = async (over: object = {}) => JSON.stringify({ emailId: 'e1', threadId: 't1', accountId: 'acc', slot: 3, login: await fingerprint(), ...over });
+ const finishWith = (status: NotificationActionStatus) => {
+ runNotificationAction.mockImplementation(async (_request: NotificationActionRequest, answer: (s: NotificationActionStatus) => void) => {
+ answer('accepted');
+ answer(status);
+ });
+ };
+
+ it.each(['archive', 'mark-read', 'delete'] as const)('%s runs through the page\'s notification action and stays out of sight', async (action) => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('done');
+ await run({ type: 'notification', action, context: await context() });
+ expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action, emailId: 'e1', accountId: 'acc', slot: 3, login: await fingerprint() });
+ expect(opener).not.toHaveBeenCalled();
+ expect(focus).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('a button that can\'t be carried out opens the message instead', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('failed');
+ await run({ type: 'notification', action: 'archive', context: await context() });
+ expect(focus).toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(expect.objectContaining({ kind: 'message', id: 'e1', slot: 3 }), undefined);
+ stop();
+ });
+
+ it('a reply opens the composer with the typed text, and is not sent', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'reply', context: await context(), reply: 'On my way' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(
+ { kind: 'message', id: 'e1', slot: 3, ownerAccountId: 'acc', reply: true },
+ { replyText: 'On my way' },
+ );
+ stop();
+ });
+
+ it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+
+ it('a click on a summary toast opens the Inbox of that login', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined);
+ stop();
+ });
+
+ it('junk in the context does no harm', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'archive', context: '{not json' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+});
+
+describe('listening', () => {
+ it('does nothing in a browser', async () => {
+ window.bulwarkDesktop = undefined;
+ const { startOsCommands } = await import('../os-commands');
+ expect(() => startOsCommands(env)()).not.toThrow();
+ });
+
+ it('subscribes, and carries out what arrives', async () => {
+ let listener: ((command: DesktopOsCommand) => void) | null = null;
+ window.bulwarkDesktop = {
+ version: 1,
+ platform: 'win32',
+ os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } },
+ } as unknown as BulwarkDesktopBridge;
+ const { startOsCommands } = await import('../os-commands');
+ const stop = startOsCommands(env);
+ listener!({ type: 'navigate', target: 'contacts' });
+ await Promise.resolve();
+ expect(leaveFor).toHaveBeenCalledWith('/contacts');
+ stop();
+ expect(listener).toBeNull();
+ });
+});
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
new file mode 100644
index 000000000..edfad4809
--- /dev/null
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -0,0 +1,338 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+/** New mail to a native notification: which messages count, what the toast may say. */
+
+const playNotificationSound = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound }));
+
+let pushWithoutContent = false;
+vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }];
+let client: IJMAPClient;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ activeAccountId: 'alice@one',
+ client,
+ getClientForAccount: () => client,
+ }),
+ },
+}));
+
+const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox;
+const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox;
+const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox;
+const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox;
+const MAILBOXES = [INBOX, SENT, NEWS, JUNK];
+
+function email(id: string, over: Partial = {}): Email {
+ return {
+ id,
+ threadId: `t-${id}`,
+ subject: `Secret ${id}`,
+ preview: 'preview text',
+ from: [{ name: 'Alice Sender', email: 'sender@example.org' }],
+ mailboxIds: { in: true },
+ keywords: {},
+ receivedAt: new Date().toISOString(),
+ ...over,
+ } as unknown as Email;
+}
+
+function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) {
+ const queue = [...changes];
+ return {
+ getAccountId: () => 'acc',
+ getAllMailboxes: vi.fn(async () => MAILBOXES),
+ getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })),
+ getEmailChanges: vi.fn(async (since: string) => {
+ const next = queue.shift();
+ return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null;
+ }),
+ getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])),
+ } as unknown as IJMAPClient;
+}
+
+const labels = {
+ newMail: 'new_mail',
+ newMailCount: (count: number) => `count:${count}`,
+ noSubject: 'no_subject',
+ archive: 'archive',
+ markRead: 'mark_read',
+ delete: 'delete',
+ reply: 'reply',
+ replyPlaceholder: 'placeholder',
+};
+
+function setBridge(bridge: Partial | undefined) {
+ window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
+}
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ pushWithoutContent = false;
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({
+ emailNotificationsEnabled: true,
+ emailNotificationSound: true,
+ doNotDisturb: false,
+ pushNotifyInboxOnly: true,
+ notificationMutes: { accounts: [], mailboxes: {} },
+ notificationSoundChoice: 'involved',
+ });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+});
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.useRealTimers();
+});
+
+describe('which new messages notify', () => {
+ it('a new unread message in the Inbox', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in');
+ });
+
+ it('nothing already read, a draft, or old mail coming back from an import', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ const old = new Date(Date.now() - 3 * 86400_000).toISOString();
+ expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ const filed = email('a', { mailboxIds: { news: true } });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ pushNotifyInboxOnly: false });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news');
+ expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('a muted folder or account stays silent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+});
+
+describe('the watch', () => {
+ const flush = async () => {
+ for (let i = 0; i < 20; i += 1) await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ };
+
+ it('resolves a push against its baseline, whatever page is open', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background');
+ await flush();
+ expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200);
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('tells a message once when the store and the push both report it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('without a baseline the first push only sets one: old mail is not news', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({}, []);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active');
+ await flush();
+ expect(fake.getEmailChanges).not.toHaveBeenCalled();
+ expect(onMail).not.toHaveBeenCalled();
+ watch.stop();
+ });
+
+ it('ignores a change after it stopped', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ watch.seed(fake);
+ await flush();
+ watch.stop();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(onMail).not.toHaveBeenCalled();
+ });
+});
+
+describe('what the notification says', () => {
+ const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) });
+
+ beforeEach(() => {
+ client = fakeClient({});
+ });
+
+ it('sender, subject, the buttons, and what a button needs after a restart', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ const notification = await buildMailNotification([one()], labels);
+ expect(notification.title).toBe('Alice Sender');
+ expect(notification.body).toBe('Secret a');
+ expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']);
+ expect(notification.reply?.label).toBe('reply');
+ expect(JSON.parse(notification.context)).toEqual({
+ accountId: 'acc',
+ slot: 3,
+ login: await loginFingerprint('https://one.example', 'alice@example.org'),
+ emailId: 'a',
+ threadId: 't-a',
+ });
+ });
+
+ it('no Archive button where the organisation has it off', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one()], { ...labels, archive: null });
+ expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']);
+ });
+
+ it('while the app is locked: no sender, no subject, no buttons', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one()], labels);
+ const text = JSON.stringify(notification);
+ expect(notification.title).toBe('new_mail');
+ expect(text).not.toContain('Alice Sender');
+ expect(text).not.toContain('Secret');
+ expect(notification.actions).toBeUndefined();
+ expect(notification.reply).toBeUndefined();
+ });
+
+ it('where the organisation keeps content off notifications: the same', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ pushWithoutContent = true;
+ const notification = await buildMailNotification([one()], labels);
+ expect(JSON.stringify(notification)).not.toContain('Secret');
+ expect(JSON.stringify(notification)).not.toContain('Alice Sender');
+ });
+
+ it('several at once are told about once, and that one has no message to act on', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
+ expect(notification.title).toBe('count:3');
+ expect(notification.actions).toBeUndefined();
+ expect(JSON.parse(notification.context).emailId).toBeUndefined();
+ });
+
+ it('several at once while locked say only how many', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one('a'), one('b')], labels);
+ expect(notification.title).toBe('count:2');
+ expect(notification.body).toBe('');
+ });
+});
+
+describe('from arrival to toast and sound', () => {
+ /** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */
+ const settle = async () => {
+ const end = performance.now() + 80;
+ while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve));
+ };
+
+ async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) {
+ const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true });
+ setBridge({ version: 1, platform: 'win32', os: { notify } as never });
+ client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ mail.seed(client);
+ await vi.advanceTimersByTimeAsync(10);
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active');
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ mail.stop();
+ return notify;
+ }
+
+ it('asks main, and plays the picked sound when main says so', async () => {
+ const notify = await arrive();
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(notify.mock.calls[0][0].attention).toBe(true);
+ expect(playNotificationSound).toHaveBeenCalledWith('involved');
+ });
+
+ it('no sound when main says it is quiet', async () => {
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationsEnabled: false });
+ expect(await arrive()).not.toHaveBeenCalled();
+ useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true });
+ expect(await arrive()).not.toHaveBeenCalled();
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it("the store's created event reaches a running watch, and nothing else", async () => {
+ const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const notify = vi.fn(async () => ({ shown: true, playSound: false }));
+ setBridge({ version: 1, platform: 'win32', os: { notify } as never });
+ client = fakeClient({ a: email('a') });
+ noteStoreCreated(client, 'acc', ['a']);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ noteStoreCreated(client, 'acc', ['a']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ mail.stop();
+ noteStoreCreated(client, 'acc', ['b']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+
+ it('tells main the sound is off when the user has it off', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationSound: false });
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify.mock.calls[0][0].sound).toBe(false);
+ });
+});
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
new file mode 100644
index 000000000..301ee9abc
--- /dev/null
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -0,0 +1,425 @@
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
+import type { CalendarEvent } from '@/lib/jmap/types';
+
+/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key),
+ useLocale: () => 'en',
+}));
+
+function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') {
+ window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined;
+}
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.unstubAllGlobals();
+});
+
+describe('the badge', () => {
+ it('goes to the bridge in the app, and the Badging API is left alone', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const setBadge = vi.fn();
+ const setAppBadge = vi.fn(async () => {});
+ Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true });
+ setBridge({ setBadge });
+ const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } });
+ expect(setBadge).toHaveBeenLastCalledWith(4);
+ rerender({ count: 4, enabled: false });
+ expect(setBadge).toHaveBeenLastCalledWith(0);
+ expect(setAppBadge).not.toHaveBeenCalled();
+ });
+
+ it('a pop-out has no say in it', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const role = await import('@/lib/popout/window-role');
+ const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true);
+ const setBadge = vi.fn();
+ setBridge({ setBadge });
+ renderHook(() => useAppBadge(4, true));
+ expect(setBadge).not.toHaveBeenCalled();
+ popout.mockRestore();
+ });
+});
+
+describe('new-mail sound', () => {
+ it('is left to the notification path in the app, not played twice', async () => {
+ const { desktopPlaysMailSound } = await import('../mail-notifications');
+ expect(desktopPlaysMailSound()).toBe(false);
+ setBridge({ notify: vi.fn() as never });
+ expect(desktopPlaysMailSound()).toBe(true);
+ });
+});
+
+describe('the policy keeps refreshing', () => {
+ const fetchPolicy = vi.fn(async () => {});
+
+ beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
+ fetchPolicy.mockClear();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ resetPolicyRefresh();
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete (document as unknown as Record).visibilityState;
+ });
+
+ it('in the desktop app while hidden in the tray', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ setBridge({});
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('but not in a browser tab out of view', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).not.toHaveBeenCalled();
+ unmount();
+ });
+});
+
+function dropEvent(type: string, files: File[]): Event {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
+ return event;
+}
+
+describe('files on the window', () => {
+ const file = new File(['x'], 'report.pdf');
+
+ beforeEach(async () => {
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ takeHandoff('compose');
+ });
+
+ it('nothing happens in a browser', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('a drop on the window starts a new draft with the file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const over = dropEvent('dragover', [file]);
+ window.dispatchEvent(over);
+ expect(over.defaultPrevented).toBe(true);
+ const drop = dropEvent('drop', [file]);
+ window.dispatchEvent(drop);
+ expect(drop.defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ it('a drop something else took is left alone', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ event.preventDefault();
+ window.dispatchEvent(event);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('text dragged around is not a file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('dragover', []);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ stop();
+ });
+
+ it('an open composer takes the file itself', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const target = vi.fn();
+ const clear = setComposerFileTarget(target);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(target).toHaveBeenCalledWith([file]);
+ expect(takeHandoff('compose')).toBeNull();
+ clear();
+ stop();
+ });
+
+ it('a pasted file is taken; pasted text is not', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const paste = (files: File[]) => {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { files } });
+ window.dispatchEvent(event);
+ return event;
+ };
+ expect(paste([]).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ expect(paste([file]).defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ it('our own native drag over our own window is not a new file', async () => {
+ const { setOwnDragActive, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ setOwnDragActive(true);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(takeHandoff('compose')).toBeNull();
+ setOwnDragActive(false);
+ stop();
+ });
+});
+
+describe('dragging an attachment out', () => {
+ const blobUrl = 'blob:https://app/1';
+ const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl };
+
+ async function ready() {
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ act(() => hook.result.current.onPointerEnter());
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ return hook;
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]))));
+ });
+
+ it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.dataTransfer.setData).not.toHaveBeenCalled();
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ expect([...files[0].data]).toEqual([1, 2, 3]);
+ });
+
+ it('in a browser: still DownloadURL, and no copy button', async () => {
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`);
+ expect(hook.result.current.onCopyAsFile).toBeUndefined();
+ });
+
+ it('Copy as file hands the bytes to main', async () => {
+ const copyFiles = vi.fn(async () => true);
+ setBridge({ startDrag: vi.fn() as never, copyFiles });
+ const hook = await ready();
+ await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true);
+ const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ });
+});
+
+describe('waking up', () => {
+ async function started(os: Partial = {}) {
+ let fire: (event: DesktopOsPowerEvent) => void = () => {};
+ setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
+ const power = await import('../power');
+ return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() };
+ }
+
+ it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => {
+ const { fire, power, stop } = await started();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ const fetchPolicy = vi.fn(async () => {});
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const visible = vi.fn();
+ const online = vi.fn();
+ const wake = vi.fn();
+ document.addEventListener('visibilitychange', visible);
+ window.addEventListener('online', online);
+ const stopWake = power.addWakeHandler(wake);
+ fire('resume');
+ fire('unlock-screen');
+ expect(visible).toHaveBeenCalledTimes(1);
+ expect(online).toHaveBeenCalledTimes(1);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ expect(wake).toHaveBeenCalledTimes(1);
+ document.removeEventListener('visibilitychange', visible);
+ window.removeEventListener('online', online);
+ stopWake();
+ stop();
+ });
+
+ it('a suspend tells the sleep handlers and wakes nothing', async () => {
+ const { fire, power, stop } = await started();
+ const sleep = vi.fn();
+ const visible = vi.fn();
+ const stopSleep = power.addSleepHandler(sleep);
+ document.addEventListener('visibilitychange', visible);
+ fire('suspend');
+ fire('lock-screen');
+ expect(sleep).toHaveBeenCalledTimes(1);
+ expect(visible).not.toHaveBeenCalled();
+ document.removeEventListener('visibilitychange', visible);
+ stopSleep();
+ stop();
+ });
+
+ it('a network change counts as waking', async () => {
+ const connection = new EventTarget();
+ vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection }));
+ const { power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).toHaveBeenCalled();
+ stopWake();
+ stop();
+ });
+});
+
+describe('the summary', () => {
+ const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent;
+ const now = new Date('2026-11-03T10:00:00Z').getTime();
+
+ it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => {
+ const { nextEventOf } = await import('../os-summary');
+ const events = [
+ event('later', '2026-11-03T15:00:00Z'),
+ event('past', '2026-11-03T09:00:00Z'),
+ event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }),
+ event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }),
+ event('soon', '2026-11-03T12:00:00Z'),
+ ];
+ expect(nextEventOf(events, now)?.id).toBe('soon');
+ expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull();
+ });
+
+ describe('with a login', () => {
+ const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] };
+ const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 }));
+ const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) };
+
+ beforeEach(async () => {
+ getEmails.mockClear();
+ const { useAuthStore } = await import('@/stores/auth-store');
+ const { useAccountStore } = await import('@/stores/account-store');
+ const { useEmailStore } = await import('@/stores/email-store');
+ useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' });
+ useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never });
+ useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never });
+ });
+
+ afterEach(async () => {
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+ });
+
+ it('lists unread mail with what the tray item needs to open it', async () => {
+ const { readSummary } = await import('../os-summary');
+ const summary = await readSummary('en');
+ expect(summary.unread).toEqual([expect.objectContaining({ emailId: 'e1', accountId: 'acc', sender: 'Alice', subject: 'Lunch?' })]);
+ expect(JSON.parse(summary.unread[0].context!)).toMatchObject({ emailId: 'e1', slot: 3 });
+ expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' });
+ });
+
+ it('while locked it is empty, without asking anyone anything', async () => {
+ const { setAppLockCheck } = await import('../app-lock-state');
+ const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ setAppLockCheck(() => true);
+ await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
+ expect(getEmails).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('the settings section', () => {
+ const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null };
+ const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false });
+
+ function bridgeFor(current: DesktopOsStatus) {
+ return {
+ getStatus: vi.fn(async () => current),
+ setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })),
+ onStatusChange: vi.fn(() => () => {}),
+ defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })),
+ openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })),
+ };
+ }
+
+ it('a browser never sees it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('in the app it shows the state and changes it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ expect(await screen.findByText('section_title')).toBeInTheDocument();
+ expect(screen.queryByText('start_minimized_label')).toBeNull();
+ // Second in the section, after Close to tray.
+ fireEvent.click(screen.getAllByRole('switch')[1]);
+ await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true }));
+ expect(await screen.findByText('start_minimized_label')).toBeInTheDocument();
+ });
+
+ it('the button offers the app and opens where the choice is made', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' }));
+ await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
+ });
+
+ it('says when another app holds the shortcut', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
+ render();
+ expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument();
+ });
+
+ it('macOS gets its own word for the bounce', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ setBridge(bridgeFor(status()) as never, 'darwin');
+ render();
+ expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
+ });
+});
diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts
new file mode 100644
index 000000000..72432d62b
--- /dev/null
+++ b/lib/desktop/app-lock-state.ts
@@ -0,0 +1,19 @@
+/**
+ * Whether the app is locked, for everything that would show mail outside the app.
+ *
+ * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a
+ * function that answers from its own state, and calls bridge.os.setLocked on every
+ * change: main then clears the tray, the jump list and delivered toasts, and strips
+ * anything that still comes through. Nothing else needs to change. Until then it
+ * answers false.
+ */
+
+let check: (() => boolean) | null = null;
+
+export function setAppLockCheck(next: (() => boolean) | null): void {
+ check = next;
+}
+
+export function isAppLocked(): boolean {
+ return check?.() === true;
+}
diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts
new file mode 100644
index 000000000..c3f6476a7
--- /dev/null
+++ b/lib/desktop/file-drag.ts
@@ -0,0 +1,55 @@
+/**
+ * Mail as real files from the page's side: a native drag or a copy to the clipboard.
+ * Later agents' calls for the taskbar's progress bar live here too.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { setOwnDragActive } from '@/lib/desktop/window-files';
+
+export interface DragFile {
+ name: string;
+ /** A blob: URL the page already holds. */
+ url?: string;
+ data?: Uint8Array;
+}
+
+export function canDragNativeFiles(): boolean {
+ return !!getDesktopBridge()?.os?.startDrag;
+}
+
+async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
+ if (file.data) return { name: file.name, data: file.data };
+ if (!file.url) throw new Error('A file needs its bytes or a URL');
+ const response = await fetch(file.url);
+ return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) };
+}
+
+/**
+ * Hands the file to main, which writes it to its private folder and starts the OS's
+ * drag. The mouse is still down, so this has to come straight from `dragstart`.
+ */
+export async function startNativeFileDrag(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.startDrag) return false;
+ setOwnDragActive(true);
+ try {
+ return await os.startDrag(await Promise.all(files.map(toBytes)));
+ } finally {
+ setOwnDragActive(false);
+ }
+}
+
+/** Copy attachments as files, for pasting into Explorer or Finder. */
+export async function copyFilesToClipboard(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.copyFiles) return false;
+ return os.copyFiles(await Promise.all(files.map(toBytes)));
+}
+
+/**
+ * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null
+ * to take it away. Does nothing in a browser.
+ */
+export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void {
+ getDesktopBridge()?.os?.setProgress(progress, mode);
+}
diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts
new file mode 100644
index 000000000..12416ef30
--- /dev/null
+++ b/lib/desktop/mail-links.ts
@@ -0,0 +1,44 @@
+/**
+ * Opening a message or folder from outside the mail page: a tray item, a toast, a jump
+ * list entry. The mail page applies a link the way it applies a permalink. If it isn't
+ * on screen, the link waits and the app goes there.
+ */
+
+import type { MailDeepLink } from '@/lib/deep-links';
+
+export interface MailLinkOptions {
+ replyText?: string;
+}
+
+export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void;
+
+let opener: MailLinkOpener | null = null;
+let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null;
+
+/** The mail page, while it's mounted. Takes over a link that was waiting for it. */
+export function setMailLinkOpener(next: MailLinkOpener): () => void {
+ opener = next;
+ if (waiting) {
+ const { link, options } = waiting;
+ waiting = null;
+ next(link, options);
+ }
+ return () => {
+ if (opener === next) opener = null;
+ };
+}
+
+export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void {
+ if (opener) {
+ opener(link, options);
+ return;
+ }
+ waiting = { link, options };
+ void leaveFor('/');
+}
+
+/** For tests. */
+export function resetMailLinks(): void {
+ opener = null;
+ waiting = null;
+}
diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts
new file mode 100644
index 000000000..090c9ebdf
--- /dev/null
+++ b/lib/desktop/mail-notifications.ts
@@ -0,0 +1,184 @@
+/**
+ * New mail as native notifications, with the same rules as the app's other ways of
+ * saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that
+ * arrives together is told about once. Sender and subject stay off the notification
+ * when the app is locked, or when the organisation says so for that login
+ * (pushWithoutContent, the rule the push preview route follows).
+ *
+ * Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers
+ * whether the page should play the sound the user picked, because this is the one
+ * place that sound is played from in the desktop app.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import type { DesktopOsNotification } from '@/lib/desktop/types';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail';
+import { devicesForAccount } from '@/lib/device-policy';
+import { playNotificationSound } from '@/lib/notification-sound';
+import { isPopoutWindow } from '@/lib/popout/window-role';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+export const MAIL_BATCH_MS = 3000;
+
+export interface MailNotificationLabels {
+ newMail: string;
+ newMailCount: (count: number) => string;
+ noSubject: string;
+ archive: string | null;
+ markRead: string;
+ delete: string;
+ reply: string;
+ replyPlaceholder: string;
+}
+
+/** What a toast's button hands back, possibly after the app restarted. */
+export interface MailNotificationContext {
+ emailId?: string;
+ threadId?: string;
+ accountId?: string;
+ slot?: number;
+ login?: string;
+}
+
+/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */
+export function desktopPlaysMailSound(): boolean {
+ return !!getDesktopBridge()?.os && !isPopoutWindow();
+}
+
+interface Sender {
+ entryId: string | null;
+ slot?: number;
+ login?: string;
+ label: string | null;
+}
+
+async function senderInfo(client: IJMAPClient): Promise {
+ const auth = useAuthStore.getState();
+ const accounts = useAccountStore.getState().accounts;
+ const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client)
+ ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined);
+ if (!entry) return { entryId: null, label: null };
+ return {
+ entryId: entry.id,
+ slot: entry.cookieSlot,
+ login: await loginFingerprint(entry.serverUrl, entry.username),
+ label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null,
+ };
+}
+
+function withoutContent(sender: Sender): boolean {
+ if (isAppLocked()) return true;
+ // Unknown counts as on, as on the server's route.
+ if (!sender.entryId) return true;
+ try {
+ return devicesForAccount(sender.entryId).pushWithoutContent;
+ } catch {
+ return true;
+ }
+}
+
+function senderName(mail: NewMail): string {
+ const first = mail.email.from?.[0];
+ return first?.name || first?.email || '';
+}
+
+export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise {
+ const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a));
+ const sender = await senderInfo(newest.client);
+ const hidden = withoutContent(sender);
+ const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login };
+ const base = {
+ id: `mail:${newest.accountId}/${newest.email.id}`,
+ group: `mail:${newest.accountId}`,
+ sound: true,
+ attention: true,
+ };
+ const suffix = sender.label && !hidden ? ` (${sender.label})` : '';
+ if (batch.length > 1) {
+ return {
+ ...base,
+ title: `${labels.newMailCount(batch.length)}${suffix}`,
+ body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`,
+ // Nothing for one message to act on: a click opens the Inbox.
+ context: JSON.stringify(context),
+ };
+ }
+ const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId };
+ if (hidden) {
+ return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) };
+ }
+ return {
+ ...base,
+ title: `${senderName(newest) || labels.newMail}${suffix}`,
+ body: newest.email.subject || newest.email.preview || labels.noSubject,
+ context: JSON.stringify(full),
+ actions: [
+ ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []),
+ { id: 'mark-read', label: labels.markRead },
+ { id: 'delete', label: labels.delete },
+ ],
+ reply: { label: labels.reply, placeholder: labels.replyPlaceholder },
+ };
+}
+
+export interface MailNotifications extends NewMailWatch {
+ setLabels(labels: MailNotificationLabels): void;
+}
+
+let current: MailNotifications | null = null;
+
+/**
+ * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once
+ * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`.
+ * Does nothing where notifications don't run: in a browser, in a pop-out.
+ */
+export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
+ current?.noteCreated(client, accountId, ids);
+}
+
+export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
+ let labels = initial;
+ let batch: NewMail[] = [];
+ let timer: ReturnType | null = null;
+
+ const flush = () => {
+ timer = null;
+ const current = batch;
+ batch = [];
+ if (current.length === 0) return;
+ const settings = useSettingsStore.getState();
+ if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return;
+ void (async () => {
+ const notification = await buildMailNotification(current, labels);
+ const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound });
+ if (result?.playSound) playNotificationSound(settings.notificationSoundChoice);
+ })().catch((error) => {
+ console.error('Failed to show a notification:', error);
+ });
+ };
+
+ const watch = startNewMailWatch((mail) => {
+ batch.push(...mail);
+ timer ??= setTimeout(flush, MAIL_BATCH_MS);
+ });
+
+ const handle: MailNotifications = {
+ ...watch,
+ setLabels(next) {
+ labels = next;
+ },
+ stop() {
+ watch.stop();
+ if (timer) clearTimeout(timer);
+ timer = null;
+ if (current === handle) current = null;
+ },
+ };
+ current = handle;
+ return handle;
+}
diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts
new file mode 100644
index 000000000..31a6568ea
--- /dev/null
+++ b/lib/desktop/new-mail.ts
@@ -0,0 +1,149 @@
+/**
+ * Which messages count as new mail for the desktop app's notifications, for every
+ * connected login, whichever page or folder is open.
+ *
+ * With the local store the source is its `created` event (noteCreatedEmails): the
+ * engine has already worked out the delta. Without one, each login's push change is
+ * resolved here with Email/changes on a baseline of its own, the way
+ * reportNewInboxMail does for plugins. Both can see the same message; it's only told
+ * once.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { isMailboxMuted } from '@/lib/notification-mutes';
+import { subscribePushStateChanges } from '@/lib/push-events';
+import { useSettingsStore } from '@/stores/settings-store';
+
+export interface NewMail {
+ client: IJMAPClient;
+ /** The JMAP account the message is in. */
+ accountId: string;
+ email: Email;
+}
+
+const MAX_PAGES = 5;
+const MAX_CHANGES = 200;
+const SEEN_LIMIT = 500;
+/** An import or a restore creates old mail: nobody is waiting to hear about that. */
+const NEWEST_AGE_MS = 24 * 60 * 60 * 1000;
+/** Folders mail lands in by itself, when not only the Inbox notifies. */
+const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']);
+
+function mailboxIdsOf(email: Email): string[] {
+ return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
+}
+
+/** The folder a new message counts for, or null if it stays silent. */
+export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null {
+ if (email.keywords?.$seen || email.keywords?.$draft) return null;
+ const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now;
+ if (now - received > NEWEST_AGE_MS) return null;
+ const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState();
+ const here = new Set(mailboxIdsOf(email));
+ const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id));
+ const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? '')));
+ // Mail filed in a muted folder and an unmuted one still notifies.
+ return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null;
+}
+
+export interface NewMailWatch {
+ /** The local store's `created` event for this login's account. */
+ noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void;
+ /** Takes a state to work from, so the first push after the start can tell what's new. */
+ seed(client: IJMAPClient): void;
+ stop(): void;
+}
+
+export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch {
+ const baselines = new WeakMap>();
+ const seen: string[] = [];
+ let queue: Promise = Promise.resolve();
+ let stopped = false;
+
+ const baselinesOf = (client: IJMAPClient) => {
+ let map = baselines.get(client);
+ if (!map) {
+ map = new Map();
+ baselines.set(client, map);
+ }
+ return map;
+ };
+
+ const enqueue = (run: () => Promise) => {
+ const next = queue.then(run, run).catch((error) => {
+ console.error('Failed to check for new mail:', error);
+ });
+ queue = next;
+ return next;
+ };
+
+ const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => {
+ const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`));
+ if (fresh.length === 0 || stopped) return;
+ for (const id of fresh) seen.push(`${accountId}/${id}`);
+ seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
+ const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]);
+ const own = client.getAccountId();
+ const mail = emails
+ .filter((email) => notifyingMailbox(email, accountId, own, mailboxes))
+ .map((email) => ({ client, accountId, email }));
+ if (mail.length > 0 && !stopped) onMail(mail);
+ };
+
+ const check = async (client: IJMAPClient, accountId: string, pushed: string) => {
+ const map = baselinesOf(client);
+ const since = map.get(accountId);
+ if (!since || !client.getEmailChanges) {
+ map.set(accountId, pushed);
+ return;
+ }
+ if (since === pushed) return;
+ const created: string[] = [];
+ let state = since;
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES);
+ if (!delta) {
+ // Too old for the server: start again from here.
+ map.set(accountId, pushed);
+ return;
+ }
+ created.push(...delta.created);
+ state = delta.newState;
+ if (!delta.hasMoreChanges) break;
+ }
+ map.set(accountId, state);
+ await report(client, accountId, created);
+ };
+
+ const stopPush = subscribePushStateChanges((change, client) => {
+ for (const [accountId, types] of Object.entries(change.changed ?? {})) {
+ const pushed = types?.Email;
+ if (pushed) void enqueue(() => check(client, accountId, pushed));
+ }
+ });
+
+ return {
+ noteCreated(client, accountId, ids) {
+ void enqueue(() => report(client, accountId, ids));
+ },
+ seed(client) {
+ void enqueue(async () => {
+ const map = baselinesOf(client);
+ const own = client.getAccountId();
+ const mailboxes = await client.getAllMailboxes();
+ const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox');
+ await Promise.all(inboxes.map(async (inbox) => {
+ const accountId = inbox.accountId ?? own;
+ if (map.has(accountId)) return;
+ const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1);
+ if (state && !map.has(accountId)) map.set(accountId, state);
+ }));
+ });
+ },
+ stop() {
+ stopped = true;
+ stopPush();
+ },
+ };
+}
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
new file mode 100644
index 000000000..8c7332225
--- /dev/null
+++ b/lib/desktop/os-commands.ts
@@ -0,0 +1,181 @@
+/**
+ * What the OS asks of the app, carried out with what the app already has: the launch
+ * handoff for files and new messages, the protocol handlers for mailto: and webcal:,
+ * the notification actions for a toast's buttons. A bridge command comes from the
+ * user's own OS, so it skips the question a web share gets (decision 17).
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import type { DesktopOsCommand } from '@/lib/desktop/types';
+import { openMailLink } from '@/lib/desktop/mail-links';
+import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
+import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
+import { parseMailto } from '@/lib/protocol-handlers/mailto';
+import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session';
+import { parseWebcal } from '@/lib/protocol-handlers/webcal';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+export interface OsCommandEnv {
+ /** Goes to a page, saving an open composer first. */
+ leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise;
+ /** A route change that isn't leaving anything behind. */
+ push: (path: string) => void;
+ /** Says something couldn't be opened. */
+ notifyFailed: (names: readonly string[]) => void;
+}
+
+/** How long a toast's button waits for a cold start to sign in. */
+const SIGN_IN_WAIT_MS = 30_000;
+
+function parseContext(raw: string | undefined): MailNotificationContext {
+ if (!raw) return {};
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {};
+ } catch {
+ return {};
+ }
+}
+
+function whenSignedIn(): Promise {
+ return new Promise((resolve) => {
+ if (useAuthStore.getState().isAuthenticated) {
+ resolve(true);
+ return;
+ }
+ const finish = (value: boolean) => {
+ clearTimeout(timer);
+ stop();
+ resolve(value);
+ };
+ const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS);
+ const stop = useAuthStore.subscribe((state) => {
+ if (state.isAuthenticated) finish(true);
+ });
+ });
+}
+
+/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */
+async function stillThatLogin(context: MailNotificationContext): Promise {
+ if (context.slot === undefined || !context.login) return false;
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
+ return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login;
+}
+
+function showWindow(): void {
+ getDesktopBridge()?.windows?.focus?.();
+}
+
+async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise {
+ showWindow();
+ await whenSignedIn();
+ const known = await stillThatLogin(context);
+ if (known && context.emailId) {
+ openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor);
+ return;
+ }
+ openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor);
+}
+
+function runAction(request: NotificationActionRequest): Promise {
+ return new Promise((resolve) => {
+ void runNotificationAction(request, (status) => {
+ if (status !== 'accepted') resolve(status);
+ });
+ });
+}
+
+async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise {
+ const context = parseContext(rawContext);
+ if (action === 'reply') {
+ await openFromNotification(context, env, { text: reply ?? '' });
+ return;
+ }
+ if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) {
+ if (await whenSignedIn()) {
+ const status = await runAction({
+ action,
+ emailId: context.emailId,
+ accountId: context.accountId,
+ threadId: context.threadId,
+ slot: context.slot,
+ login: context.login,
+ at: Date.now(),
+ });
+ if (status === 'done') return;
+ }
+ }
+ // Clicked, or the button couldn't be carried out here: the message opens.
+ await openFromNotification(context, env);
+}
+
+function handleFiles(command: Extract, env: OsCommandEnv): void {
+ const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
+ const failed = [...command.failed];
+ if (command.reason === 'attach') {
+ if (made.length > 0) handOffDraft({ files: made });
+ } else {
+ for (const file of made) {
+ const kind = kindForFile(file);
+ if (kind) handOffFiles([file], kind);
+ else failed.push(file.name);
+ }
+ }
+ if (failed.length > 0) env.notifyFailed(failed);
+}
+
+export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise {
+ switch (command.type) {
+ case 'new-message':
+ handOffNewMessage();
+ return;
+ case 'navigate':
+ if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor);
+ else void env.leaveFor(`/${command.target}`);
+ return;
+ case 'open-message': {
+ const context = parseContext(command.context);
+ if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env);
+ openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor);
+ return;
+ }
+ case 'open-mailbox':
+ openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor);
+ return;
+ case 'mailto': {
+ const parsed = parseMailto(command.url);
+ if (!parsed) return;
+ savePendingMailto(parsed);
+ notifyPendingMailto();
+ env.push('/');
+ return;
+ }
+ case 'webcal': {
+ const parsed = parseWebcal(command.url);
+ if (!parsed) return;
+ savePendingWebcal(parsed);
+ notifyPendingWebcal();
+ env.push('/calendar');
+ return;
+ }
+ case 'files':
+ handleFiles(command, env);
+ return;
+ case 'notification':
+ return handleNotification(command.action, command.context, command.reply, env);
+ }
+}
+
+/** Listens for as long as the page lives. Commands wait in main until this runs. */
+export function startOsCommands(env: OsCommandEnv): () => void {
+ const os = getDesktopBridge()?.os;
+ if (!os) return () => {};
+ return os.onCommand((command) => {
+ void Promise.resolve(handleOsCommand(command, env)).catch((error) => {
+ console.error('An OS command failed:', error);
+ });
+ });
+}
diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts
new file mode 100644
index 000000000..12d014051
--- /dev/null
+++ b/lib/desktop/os-summary.ts
@@ -0,0 +1,130 @@
+/**
+ * What the tray and the jump list show: recent unread mail, the newest conversations,
+ * the next calendar event. Read with the same client calls as the rest of the app, so
+ * the local store answers when it has them. No content while locked or where the
+ * organisation keeps it off notifications.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types';
+import { devicesForAccount } from '@/lib/device-policy';
+import type { CalendarEvent, Email } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useEmailStore } from '@/stores/email-store';
+
+const ROWS = 5;
+const REFRESH_DELAY_MS = 2000;
+const REFRESH_EVERY_MS = 5 * 60 * 1000;
+const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+export const EMPTY_SUMMARY: DesktopOsSummary = { unread: [], recent: [], nextEvent: null };
+
+export function eventStart(event: CalendarEvent): number {
+ return new Date(event.utcStart ?? event.start).getTime();
+}
+
+/** The soonest event still ahead, leaving out cancelled ones. */
+export function nextEventOf(events: readonly CalendarEvent[], now: number): CalendarEvent | null {
+ const ahead = events
+ .filter((event) => event.status !== 'cancelled' && !event.showWithoutTime && eventStart(event) >= now)
+ .sort((a, b) => eventStart(a) - eventStart(b));
+ return ahead[0] ?? null;
+}
+
+export function formatEventTime(start: number, now: number, locale: string | undefined): string {
+ const date = new Date(start);
+ const sameDay = date.toDateString() === new Date(now).toDateString();
+ const options: Intl.DateTimeFormatOptions = sameDay
+ ? { hour: 'numeric', minute: '2-digit' }
+ : { weekday: 'short', hour: 'numeric', minute: '2-digit' };
+ return new Intl.DateTimeFormat(locale, options).format(date);
+}
+
+export async function readSummary(locale: string | undefined, now = Date.now()): Promise {
+ if (isAppLocked()) return EMPTY_SUMMARY;
+ const auth = useAuthStore.getState();
+ const client: IJMAPClient | null = auth.client;
+ const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId);
+ if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY;
+ if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY;
+
+ const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared);
+ let unread: DesktopOsTrayMessage[] = [];
+ let recent: DesktopOsTrayMessage[] = [];
+ if (inbox) {
+ const accountId = client.getAccountId();
+ const login = await loginFingerprint(entry.serverUrl, entry.username);
+ const row = (email: Email): DesktopOsTrayMessage => ({
+ emailId: email.id,
+ accountId,
+ sender: email.from?.[0]?.name || email.from?.[0]?.email || '',
+ subject: email.subject ?? '',
+ context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }),
+ });
+ const [unreadList, recentList] = await Promise.all([
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }),
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS),
+ ]);
+ unread = unreadList.emails.map(row);
+ recent = recentList.emails.map(row);
+ }
+
+ let nextEvent: DesktopOsSummary['nextEvent'] = null;
+ if (useCalendarStore.getState().supportsCalendar) {
+ const events = await client.queryCalendarEvents({
+ after: new Date(now).toISOString(),
+ before: new Date(now + EVENT_WINDOW_MS).toISOString(),
+ }).catch(() => [] as CalendarEvent[]);
+ const next = nextEventOf(events, now);
+ if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
+ }
+ return { unread, recent, nextEvent };
+}
+
+/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */
+export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } {
+ const os = getDesktopBridge()?.os;
+ if (!os) return { refresh: () => {}, stop: () => {} };
+ let timer: ReturnType | null = null;
+ let stale = false;
+
+ const run = () => {
+ timer = null;
+ void readSummary(locale()).then((summary) => {
+ if (!stale) os.setSummary(summary);
+ }, (error) => {
+ console.warn('Could not read the tray summary:', error);
+ });
+ };
+ const refresh = () => {
+ timer ??= setTimeout(run, REFRESH_DELAY_MS);
+ };
+ const inboxUnread = () => useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared)?.unreadEmails ?? -1;
+ let lastUnread = inboxUnread();
+ const stopEmail = useEmailStore.subscribe(() => {
+ const current = inboxUnread();
+ if (current === lastUnread) return;
+ lastUnread = current;
+ refresh();
+ });
+ const stopAuth = useAuthStore.subscribe((state, previous) => {
+ if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh();
+ });
+ const interval = setInterval(refresh, REFRESH_EVERY_MS);
+ refresh();
+ return {
+ refresh,
+ stop() {
+ stale = true;
+ stopEmail();
+ stopAuth();
+ clearInterval(interval);
+ if (timer) clearTimeout(timer);
+ },
+ };
+}
diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts
new file mode 100644
index 000000000..84d5ccd58
--- /dev/null
+++ b/lib/desktop/power.ts
@@ -0,0 +1,70 @@
+/**
+ * Waking up without stale state. After sleep, an unlock or a network change, the app
+ * does what it does when a tab comes back into view: the JMAP client checks for state
+ * changes and replaces a dead stream, the identities and policy refresh. Those are the
+ * handlers already listening, so this only gives them the word. The hidden main window
+ * of the desktop app never goes out of view, so without this they wouldn't hear it.
+ *
+ * The local store registers with addWakeHandler and addSleepHandler (setOnline, sync).
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { usePolicyStore } from '@/stores/policy-store';
+
+type Handler = () => void;
+
+const wakeHandlers = new Set();
+const sleepHandlers = new Set();
+/** A resume and an unlock usually come together. */
+const WAKE_REPEAT_MS = 2000;
+
+export function addWakeHandler(handler: Handler): () => void {
+ wakeHandlers.add(handler);
+ return () => wakeHandlers.delete(handler);
+}
+
+export function addSleepHandler(handler: Handler): () => void {
+ sleepHandlers.add(handler);
+ return () => sleepHandlers.delete(handler);
+}
+
+function runAll(handlers: Set): void {
+ for (const handler of [...handlers]) {
+ try {
+ handler();
+ } catch (error) {
+ console.error('A wake or sleep handler failed:', error);
+ }
+ }
+}
+
+function wake(): void {
+ document.dispatchEvent(new Event('visibilitychange'));
+ window.dispatchEvent(new Event('online'));
+ const policy = usePolicyStore.getState();
+ if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
+ runAll(wakeHandlers);
+}
+
+export function startPowerWatch(): () => void {
+ const bridge = getDesktopBridge()?.os;
+ if (!bridge) return () => {};
+ let lastWake = 0;
+ const wakeOnce = () => {
+ const now = Date.now();
+ if (now - lastWake < WAKE_REPEAT_MS) return;
+ lastWake = now;
+ wake();
+ };
+ const stopPower = bridge.onPower((event) => {
+ if (event === 'resume' || event === 'unlock-screen') wakeOnce();
+ else if (event === 'suspend') runAll(sleepHandlers);
+ });
+ // Wi-Fi to cable is a new network with no offline moment in between.
+ const connection = (navigator as unknown as { connection?: EventTarget }).connection;
+ connection?.addEventListener?.('change', wakeOnce);
+ return () => {
+ stopPower();
+ connection?.removeEventListener?.('change', wakeOnce);
+ };
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index e049b727b..455ae5f1d 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -111,6 +111,122 @@ export interface DesktopPluginsBridge {
install: (slug: string, version?: string) => Promise;
}
+export interface DesktopOsSettings {
+ /** Closing the main window leaves the app running in the tray. */
+ closeToTray: boolean;
+ startAtLogin: boolean;
+ /** At login: no window, only the tray. */
+ startMinimized: boolean;
+ /** Taskbar flash or dock bounce on new mail. */
+ attentionOnNewMail: boolean;
+ quietHoursEnabled: boolean;
+ /** HH:MM, local time. */
+ quietHoursStart: string;
+ quietHoursEnd: string;
+ /** "Show Bulwark", an Electron accelerator. Null: off. */
+ showShortcut: string | null;
+}
+
+export interface DesktopOsStatus {
+ settings: DesktopOsSettings;
+ pausedUntil: number | null;
+ /** The shortcut in settings is really held. Another app can have it. */
+ shortcutActive: boolean;
+}
+
+export interface DesktopOsFile {
+ name: string;
+ data: Uint8Array;
+}
+
+/** What the OS asks the app to do: a tray item, a jump list entry, a toast, a double-clicked file. */
+export type DesktopOsCommand =
+ | { type: 'new-message' }
+ | { type: 'navigate'; target: 'inbox' | 'calendar' | 'contacts' }
+ | { type: 'open-message'; emailId: string; accountId?: string; context?: string }
+ | { type: 'open-mailbox'; mailboxId: string }
+ | { type: 'mailto'; url: string }
+ | { type: 'webcal'; url: string }
+ | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] }
+ | { type: 'notification'; action: string; context: string; reply?: string };
+
+export interface DesktopOsNotification {
+ /** Tag of the toast. Showing an id again within a few minutes does nothing. */
+ id: string;
+ group?: string;
+ title: string;
+ body: string;
+ /** Handed back with the action. Keep it short: Windows keeps it in the toast. */
+ context: string;
+ actions?: { id: string; label: string }[];
+ reply?: { label: string; placeholder: string };
+ /** The user wants a sound with this. */
+ sound?: boolean;
+ /** New mail: flash the taskbar or bounce the dock if that's on. */
+ attention?: boolean;
+}
+
+export interface DesktopOsNotifyResult {
+ shown: boolean;
+ reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid';
+ /** The page should play the sound the user picked. */
+ playSound: boolean;
+}
+
+export interface DesktopOsTrayMessage {
+ emailId: string;
+ accountId: string;
+ sender: string;
+ subject: string;
+ context?: string;
+}
+
+export interface DesktopOsSummary {
+ unread: DesktopOsTrayMessage[];
+ /** Newest conversations, whether read or not, for the jump list. */
+ recent: DesktopOsTrayMessage[];
+ nextEvent: { title: string; when: string } | null;
+}
+
+export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen';
+
+export interface DesktopOsDefaultAppStatus {
+ /** Offered to the OS as a mail app. */
+ registered: boolean;
+ mailto: boolean;
+ webcal: boolean;
+}
+
+export interface DesktopOsBridge {
+ /**
+ * What the OS asks for. Main holds these until the main window's page subscribes,
+ * so a cold start loses nothing.
+ */
+ onCommand: (listener: (command: DesktopOsCommand) => void) => () => void;
+ getStatus: () => Promise;
+ setSettings: (patch: Partial) => Promise;
+ /** Pause notifications until this time, or null to end the pause. */
+ setPaused: (until: number | null) => void;
+ onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void;
+ /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */
+ setBadge: (count: number) => void;
+ /** What the tray and the jump list show. */
+ setSummary: (summary: DesktopOsSummary) => void;
+ /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */
+ setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void;
+ /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */
+ setLocked: (locked: boolean) => void;
+ notify: (notification: DesktopOsNotification) => Promise;
+ onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void;
+ /** Writes the files to a private folder and starts a drag. Only with the mouse held. */
+ startDrag: (files: DesktopOsFile[]) => Promise;
+ /** The same, onto the clipboard as files. */
+ copyFiles: (files: DesktopOsFile[]) => Promise;
+ defaultAppStatus: () => Promise;
+ /** Offers the app to the OS and opens where the user chooses. */
+ openDefaultAppSettings: () => Promise;
+}
+
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -120,6 +236,7 @@ export interface BulwarkDesktopBridge {
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
+ os?: DesktopOsBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts
new file mode 100644
index 000000000..c9b95221a
--- /dev/null
+++ b/lib/desktop/window-files.ts
@@ -0,0 +1,66 @@
+/**
+ * Files dropped on a window or pasted from the clipboard. Whatever already handles a
+ * drop keeps doing so, which it shows by preventDefault: the composer, the file page,
+ * calendar import. The rest becomes an attachment on a new draft, or on the composer
+ * that's open.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { handOffDraft } from '@/lib/launch-handoff';
+
+type ComposerTarget = (files: File[]) => unknown;
+
+let composerTarget: ComposerTarget | null = null;
+/** Our own native drag passing over our own window isn't a new file. */
+let ownDrag = false;
+
+/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */
+export function setComposerFileTarget(target: ComposerTarget): () => void {
+ composerTarget = target;
+ return () => {
+ if (composerTarget === target) composerTarget = null;
+ };
+}
+
+export function setOwnDragActive(active: boolean): void {
+ ownDrag = active;
+}
+
+function take(files: File[]): void {
+ if (composerTarget) composerTarget(files);
+ else handOffDraft({ files });
+}
+
+function hasFiles(event: DragEvent): boolean {
+ return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
+}
+
+export function startWindowFiles(): () => void {
+ if (!getDesktopBridge()?.os) return () => {};
+ const onDragOver = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
+ };
+ const onDrop = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ const files = Array.from(event.dataTransfer?.files ?? []);
+ if (files.length > 0) take(files);
+ };
+ const onPaste = (event: ClipboardEvent) => {
+ if (event.defaultPrevented) return;
+ const files = Array.from(event.clipboardData?.files ?? []);
+ if (files.length === 0) return;
+ event.preventDefault();
+ take(files);
+ };
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('drop', onDrop);
+ window.addEventListener('paste', onPaste);
+ return () => {
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('drop', onDrop);
+ window.removeEventListener('paste', onPaste);
+ };
+}
diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts
index 39287cd2f..c911eeae8 100644
--- a/lib/pwa-notify/__tests__/page-actions.test.ts
+++ b/lib/pwa-notify/__tests__/page-actions.test.ts
@@ -13,6 +13,7 @@ const makeClient = (accountId: string) => ({
getAccountId: () => accountId,
getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })),
getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]),
+ batchMoveEmails: vi.fn(async (..._args: unknown[]) => {}),
});
let clients: Record>;
let activeAccountId = 'alice@one';
@@ -31,7 +32,9 @@ vi.mock('@/stores/auth-store', () => ({
}),
},
}));
-vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } }));
+let listed: { id: string; sourceClientAccountId?: string }[] = [];
+const deleteEmail = vi.fn(async (..._args: unknown[]) => true);
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive, deleteEmail, emails: listed }) } }));
// Each login's own subject, and a policy that reads it: the gate of the login
// the notification is for, not of the one that's active here.
vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) }));
@@ -43,6 +46,7 @@ vi.mock('@/stores/policy-store', () => ({
},
}));
vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered }));
+vi.mock('@/stores/settings-store', () => ({ useSettingsStore: { getState: () => ({ deleteAction: 'trash' }) } }));
async function run(over: Partial = {}) {
const request: NotificationActionRequest = {
@@ -65,6 +69,7 @@ beforeEach(() => {
activeAccountId = 'alice@one';
gateOff.clear();
hooksRegistered = false;
+ listed = [];
batchMarkAsRead.mockResolvedValue(true);
batchArchive.mockResolvedValue(true);
});
@@ -278,3 +283,34 @@ describe('Reply', () => {
removeCurrent();
});
});
+
+describe("Delete, from the desktop app's toast", () => {
+ it('goes through the store when the message is in the list, with its hooks and the Delete setting', async () => {
+ listed = [{ id: 'e1' }];
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']);
+ expect(deleteEmail).toHaveBeenCalledWith(clients['alice@one'], 'e1');
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it("moves it to Trash by the folder's real id when it is not in the list", async () => {
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']);
+ expect(clients['alice@one'].batchMoveEmails).toHaveBeenCalledWith(['e1'], 'bin', undefined, expect.any(Boolean));
+ expect(deleteEmail).not.toHaveBeenCalled();
+ });
+
+ it('plugins are never gone round: with a veto hook listening, the message opens instead', async () => {
+ hooksRegistered = true;
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']);
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it('a message since moved to Trash stays there', async () => {
+ clients['alice@one'].getEmail.mockResolvedValue({ id: 'e1', mailboxIds: { bin: true } } as never);
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']);
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it("the worker can't send one", () => {
+ expect(isNotificationActionRequest({ action: 'delete', emailId: 'e1', accountId: 'a', at: Date.now() })).toBe(false);
+ });
+});
diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts
index 96b1db101..1368a61be 100644
--- a/lib/pwa-notify/page-actions.ts
+++ b/lib/pwa-notify/page-actions.ts
@@ -5,10 +5,12 @@ import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { loginFingerprint } from './login-fingerprint';
import { vetoHooksRegistered } from './plugin-veto';
+import { useSettingsStore } from '@/stores/settings-store';
/** What the service worker posts when a notification's button is clicked (public/sw.js). */
export interface NotificationActionRequest {
- action: 'archive' | 'mark-read' | 'reply';
+ /** `delete` only comes from the desktop app's toasts, never from the worker. */
+ action: 'archive' | 'mark-read' | 'reply' | 'delete';
emailId: string;
accountId: string;
/** When the worker asked. */
@@ -149,6 +151,24 @@ export async function runNotificationAction(
answer('failed');
return;
}
+ if (request.action === 'delete') {
+ const store = useEmailStore.getState();
+ // In the list: the store's own delete, with its hooks and the Delete setting.
+ if (store.emails.some((row) => row.id === request.emailId && (row.sourceClientAccountId ?? entry.id) === entry.id)) {
+ answer(await store.deleteEmail(client, request.emailId) ? 'done' : 'failed');
+ return;
+ }
+ // Not in the list, so no store action can reach it, and plugins are never gone round.
+ const trash = mailboxes.find((mailbox) => mailbox.role === 'trash');
+ if (!trash || !email || vetoHooksRegistered()) {
+ answer('failed');
+ return;
+ }
+ const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
+ await client.batchMoveEmails([request.emailId], trash.originalId || trash.id, accountId, markRead);
+ answer('done');
+ return;
+ }
const done = request.action === 'mark-read'
? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId })
: await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true });
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 91db0f8ea..73529f9a4 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "أدخل عنوان الخادم أولًا.",
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
"server_invalid": "عنوان الخادم هذا غير صالح."
+ },
+ "desktopOs": {
+ "section_title": "تطبيق سطح المكتب",
+ "section_description": "طريقة عمل التطبيق مع حاسوبك.",
+ "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق",
+ "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.",
+ "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب",
+ "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.",
+ "start_minimized_label": "البدء في شريط النظام",
+ "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.",
+ "attention_label": "وميض شريط المهام عند وصول بريد جديد",
+ "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد",
+ "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.",
+ "quiet_hours_label": "ساعات الهدوء",
+ "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.",
+ "quiet_hours_from": "من",
+ "quiet_hours_to": "إلى",
+ "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح",
+ "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.",
+ "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.",
+ "default_app_label": "تطبيق البريد الافتراضي",
+ "default_app_description": "فتح روابط البريد وملفات .eml و.ics و.vcf بهذا التطبيق.",
+ "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.",
+ "default_app_button": "اختيار التطبيق الافتراضي…",
+ "new_mail": "بريد جديد",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "نسخ كملف",
+ "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
+ "copy_failed": "تعذّر نسخ الملف."
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index d2e062a69..baea89184 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Primer escriu l'adreça del servidor.",
"server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.",
"server_invalid": "Aquesta adreça de servidor no és vàlida."
+ },
+ "desktopOs": {
+ "section_title": "Aplicació d'escriptori",
+ "section_description": "Com funciona l'aplicació amb l'ordinador.",
+ "close_to_tray_label": "Continua a la safata en tancar",
+ "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.",
+ "start_at_login_label": "Inicia en entrar a l'ordinador",
+ "start_at_login_description": "L'aplicació s'inicia sola quan entreu.",
+ "start_minimized_label": "Inicia a la safata",
+ "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.",
+ "attention_label": "Fes parpellejar la barra de tasques amb correu nou",
+ "attention_mac_label": "Fes botar la icona del Dock amb correu nou",
+ "attention_description": "Només quan l'aplicació no és al davant.",
+ "quiet_hours_label": "Hores de silenci",
+ "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat",
+ "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.",
+ "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.",
+ "default_app_label": "Aplicació de correu predeterminada",
+ "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.",
+ "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.",
+ "default_app_button": "Tria l'aplicació predeterminada…",
+ "new_mail": "Correu nou",
+ "no_subject": "(sense assumpte)",
+ "action_delete": "Suprimeix",
+ "copy_as_file": "Copia com a fitxer",
+ "copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
+ "copy_failed": "No s'ha pogut copiar el fitxer."
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 6b1e41754..b6f1dfbaa 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Nejdřív zadejte adresu serveru.",
"server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.",
"server_invalid": "Tato adresa serveru není platná."
+ },
+ "desktopOs": {
+ "section_title": "Desktopová aplikace",
+ "section_description": "Jak aplikace spolupracuje s vaším počítačem.",
+ "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti",
+ "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.",
+ "start_at_login_label": "Spustit po přihlášení k počítači",
+ "start_at_login_description": "Aplikace se po přihlášení spustí sama.",
+ "start_minimized_label": "Spustit v oznamovací oblasti",
+ "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.",
+ "attention_label": "Při nové poště blikat na hlavním panelu",
+ "attention_mac_label": "Při nové poště poskakovat ikonou v Docku",
+ "attention_description": "Jen když aplikace není v popředí.",
+ "quiet_hours_label": "Doba klidu",
+ "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou",
+ "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.",
+ "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.",
+ "default_app_label": "Výchozí poštovní aplikace",
+ "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.",
+ "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.",
+ "default_app_button": "Vybrat výchozí aplikaci…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez předmětu)",
+ "action_delete": "Smazat",
+ "copy_as_file": "Kopírovat jako soubor",
+ "copied": "Soubor zkopírován. Můžete ho vložit do složky.",
+ "copy_failed": "Soubor se nepodařilo zkopírovat."
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index 61d6a1412..d9ccb53b3 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Angiv først serverens adresse.",
"server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.",
"server_invalid": "Denne serveradresse er ikke gyldig."
+ },
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Sådan arbejder appen sammen med din computer.",
+ "close_to_tray_label": "Fortsæt i systembakken ved lukning",
+ "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.",
+ "start_at_login_label": "Start, når du logger på computeren",
+ "start_at_login_description": "Appen starter selv, når du logger på.",
+ "start_minimized_label": "Start i systembakken",
+ "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.",
+ "attention_label": "Blink i proceslinjen ved ny post",
+ "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post",
+ "attention_description": "Kun når appen ikke er forrest.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en tastaturgenvej",
+ "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.",
+ "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.",
+ "default_app_label": "Standardmailapp",
+ "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.",
+ "default_app_is_default": "Denne app åbner dine maillinks.",
+ "default_app_button": "Vælg standardapp…",
+ "new_mail": "Ny post",
+ "no_subject": "(intet emne)",
+ "action_delete": "Slet",
+ "copy_as_file": "Kopiér som fil",
+ "copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres."
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index eb1ec9a29..1e6a972ed 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.",
"server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.",
"server_invalid": "Diese Serveradresse ist ungültig."
+ },
+ "desktopOs": {
+ "section_title": "Desktop-App",
+ "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.",
+ "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen",
+ "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.",
+ "start_at_login_label": "Beim Anmelden am Computer starten",
+ "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.",
+ "start_minimized_label": "Im Infobereich starten",
+ "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.",
+ "attention_label": "Taskleiste bei neuen E-Mails blinken lassen",
+ "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen",
+ "attention_description": "Nur wenn die App nicht im Vordergrund ist.",
+ "quiet_hours_label": "Ruhezeiten",
+ "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.",
+ "quiet_hours_from": "Von",
+ "quiet_hours_to": "bis",
+ "show_shortcut_label": "App mit einer Tastenkombination anzeigen",
+ "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.",
+ "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.",
+ "default_app_label": "Standard-E-Mail-App",
+ "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.",
+ "default_app_is_default": "Diese App öffnet Ihre Mail-Links.",
+ "default_app_button": "Standard-App auswählen…",
+ "new_mail": "Neue E-Mail",
+ "no_subject": "(kein Betreff)",
+ "action_delete": "Löschen",
+ "copy_as_file": "Als Datei kopieren",
+ "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
+ "copy_failed": "Die Datei konnte nicht kopiert werden."
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 0af0e143f..450132227 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Enter your server's address first.",
"server_declined": "You didn't allow this server, so nothing was sent to it.",
"server_invalid": "This server address isn't valid."
+ },
+ "desktopOs": {
+ "section_title": "Desktop app",
+ "section_description": "How the app works with your computer.",
+ "close_to_tray_label": "Keep running in the tray when closed",
+ "close_to_tray_description": "New mail and reminders keep coming after you close the window.",
+ "start_at_login_label": "Start when you sign in to your computer",
+ "start_at_login_description": "The app starts by itself when you sign in.",
+ "start_minimized_label": "Start in the tray",
+ "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.",
+ "attention_label": "Flash the taskbar on new mail",
+ "attention_mac_label": "Bounce the Dock icon on new mail",
+ "attention_description": "Only when the app is not in front.",
+ "quiet_hours_label": "Quiet hours",
+ "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.",
+ "quiet_hours_from": "From",
+ "quiet_hours_to": "to",
+ "show_shortcut_label": "Show the app with a keyboard shortcut",
+ "show_shortcut_description": "{keys} brings the app to the front, from any other app.",
+ "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.",
+ "default_app_label": "Default mail app",
+ "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.",
+ "default_app_is_default": "This app opens your mail links.",
+ "default_app_button": "Choose default app…",
+ "new_mail": "New mail",
+ "no_subject": "(no subject)",
+ "action_delete": "Delete",
+ "copy_as_file": "Copy as file",
+ "copied": "File copied. You can paste it into a folder.",
+ "copy_failed": "The file could not be copied."
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 83aeead77..e208908e9 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Primero escribe la dirección del servidor.",
"server_declined": "No permitiste este servidor, así que no se le envió nada.",
"server_invalid": "Esta dirección de servidor no es válida."
+ },
+ "desktopOs": {
+ "section_title": "Aplicación de escritorio",
+ "section_description": "Cómo funciona la aplicación con tu ordenador.",
+ "close_to_tray_label": "Seguir en la bandeja al cerrar",
+ "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.",
+ "start_at_login_label": "Iniciar al entrar en el ordenador",
+ "start_at_login_description": "La aplicación se inicia sola cuando entras.",
+ "start_minimized_label": "Iniciar en la bandeja",
+ "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.",
+ "attention_label": "Hacer parpadear la barra de tareas con correo nuevo",
+ "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo",
+ "attention_description": "Solo cuando la aplicación no está delante.",
+ "quiet_hours_label": "Horas de silencio",
+ "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado",
+ "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.",
+ "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.",
+ "default_app_label": "Aplicación de correo predeterminada",
+ "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.",
+ "default_app_is_default": "Esta aplicación abre tus enlaces de correo.",
+ "default_app_button": "Elegir aplicación predeterminada…",
+ "new_mail": "Correo nuevo",
+ "no_subject": "(sin asunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como archivo",
+ "copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
+ "copy_failed": "No se pudo copiar el archivo."
}
}
diff --git a/locales/et/common.json b/locales/et/common.json
index 788fb5171..709b73243 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Sisesta kõigepealt serveri aadress.",
"server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.",
"server_invalid": "See serveri aadress ei kehti."
+ },
+ "desktopOs": {
+ "section_title": "Töölauarakendus",
+ "section_description": "Kuidas rakendus sinu arvutiga koos töötab.",
+ "close_to_tray_label": "Sulgemisel jätka salves",
+ "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.",
+ "start_at_login_label": "Käivita arvutisse sisselogimisel",
+ "start_at_login_description": "Rakendus käivitub sisselogimisel ise.",
+ "start_minimized_label": "Käivita salves",
+ "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.",
+ "attention_label": "Vilguta tegumiriba uue kirja korral",
+ "attention_mac_label": "Põrgata Docki ikooni uue kirja korral",
+ "attention_description": "Ainult siis, kui rakendus pole esiplaanil.",
+ "quiet_hours_label": "Vaikne aeg",
+ "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.",
+ "quiet_hours_from": "Alates",
+ "quiet_hours_to": "kuni",
+ "show_shortcut_label": "Näita rakendust kiirklahviga",
+ "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.",
+ "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.",
+ "default_app_label": "Vaikimisi e-posti rakendus",
+ "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.",
+ "default_app_is_default": "See rakendus avab sinu e-posti lingid.",
+ "default_app_button": "Vali vaikerakendus…",
+ "new_mail": "Uus kiri",
+ "no_subject": "(teema puudub)",
+ "action_delete": "Kustuta",
+ "copy_as_file": "Kopeeri failina",
+ "copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
+ "copy_failed": "Faili ei saanud kopeerida."
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9eadf3199..7c5d654d8 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
"server_invalid": "این نشانی سرور معتبر نیست."
+ },
+ "desktopOs": {
+ "section_title": "برنامه دسکتاپ",
+ "section_description": "نحوه کار برنامه با رایانه شما.",
+ "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند",
+ "close_to_tray_description": "نامههای جدید و یادآوریها پس از بستن پنجره هم میرسند.",
+ "start_at_login_label": "هنگام ورود به رایانه شروع شود",
+ "start_at_login_description": "برنامه هنگام ورود خودش شروع میشود.",
+ "start_minimized_label": "شروع در سینی سیستم",
+ "start_minimized_description": "هنگام ورود پنجرهای باز نمیشود. با نماد سینی سیستم آن را باز کنید.",
+ "attention_label": "چشمک نوار وظیفه هنگام نامه جدید",
+ "attention_mac_label": "جهش نماد Dock هنگام نامه جدید",
+ "attention_description": "فقط وقتی برنامه در جلو نیست.",
+ "quiet_hours_label": "ساعات سکوت",
+ "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامهها همچنان میرسند.",
+ "quiet_hours_from": "از",
+ "quiet_hours_to": "تا",
+ "show_shortcut_label": "نمایش برنامه با میانبر صفحهکلید",
+ "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو میآورد.",
+ "show_shortcut_taken": "برنامه دیگری از {keys} استفاده میکند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.",
+ "default_app_label": "برنامه پیشفرض ایمیل",
+ "default_app_description": "باز کردن پیوندهای ایمیل و پروندههای .eml، .ics و .vcf با این برنامه.",
+ "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز میکند.",
+ "default_app_button": "انتخاب برنامه پیشفرض…",
+ "new_mail": "نامه جدید",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "رونوشت بهصورت پرونده",
+ "copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.",
+ "copy_failed": "رونوشت پرونده ممکن نشد."
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index a44af9dbe..64eb990a1 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Saisissez d'abord l'adresse du serveur.",
"server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.",
"server_invalid": "Cette adresse de serveur n'est pas valide."
+ },
+ "desktopOs": {
+ "section_title": "Application de bureau",
+ "section_description": "Comment l'application fonctionne avec votre ordinateur.",
+ "close_to_tray_label": "Rester dans la zone de notification à la fermeture",
+ "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.",
+ "start_at_login_label": "Démarrer à l'ouverture de session",
+ "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.",
+ "start_minimized_label": "Démarrer dans la zone de notification",
+ "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.",
+ "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier",
+ "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier",
+ "attention_description": "Seulement quand l'application n'est pas au premier plan.",
+ "quiet_hours_label": "Heures calmes",
+ "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "à",
+ "show_shortcut_label": "Afficher l'application avec un raccourci clavier",
+ "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.",
+ "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.",
+ "default_app_label": "Application de messagerie par défaut",
+ "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.",
+ "default_app_is_default": "Cette application ouvre vos liens de messagerie.",
+ "default_app_button": "Choisir l'application par défaut…",
+ "new_mail": "Nouveau courrier",
+ "no_subject": "(sans objet)",
+ "action_delete": "Supprimer",
+ "copy_as_file": "Copier en tant que fichier",
+ "copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
+ "copy_failed": "Le fichier n'a pas pu être copié."
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index 17580b9a8..18bb2fd87 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6510,5 +6510,35 @@
"server_needed": "קודם צריך להזין את כתובת השרת.",
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
"server_invalid": "כתובת השרת הזו לא תקינה."
+ },
+ "desktopOs": {
+ "section_title": "יישום שולחן עבודה",
+ "section_description": "איך היישום עובד עם המחשב שלך.",
+ "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה",
+ "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.",
+ "start_at_login_label": "להפעיל בכניסה למחשב",
+ "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.",
+ "start_minimized_label": "להפעיל במגש המערכת",
+ "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.",
+ "attention_label": "הבהוב בשורת המשימות בדואר חדש",
+ "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש",
+ "attention_description": "רק כשהיישום לא בחזית.",
+ "quiet_hours_label": "שעות שקט",
+ "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.",
+ "quiet_hours_from": "מ-",
+ "quiet_hours_to": "עד",
+ "show_shortcut_label": "הצגת היישום בקיצור מקשים",
+ "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.",
+ "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.",
+ "default_app_label": "יישום הדואר המוגדר כברירת מחדל",
+ "default_app_description": "פתיחת קישורי דואר וקובצי .eml, .ics ו-.vcf ביישום זה.",
+ "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.",
+ "default_app_button": "בחירת יישום ברירת מחדל…",
+ "new_mail": "דואר חדש",
+ "no_subject": "(ללא נושא)",
+ "action_delete": "מחיקה",
+ "copy_as_file": "העתקה כקובץ",
+ "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
+ "copy_failed": "לא ניתן להעתיק את הקובץ."
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 474772591..3a8863848 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Először adja meg a szerver címét.",
"server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.",
"server_invalid": "Ez a szervercím nem érvényes."
+ },
+ "desktopOs": {
+ "section_title": "Asztali alkalmazás",
+ "section_description": "Hogyan működik az alkalmazás a számítógépével.",
+ "close_to_tray_label": "Bezáráskor fusson tovább a tálcán",
+ "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.",
+ "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor",
+ "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.",
+ "start_minimized_label": "Indítás a tálcán",
+ "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.",
+ "attention_label": "Tálca villogtatása új levélnél",
+ "attention_mac_label": "Dock-ikon ugráltatása új levélnél",
+ "attention_description": "Csak ha az alkalmazás nincs előtérben.",
+ "quiet_hours_label": "Csendes órák",
+ "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.",
+ "quiet_hours_from": "Ettől",
+ "quiet_hours_to": "eddig",
+ "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal",
+ "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.",
+ "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.",
+ "default_app_label": "Alapértelmezett levelezőalkalmazás",
+ "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.",
+ "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.",
+ "default_app_button": "Alapértelmezett alkalmazás kiválasztása…",
+ "new_mail": "Új levél",
+ "no_subject": "(nincs tárgy)",
+ "action_delete": "Törlés",
+ "copy_as_file": "Másolás fájlként",
+ "copied": "Fájl másolva. Beillesztheti egy mappába.",
+ "copy_failed": "A fájlt nem sikerült másolni."
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index 8cc39c364..b3e7964cd 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Inserisci prima l'indirizzo del server.",
"server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.",
"server_invalid": "Questo indirizzo del server non è valido."
+ },
+ "desktopOs": {
+ "section_title": "App desktop",
+ "section_description": "Come l'app funziona con il tuo computer.",
+ "close_to_tray_label": "Resta nell'area di notifica alla chiusura",
+ "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.",
+ "start_at_login_label": "Avvia all'accesso al computer",
+ "start_at_login_description": "L'app si avvia da sola quando accedi.",
+ "start_minimized_label": "Avvia nell'area di notifica",
+ "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.",
+ "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta",
+ "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta",
+ "attention_description": "Solo quando l'app non è in primo piano.",
+ "quiet_hours_label": "Ore di silenzio",
+ "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.",
+ "quiet_hours_from": "Da",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera",
+ "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.",
+ "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.",
+ "default_app_label": "App di posta predefinita",
+ "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.",
+ "default_app_is_default": "Questa app apre i tuoi link di posta.",
+ "default_app_button": "Scegli app predefinita…",
+ "new_mail": "Nuova posta",
+ "no_subject": "(nessun oggetto)",
+ "action_delete": "Elimina",
+ "copy_as_file": "Copia come file",
+ "copied": "File copiato. Puoi incollarlo in una cartella.",
+ "copy_failed": "Impossibile copiare il file."
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index e051ceb66..e776616c2 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "先にサーバーのアドレスを入力してください。",
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
"server_invalid": "このサーバーアドレスは正しくありません。"
+ },
+ "desktopOs": {
+ "section_title": "デスクトップアプリ",
+ "section_description": "アプリとパソコンの連携方法です。",
+ "close_to_tray_label": "閉じてもトレイで実行を続ける",
+ "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。",
+ "start_at_login_label": "パソコンへのサインイン時に起動",
+ "start_at_login_description": "サインインするとアプリが自動で起動します。",
+ "start_minimized_label": "トレイで起動",
+ "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。",
+ "attention_label": "新着メールでタスクバーを点滅",
+ "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる",
+ "attention_description": "アプリが前面にないときのみ。",
+ "quiet_hours_label": "おやすみ時間",
+ "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。",
+ "quiet_hours_from": "開始",
+ "quiet_hours_to": "終了",
+ "show_shortcut_label": "キーボードショートカットでアプリを表示",
+ "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。",
+ "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。",
+ "default_app_label": "既定のメールアプリ",
+ "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。",
+ "default_app_is_default": "メールのリンクはこのアプリで開きます。",
+ "default_app_button": "既定のアプリを選択…",
+ "new_mail": "新着メール",
+ "no_subject": "(件名なし)",
+ "action_delete": "削除",
+ "copy_as_file": "ファイルとしてコピー",
+ "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
+ "copy_failed": "ファイルをコピーできませんでした。"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index e7f7be907..d589f30e9 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "먼저 서버 주소를 입력하세요.",
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
"server_invalid": "이 서버 주소는 올바르지 않습니다."
+ },
+ "desktopOs": {
+ "section_title": "데스크톱 앱",
+ "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.",
+ "close_to_tray_label": "닫을 때 트레이에서 계속 실행",
+ "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.",
+ "start_at_login_label": "컴퓨터에 로그인할 때 시작",
+ "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.",
+ "start_minimized_label": "트레이에서 시작",
+ "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.",
+ "attention_label": "새 메일이 오면 작업 표시줄 깜박이기",
+ "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기",
+ "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.",
+ "quiet_hours_label": "방해 금지 시간",
+ "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.",
+ "quiet_hours_from": "시작",
+ "quiet_hours_to": "종료",
+ "show_shortcut_label": "키보드 단축키로 앱 표시",
+ "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.",
+ "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.",
+ "default_app_label": "기본 메일 앱",
+ "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.",
+ "default_app_is_default": "이 앱이 메일 링크를 엽니다.",
+ "default_app_button": "기본 앱 선택…",
+ "new_mail": "새 메일",
+ "no_subject": "(제목 없음)",
+ "action_delete": "삭제",
+ "copy_as_file": "파일로 복사",
+ "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
+ "copy_failed": "파일을 복사할 수 없습니다."
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index c07fe9c31..22c93c057 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Vispirms ievadiet servera adresi.",
"server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.",
"server_invalid": "Šī servera adrese nav derīga."
+ },
+ "desktopOs": {
+ "section_title": "Darbvirsmas lietotne",
+ "section_description": "Kā lietotne darbojas ar jūsu datoru.",
+ "close_to_tray_label": "Aizverot turpināt darbu teknē",
+ "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.",
+ "start_at_login_label": "Sākt, piesakoties datorā",
+ "start_at_login_description": "Lietotne sākas pati, kad piesakāties.",
+ "start_minimized_label": "Sākt teknē",
+ "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.",
+ "attention_label": "Mirgot uzdevumjoslā, pienākot pastam",
+ "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam",
+ "attention_description": "Tikai tad, ja lietotne nav priekšplānā.",
+ "quiet_hours_label": "Klusuma stundas",
+ "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.",
+ "quiet_hours_from": "No",
+ "quiet_hours_to": "līdz",
+ "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu",
+ "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.",
+ "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.",
+ "default_app_label": "Noklusējuma pasta lietotne",
+ "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.",
+ "default_app_is_default": "Šī lietotne atver jūsu pasta saites.",
+ "default_app_button": "Izvēlēties noklusējuma lietotni…",
+ "new_mail": "Jauns pasts",
+ "no_subject": "(nav tēmas)",
+ "action_delete": "Dzēst",
+ "copy_as_file": "Kopēt kā failu",
+ "copied": "Fails nokopēts. Varat to ielīmēt mapē.",
+ "copy_failed": "Failu neizdevās nokopēt."
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 4633a5d6d..3d8bb9a5d 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
"server_invalid": "Энэ серверийн хаяг буруу байна."
+ },
+ "desktopOs": {
+ "section_title": "Ширээний апп",
+ "section_description": "Апп таны компьютертэй хэрхэн ажиллах.",
+ "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах",
+ "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.",
+ "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх",
+ "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.",
+ "start_minimized_label": "Тавиур дээр эхлүүлэх",
+ "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.",
+ "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах",
+ "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх",
+ "attention_description": "Зөвхөн апп урд талд байхгүй үед.",
+ "quiet_hours_label": "Чимээгүй цаг",
+ "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.",
+ "quiet_hours_from": "Эхлэх",
+ "quiet_hours_to": "хүртэл",
+ "show_shortcut_label": "Гарын товчлолоор аппыг харуулах",
+ "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.",
+ "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.",
+ "default_app_label": "Үндсэн шуудангийн апп",
+ "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.",
+ "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.",
+ "default_app_button": "Үндсэн апп сонгох…",
+ "new_mail": "Шинэ захиа",
+ "no_subject": "(гарчиггүй)",
+ "action_delete": "Устгах",
+ "copy_as_file": "Файл болгож хуулах",
+ "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
+ "copy_failed": "Файлыг хуулж чадсангүй."
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index d2e1a518e..d4d7099ad 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Skriv inn serveradressen først.",
"server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.",
"server_invalid": "Denne serveradressen er ikke gyldig."
+ },
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Slik samarbeider appen med datamaskinen din.",
+ "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking",
+ "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.",
+ "start_at_login_label": "Start når du logger på datamaskinen",
+ "start_at_login_description": "Appen starter av seg selv når du logger på.",
+ "start_minimized_label": "Start i systemstatusfeltet",
+ "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.",
+ "attention_label": "Blink i oppgavelinjen ved ny e-post",
+ "attention_mac_label": "La Dock-ikonet sprette ved ny e-post",
+ "attention_description": "Bare når appen ikke er foran.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en hurtigtast",
+ "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.",
+ "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.",
+ "default_app_label": "Standard e-postapp",
+ "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.",
+ "default_app_is_default": "Denne appen åpner e-postlenkene dine.",
+ "default_app_button": "Velg standardapp…",
+ "new_mail": "Ny e-post",
+ "no_subject": "(uten emne)",
+ "action_delete": "Slett",
+ "copy_as_file": "Kopier som fil",
+ "copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres."
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index b704a66fc..12301289c 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Vul eerst het adres van de server in.",
"server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.",
"server_invalid": "Dit serveradres is niet geldig."
+ },
+ "desktopOs": {
+ "section_title": "Desktop-app",
+ "section_description": "Hoe de app samenwerkt met je computer.",
+ "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak",
+ "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.",
+ "start_at_login_label": "Starten als je je aanmeldt op de computer",
+ "start_at_login_description": "De app start vanzelf als je je aanmeldt.",
+ "start_minimized_label": "Starten in het systeemvak",
+ "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.",
+ "attention_label": "Taakbalk laten knipperen bij nieuwe mail",
+ "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail",
+ "attention_description": "Alleen als de app niet vooraan staat.",
+ "quiet_hours_label": "Stille uren",
+ "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.",
+ "quiet_hours_from": "Van",
+ "quiet_hours_to": "tot",
+ "show_shortcut_label": "App tonen met een sneltoets",
+ "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.",
+ "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.",
+ "default_app_label": "Standaard-mailapp",
+ "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.",
+ "default_app_is_default": "Deze app opent je maillinks.",
+ "default_app_button": "Standaard-app kiezen…",
+ "new_mail": "Nieuwe mail",
+ "no_subject": "(geen onderwerp)",
+ "action_delete": "Verwijderen",
+ "copy_as_file": "Kopiëren als bestand",
+ "copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
+ "copy_failed": "Het bestand kon niet worden gekopieerd."
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 8ccc30784..6b31fdcf1 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Najpierw wpisz adres serwera.",
"server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.",
"server_invalid": "Ten adres serwera jest nieprawidłowy."
+ },
+ "desktopOs": {
+ "section_title": "Aplikacja na komputer",
+ "section_description": "Jak aplikacja współpracuje z Twoim komputerem.",
+ "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku",
+ "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.",
+ "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera",
+ "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.",
+ "start_minimized_label": "Uruchamiaj w zasobniku",
+ "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.",
+ "attention_label": "Migaj na pasku zadań przy nowej poczcie",
+ "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie",
+ "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.",
+ "quiet_hours_label": "Godziny ciszy",
+ "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym",
+ "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.",
+ "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.",
+ "default_app_label": "Domyślna aplikacja poczty",
+ "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.",
+ "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.",
+ "default_app_button": "Wybierz domyślną aplikację…",
+ "new_mail": "Nowa poczta",
+ "no_subject": "(bez tematu)",
+ "action_delete": "Usuń",
+ "copy_as_file": "Kopiuj jako plik",
+ "copied": "Plik skopiowany. Możesz wkleić go do folderu.",
+ "copy_failed": "Nie udało się skopiować pliku."
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 188b256f8..ed181e935 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Digite primeiro o endereço do servidor.",
"server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.",
"server_invalid": "Este endereço de servidor não é válido."
+ },
+ "desktopOs": {
+ "section_title": "Aplicação de ambiente de trabalho",
+ "section_description": "Como a aplicação funciona com o seu computador.",
+ "close_to_tray_label": "Continuar na bandeja ao fechar",
+ "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.",
+ "start_at_login_label": "Iniciar ao entrar no computador",
+ "start_at_login_description": "A aplicação inicia sozinha quando entra.",
+ "start_minimized_label": "Iniciar na bandeja",
+ "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.",
+ "attention_label": "Piscar a barra de tarefas com correio novo",
+ "attention_mac_label": "Saltar o ícone da Dock com correio novo",
+ "attention_description": "Só quando a aplicação não está à frente.",
+ "quiet_hours_label": "Horas de silêncio",
+ "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "até",
+ "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado",
+ "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.",
+ "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.",
+ "default_app_label": "Aplicação de correio predefinida",
+ "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.",
+ "default_app_is_default": "Esta aplicação abre as suas ligações de correio.",
+ "default_app_button": "Escolher aplicação predefinida…",
+ "new_mail": "Correio novo",
+ "no_subject": "(sem assunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como ficheiro",
+ "copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
+ "copy_failed": "Não foi possível copiar o ficheiro."
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index ae31839c2..fb2ca2d1f 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Introdu mai întâi adresa serverului.",
"server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.",
"server_invalid": "Această adresă de server nu este validă."
+ },
+ "desktopOs": {
+ "section_title": "Aplicație desktop",
+ "section_description": "Cum funcționează aplicația cu computerul tău.",
+ "close_to_tray_label": "Rămâne în zona de notificări la închidere",
+ "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.",
+ "start_at_login_label": "Pornește la conectarea în computer",
+ "start_at_login_description": "Aplicația pornește singură când te conectezi.",
+ "start_minimized_label": "Pornește în zona de notificări",
+ "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.",
+ "attention_label": "Clipește în bara de activități la e-mail nou",
+ "attention_mac_label": "Sare pictograma din Dock la e-mail nou",
+ "attention_description": "Doar când aplicația nu este în față.",
+ "quiet_hours_label": "Ore de liniște",
+ "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.",
+ "quiet_hours_from": "De la",
+ "quiet_hours_to": "până la",
+ "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură",
+ "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.",
+ "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.",
+ "default_app_label": "Aplicația de e-mail implicită",
+ "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.",
+ "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.",
+ "default_app_button": "Alege aplicația implicită…",
+ "new_mail": "E-mail nou",
+ "no_subject": "(fără subiect)",
+ "action_delete": "Șterge",
+ "copy_as_file": "Copiază ca fișier",
+ "copied": "Fișier copiat. Îl poți lipi într-un dosar.",
+ "copy_failed": "Fișierul nu a putut fi copiat."
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index a9ee1c65c..1661ae3ca 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Сначала введите адрес сервера.",
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
"server_invalid": "Этот адрес сервера недействителен."
+ },
+ "desktopOs": {
+ "section_title": "Настольное приложение",
+ "section_description": "Как приложение работает с вашим компьютером.",
+ "close_to_tray_label": "При закрытии продолжать работу в трее",
+ "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.",
+ "start_at_login_label": "Запускать при входе в систему",
+ "start_at_login_description": "Приложение запускается само, когда вы входите.",
+ "start_minimized_label": "Запускать в трее",
+ "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.",
+ "attention_label": "Мигать на панели задач при новой почте",
+ "attention_mac_label": "Подпрыгивать значком в Dock при новой почте",
+ "attention_description": "Только когда приложение не на переднем плане.",
+ "quiet_hours_label": "Тихие часы",
+ "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.",
+ "quiet_hours_from": "С",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показывать приложение сочетанием клавиш",
+ "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.",
+ "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.",
+ "default_app_label": "Почтовое приложение по умолчанию",
+ "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.",
+ "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.",
+ "default_app_button": "Выбрать приложение по умолчанию…",
+ "new_mail": "Новая почта",
+ "no_subject": "(без темы)",
+ "action_delete": "Удалить",
+ "copy_as_file": "Копировать как файл",
+ "copied": "Файл скопирован. Его можно вставить в папку.",
+ "copy_failed": "Не удалось скопировать файл."
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index a018ba55a..76252a888 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Najprv zadajte adresu servera.",
"server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.",
"server_invalid": "Táto adresa servera nie je platná."
+ },
+ "desktopOs": {
+ "section_title": "Počítačová aplikácia",
+ "section_description": "Ako aplikácia spolupracuje s vaším počítačom.",
+ "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení",
+ "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.",
+ "start_at_login_label": "Spustiť po prihlásení do počítača",
+ "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.",
+ "start_minimized_label": "Spustiť v oblasti oznámení",
+ "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.",
+ "attention_label": "Pri novej pošte blikať na paneli úloh",
+ "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku",
+ "attention_description": "Len keď aplikácia nie je v popredí.",
+ "quiet_hours_label": "Čas pokoja",
+ "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou",
+ "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.",
+ "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.",
+ "default_app_label": "Predvolená poštová aplikácia",
+ "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.",
+ "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.",
+ "default_app_button": "Vybrať predvolenú aplikáciu…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez predmetu)",
+ "action_delete": "Odstrániť",
+ "copy_as_file": "Kopírovať ako súbor",
+ "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
+ "copy_failed": "Súbor sa nepodarilo skopírovať."
}
}
diff --git a/locales/th/common.json b/locales/th/common.json
index 065a7afbc..89c513d65 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6517,5 +6517,35 @@
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
+ },
+ "desktopOs": {
+ "section_title": "แอปเดสก์ท็อป",
+ "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ",
+ "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด",
+ "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง",
+ "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์",
+ "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้",
+ "start_minimized_label": "เริ่มในถาดระบบ",
+ "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ",
+ "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่",
+ "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่",
+ "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า",
+ "quiet_hours_label": "ช่วงเวลาเงียบ",
+ "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ",
+ "quiet_hours_from": "จาก",
+ "quiet_hours_to": "ถึง",
+ "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด",
+ "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้",
+ "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง",
+ "default_app_label": "แอปเมลเริ่มต้น",
+ "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้",
+ "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ",
+ "default_app_button": "เลือกแอปเริ่มต้น…",
+ "new_mail": "เมลใหม่",
+ "no_subject": "(ไม่มีหัวเรื่อง)",
+ "action_delete": "ลบ",
+ "copy_as_file": "คัดลอกเป็นไฟล์",
+ "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
+ "copy_failed": "คัดลอกไฟล์ไม่ได้"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 81acedbf7..be92c4d19 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Önce sunucu adresini girin.",
"server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.",
"server_invalid": "Bu sunucu adresi geçerli değil."
+ },
+ "desktopOs": {
+ "section_title": "Masaüstü uygulaması",
+ "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.",
+ "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et",
+ "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.",
+ "start_at_login_label": "Bilgisayarda oturum açınca başlat",
+ "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.",
+ "start_minimized_label": "Sistem tepsisinde başlat",
+ "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.",
+ "attention_label": "Yeni postada görev çubuğunu yanıp söndür",
+ "attention_mac_label": "Yeni postada Dock simgesini zıplat",
+ "attention_description": "Yalnızca uygulama önde değilken.",
+ "quiet_hours_label": "Sessiz saatler",
+ "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.",
+ "quiet_hours_from": "Başlangıç",
+ "quiet_hours_to": "bitiş",
+ "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster",
+ "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.",
+ "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.",
+ "default_app_label": "Varsayılan posta uygulaması",
+ "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.",
+ "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.",
+ "default_app_button": "Varsayılan uygulamayı seç…",
+ "new_mail": "Yeni posta",
+ "no_subject": "(konu yok)",
+ "action_delete": "Sil",
+ "copy_as_file": "Dosya olarak kopyala",
+ "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
+ "copy_failed": "Dosya kopyalanamadı."
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 42dbd1aba..f77d1099c 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "Спочатку введіть адресу сервера.",
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
"server_invalid": "Ця адреса сервера недійсна."
+ },
+ "desktopOs": {
+ "section_title": "Настільний застосунок",
+ "section_description": "Як застосунок працює з вашим комп’ютером.",
+ "close_to_tray_label": "Під час закриття працювати далі в треї",
+ "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.",
+ "start_at_login_label": "Запускати під час входу в систему",
+ "start_at_login_description": "Застосунок запускається сам, коли ви входите.",
+ "start_minimized_label": "Запускати в треї",
+ "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.",
+ "attention_label": "Блимати на панелі завдань за нової пошти",
+ "attention_mac_label": "Підстрибувати значком у Dock за нової пошти",
+ "attention_description": "Лише коли застосунок не на передньому плані.",
+ "quiet_hours_label": "Тихі години",
+ "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.",
+ "quiet_hours_from": "З",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показувати застосунок сполученням клавіш",
+ "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.",
+ "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.",
+ "default_app_label": "Поштовий застосунок за замовчуванням",
+ "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.",
+ "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.",
+ "default_app_button": "Вибрати застосунок за замовчуванням…",
+ "new_mail": "Нова пошта",
+ "no_subject": "(без теми)",
+ "action_delete": "Видалити",
+ "copy_as_file": "Копіювати як файл",
+ "copied": "Файл скопійовано. Його можна вставити в папку.",
+ "copy_failed": "Не вдалося скопіювати файл."
}
}
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 9cbcd8bd3..4692a979d 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6521,5 +6521,35 @@
"server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.",
"server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.",
"server_invalid": "Địa chỉ máy chủ này không hợp lệ."
+ },
+ "desktopOs": {
+ "section_title": "Ứng dụng máy tính",
+ "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.",
+ "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng",
+ "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.",
+ "start_at_login_label": "Khởi động khi đăng nhập vào máy tính",
+ "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.",
+ "start_minimized_label": "Khởi động ở khay hệ thống",
+ "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.",
+ "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới",
+ "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới",
+ "attention_description": "Chỉ khi ứng dụng không ở phía trước.",
+ "quiet_hours_label": "Giờ yên tĩnh",
+ "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.",
+ "quiet_hours_from": "Từ",
+ "quiet_hours_to": "đến",
+ "show_shortcut_label": "Hiện ứng dụng bằng phím tắt",
+ "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.",
+ "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.",
+ "default_app_label": "Ứng dụng thư mặc định",
+ "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.",
+ "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.",
+ "default_app_button": "Chọn ứng dụng mặc định…",
+ "new_mail": "Thư mới",
+ "no_subject": "(không có chủ đề)",
+ "action_delete": "Xóa",
+ "copy_as_file": "Sao chép dưới dạng tệp",
+ "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
+ "copy_failed": "Không thể sao chép tệp."
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 5ba44316f..2e49af076 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "請先輸入伺服器位址。",
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
"server_invalid": "此伺服器位址無效。"
+ },
+ "desktopOs": {
+ "section_title": "桌面應用程式",
+ "section_description": "應用程式如何與您的電腦配合。",
+ "close_to_tray_label": "關閉時繼續在系統匣中執行",
+ "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。",
+ "start_at_login_label": "登入電腦時啟動",
+ "start_at_login_description": "您登入時應用程式會自動啟動。",
+ "start_minimized_label": "在系統匣中啟動",
+ "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。",
+ "attention_label": "有新郵件時閃爍工作列",
+ "attention_mac_label": "有新郵件時讓 Dock 圖示跳動",
+ "attention_description": "僅在應用程式不在前景時。",
+ "quiet_hours_label": "勿擾時段",
+ "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。",
+ "quiet_hours_from": "從",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式",
+ "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。",
+ "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。",
+ "default_app_label": "預設郵件應用程式",
+ "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。",
+ "default_app_is_default": "此應用程式會開啟您的郵件連結。",
+ "default_app_button": "選擇預設應用程式…",
+ "new_mail": "新郵件",
+ "no_subject": "(無主旨)",
+ "action_delete": "刪除",
+ "copy_as_file": "複製為檔案",
+ "copied": "檔案已複製。您可以將其貼到資料夾中。",
+ "copy_failed": "無法複製檔案。"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index f53c82da4..746815777 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6509,5 +6509,35 @@
"server_needed": "请先输入服务器地址。",
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
"server_invalid": "此服务器地址无效。"
+ },
+ "desktopOs": {
+ "section_title": "桌面应用",
+ "section_description": "应用如何与你的电脑配合。",
+ "close_to_tray_label": "关闭时继续在托盘中运行",
+ "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。",
+ "start_at_login_label": "登录电脑时启动",
+ "start_at_login_description": "你登录时应用会自动启动。",
+ "start_minimized_label": "在托盘中启动",
+ "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。",
+ "attention_label": "有新邮件时闪烁任务栏",
+ "attention_mac_label": "有新邮件时让 Dock 图标跳动",
+ "attention_description": "仅在应用不在前台时。",
+ "quiet_hours_label": "免打扰时段",
+ "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。",
+ "quiet_hours_from": "从",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用键盘快捷键显示应用",
+ "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。",
+ "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。",
+ "default_app_label": "默认邮件应用",
+ "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。",
+ "default_app_is_default": "此应用会打开你的邮件链接。",
+ "default_app_button": "选择默认应用…",
+ "new_mail": "新邮件",
+ "no_subject": "(无主题)",
+ "action_delete": "删除",
+ "copy_as_file": "复制为文件",
+ "copied": "文件已复制。你可以将其粘贴到文件夹中。",
+ "copy_failed": "无法复制文件。"
}
}
From b805571dec2cdb5cb633e0e9c81becc84826b99f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:30:37 +0100
Subject: [PATCH 02/24] fix: drag attachments and messages out of the desktop
app with their bytes in hand
---
components/email/email-viewer.tsx | 9 ++++
.../__tests__/use-email-drag-routing.test.tsx | 5 +--
hooks/use-attachment-drag.ts | 43 +++++++++++++------
hooks/use-email-drag.ts | 26 +++++++----
lib/desktop/__tests__/os-window.test.tsx | 30 ++++++++++---
lib/desktop/file-drag.ts | 26 ++++++++---
6 files changed, 101 insertions(+), 38 deletions(-)
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 09c925b5e..9dbde37b4 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -685,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
+ getBlob: async () => {
+ if (attachment.blobId && client) {
+ return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
+ }
+ const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null);
+ if (!bytes || bytes.byteLength === 0) return null;
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+ return new Blob([buffer], { type: inertBlobType(attachment.type) });
+ },
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx
index 242f40e5f..e3757d946 100644
--- a/hooks/__tests__/use-email-drag-routing.test.tsx
+++ b/hooks/__tests__/use-email-drag-routing.test.tsx
@@ -734,11 +734,10 @@ describe('message row drag hover wiring', () => {
it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
const email = mail({ subject: 'Hello world' });
- const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
- await warm(result);
const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
- vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!))));
+ const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
+ await warm(result);
try {
startDrag.mockClear();
// Plain: still a move to a folder, still DownloadURL for Explorer.
diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts
index ce0047251..6136c4453 100644
--- a/hooks/use-attachment-drag.ts
+++ b/hooks/use-attachment-drag.ts
@@ -34,6 +34,8 @@ export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise;
+ /** The bytes themselves, for the desktop app's native drag. */
+ getBlob?: () => Promise;
}
export interface UseAttachmentDragResult {
@@ -59,6 +61,8 @@ export function useAttachmentDrag(
const urlRef = useRef(null);
const ownedRef = useRef(false);
const inFlightRef = useRef | null>(null);
+ const blobRef = useRef(null);
+ const nativeDrag = canDragNativeFiles() && !!source.getBlob;
useEffect(() => {
return () => {
@@ -73,6 +77,19 @@ export function useAttachmentDrag(
const prefetch = useCallback(() => {
if (!enabled) return;
+ if (nativeDrag) {
+ if (blobRef.current || inFlightRef.current) return;
+ inFlightRef.current = source.getBlob!()
+ .then((blob) => {
+ blobRef.current = blob;
+ return null;
+ })
+ .catch(() => null)
+ .finally(() => {
+ inFlightRef.current = null;
+ });
+ return;
+ }
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
@@ -90,7 +107,7 @@ export function useAttachmentDrag(
.finally(() => {
inFlightRef.current = null;
});
- }, [enabled, source]);
+ }, [enabled, source, nativeDrag]);
const handleDragStart = useCallback(
(e: DragEvent) => {
@@ -98,6 +115,15 @@ export function useAttachmentDrag(
const name = source.name || "download";
const type = source.type || "application/octet-stream";
+ // In the desktop app the OS's own drag, with the real file on disk first. The bytes
+ // aren't here yet: fetch them for the next try, as below.
+ if (nativeDrag) {
+ e.preventDefault();
+ if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]);
+ else prefetch();
+ return;
+ }
+
if (!url) {
// Blob isn't materialized yet. Kick off the fetch so the next attempt
// works, but cancel this drag so the user doesn't get a silent failure
@@ -107,13 +133,6 @@ export function useAttachmentDrag(
return;
}
- // In the desktop app the OS's own drag, with the real file on disk first.
- if (canDragNativeFiles()) {
- e.preventDefault();
- void startNativeFileDrag([{ name, url }]);
- return;
- }
-
// `DownloadURL` format: ::. The filename must be
// raw - URL-encoding it lands literally on disk (`%20` instead of a
// space). Callers are expected to sanitise reserved chars (`:` etc.)
@@ -121,7 +140,7 @@ export function useAttachmentDrag(
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
- [source.name, source.type, prefetch],
+ [source.name, source.type, prefetch, nativeDrag],
);
const handleDragEnd = useCallback(() => {
@@ -136,8 +155,8 @@ export function useAttachmentDrag(
}, []);
const copyAsFile = useCallback(async () => {
- const url = urlRef.current ?? await source.getBlobUrl();
- return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false;
+ const blob = blobRef.current ?? await source.getBlob?.();
+ return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false;
}, [source]);
if (!enabled) return NOOP_HANDLERS;
@@ -147,6 +166,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
- ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}),
+ ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}),
};
}
diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts
index b60a4e910..6debff52c 100644
--- a/hooks/use-email-drag.ts
+++ b/hooks/use-email-drag.ts
@@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
-import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag";
+import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -104,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions
function revokeLater(url: string): void {
// Chromium may read the URL asynchronously after the drop completes.
- setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ forgetDragBlob(url);
+ }, 60_000);
}
async function buildEmailZip(emails: RoutedEmail[]): Promise {
@@ -127,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise {
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
- return URL.createObjectURL(zipBlob);
+ return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob);
}
function prefetchEmailBundle(
@@ -225,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (previous?.url) revokeLater(previous.url);
const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null };
singleRef.current = entry;
- entry.promise = route.client
- .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId)
+ // The desktop app's native drag needs the bytes as well as the address.
+ const fetched = canDragNativeFiles()
+ ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob))
+ : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId);
+ entry.promise = fetched
.then((url) => {
if (url) {
if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url;
@@ -298,9 +304,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
// to stay the page's, because it also moves the message to a folder.
- if (e.altKey && canDragNativeFiles()) {
+ const blob = e.altKey ? keptDragBlob(url) : undefined;
+ if (blob) {
e.preventDefault();
- void startNativeFileDrag([{ name, url }]);
+ void startNativeFileDrag([{ name, blob }]);
return;
}
// `DownloadURL` format: ::. Chromium expects
@@ -321,9 +328,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
- if (e.altKey && canDragNativeFiles()) {
+ const blob = e.altKey ? keptDragBlob(ready.url) : undefined;
+ if (blob) {
e.preventDefault();
- void startNativeFileDrag([{ name: ready.name, url: ready.url }]);
+ void startNativeFileDrag([{ name: ready.name, blob }]);
return;
}
e.dataTransfer.setData(
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
index 301ee9abc..f7f3a5012 100644
--- a/lib/desktop/__tests__/os-window.test.tsx
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -200,23 +200,23 @@ describe('files on the window', () => {
describe('dragging an attachment out', () => {
const blobUrl = 'blob:https://app/1';
- const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl };
+ const source = {
+ name: 'report.pdf',
+ type: 'application/pdf',
+ getBlobUrl: async () => blobUrl,
+ getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
+ };
async function ready() {
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const hook = renderHook(() => useAttachmentDrag(source, true));
act(() => hook.result.current.onPointerEnter());
await act(async () => {
- await Promise.resolve();
- await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
});
return hook;
}
- beforeEach(() => {
- vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]))));
- });
-
it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
@@ -231,6 +231,22 @@ describe('dragging an attachment out', () => {
expect([...files[0].data]).toEqual([1, 2, 3]);
});
+ it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ hook.result.current.onDragStart(event as never);
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ });
+
it('in a browser: still DownloadURL, and no copy button', async () => {
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts
index c3f6476a7..07084646c 100644
--- a/lib/desktop/file-drag.ts
+++ b/lib/desktop/file-drag.ts
@@ -6,11 +6,26 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { setOwnDragActive } from '@/lib/desktop/window-files';
+/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */
export interface DragFile {
name: string;
- /** A blob: URL the page already holds. */
- url?: string;
- data?: Uint8Array;
+ blob: Blob;
+}
+
+const keptBlobs = new Map();
+
+/** In the app, remembers what's behind an object URL made for dragging. */
+export function keepDragBlob(url: string, blob: Blob): string {
+ if (canDragNativeFiles()) keptBlobs.set(url, blob);
+ return url;
+}
+
+export function keptDragBlob(url: string): Blob | undefined {
+ return keptBlobs.get(url);
+}
+
+export function forgetDragBlob(url: string): void {
+ keptBlobs.delete(url);
}
export function canDragNativeFiles(): boolean {
@@ -18,10 +33,7 @@ export function canDragNativeFiles(): boolean {
}
async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
- if (file.data) return { name: file.name, data: file.data };
- if (!file.url) throw new Error('A file needs its bytes or a URL');
- const response = await fetch(file.url);
- return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) };
+ return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) };
}
/**
From 8e469571887c01d802760793f736d705ebb51eff Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:34:29 +0100
Subject: [PATCH 03/24] feat: notify from the local store's new mail and take
it offline when the machine sleeps
---
lib/desktop/__tests__/os-mail.test.ts | 3 +-
lib/desktop/__tests__/os-store.test.ts | 76 ++++++++++++++++++++++++
lib/desktop/__tests__/os-window.test.tsx | 3 +-
lib/desktop/mail-notifications.ts | 16 +----
lib/desktop/power.ts | 34 ++---------
lib/desktop/system-events.ts | 56 +++++++++++++++++
lib/offline/offline-mode.ts | 10 ++++
7 files changed, 155 insertions(+), 43 deletions(-)
create mode 100644 lib/desktop/__tests__/os-store.test.ts
create mode 100644 lib/desktop/system-events.ts
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
index edfad4809..1338162d0 100644
--- a/lib/desktop/__tests__/os-mail.test.ts
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -311,7 +311,8 @@ describe('from arrival to toast and sound', () => {
});
it("the store's created event reaches a running watch, and nothing else", async () => {
- const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events');
const notify = vi.fn(async () => ({ shown: true, playSound: false }));
setBridge({ version: 1, platform: 'win32', os: { notify } as never });
client = fakeClient({ a: email('a') });
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
new file mode 100644
index 000000000..7339a0aa4
--- /dev/null
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -0,0 +1,76 @@
+import 'fake-indexeddb/auto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { StorePageHooks } from '@/lib/store/client/api';
+
+/** The local store and the OS integration: mail it found, and a machine going to sleep. */
+
+const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null }));
+const setOnline = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('@/lib/store/client/api', async (original) => ({
+ ...(await original()),
+ configureStore: (hooks: StorePageHooks | null) => {
+ if (hooks) configured.hooks = hooks;
+ },
+ getStore: () => ({
+ supported: () => true,
+ setOnline,
+ ensureOpen: async () => {},
+ refresh: () => {},
+ sync: async () => {},
+ stop: () => {},
+ }),
+}));
+
+describe('the store and the OS integration', () => {
+ const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAuthStore.setState({ getClientForAccount: () => client as never } as never);
+ const { registerOfflineHooks } = await import('@/lib/offline/offline-mode');
+ registerOfflineHooks();
+ });
+
+ afterEach(async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ setStoreCreatedListener(null);
+ });
+
+ it("hands the store's new mail to the notification path, with the login's client", async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ const listener = vi.fn();
+ setStoreCreatedListener(listener);
+ configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] });
+ expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']);
+ });
+
+ it('other collections are not mail', async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ const listener = vi.fn();
+ setStoreCreatedListener(listener);
+ configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] });
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('with nothing listening, as in a browser, it does nothing', () => {
+ expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow();
+ });
+
+ it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => {
+ const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode');
+ expect(offlineSupported()).toBe(true);
+ const { runSleepHandlers } = await import('../system-events');
+ const stop = startOfflineMode();
+ runSleepHandlers();
+ expect(setOnline).toHaveBeenLastCalledWith(false);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never;
+ const { startPowerWatch } = await import('../power');
+ const stopPower = startPowerWatch();
+ expect(setOnline).toHaveBeenLastCalledWith(true);
+ stopPower();
+ stop();
+ window.bulwarkDesktop = undefined;
+ });
+});
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
index f7f3a5012..2e005476f 100644
--- a/lib/desktop/__tests__/os-window.test.tsx
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -271,7 +271,8 @@ describe('waking up', () => {
let fire: (event: DesktopOsPowerEvent) => void = () => {};
setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
const power = await import('../power');
- return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() };
+ const events = await import('../system-events');
+ return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() };
}
it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => {
diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts
index 090c9ebdf..05b3303ba 100644
--- a/lib/desktop/mail-notifications.ts
+++ b/lib/desktop/mail-notifications.ts
@@ -14,6 +14,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsNotification } from '@/lib/desktop/types';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail';
+import { setStoreCreatedListener } from '@/lib/desktop/system-events';
import { devicesForAccount } from '@/lib/device-policy';
import { playNotificationSound } from '@/lib/notification-sound';
import { isPopoutWindow } from '@/lib/popout/window-role';
@@ -130,17 +131,6 @@ export interface MailNotifications extends NewMailWatch {
setLabels(labels: MailNotificationLabels): void;
}
-let current: MailNotifications | null = null;
-
-/**
- * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once
- * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`.
- * Does nothing where notifications don't run: in a browser, in a pop-out.
- */
-export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
- current?.noteCreated(client, accountId, ids);
-}
-
export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
let labels = initial;
let batch: NewMail[] = [];
@@ -176,9 +166,9 @@ export function startMailNotifications(initial: MailNotificationLabels): MailNot
watch.stop();
if (timer) clearTimeout(timer);
timer = null;
- if (current === handle) current = null;
+ setStoreCreatedListener(null);
},
};
- current = handle;
+ setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids));
return handle;
}
diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts
index 84d5ccd58..44d429dfc 100644
--- a/lib/desktop/power.ts
+++ b/lib/desktop/power.ts
@@ -4,46 +4,24 @@
* changes and replaces a dead stream, the identities and policy refresh. Those are the
* handlers already listening, so this only gives them the word. The hidden main window
* of the desktop app never goes out of view, so without this they wouldn't hear it.
- *
- * The local store registers with addWakeHandler and addSleepHandler (setOnline, sync).
+ * The local store hangs on the same two: `online` is setOnline(true) and a flush,
+ * `visibilitychange` is a sync per account. It takes itself offline for a sleep through
+ * addSleepHandler, in lib/desktop/system-events.ts.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
import { usePolicyStore } from '@/stores/policy-store';
-type Handler = () => void;
-
-const wakeHandlers = new Set();
-const sleepHandlers = new Set();
/** A resume and an unlock usually come together. */
const WAKE_REPEAT_MS = 2000;
-export function addWakeHandler(handler: Handler): () => void {
- wakeHandlers.add(handler);
- return () => wakeHandlers.delete(handler);
-}
-
-export function addSleepHandler(handler: Handler): () => void {
- sleepHandlers.add(handler);
- return () => sleepHandlers.delete(handler);
-}
-
-function runAll(handlers: Set): void {
- for (const handler of [...handlers]) {
- try {
- handler();
- } catch (error) {
- console.error('A wake or sleep handler failed:', error);
- }
- }
-}
-
function wake(): void {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('online'));
const policy = usePolicyStore.getState();
if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
- runAll(wakeHandlers);
+ runWakeHandlers();
}
export function startPowerWatch(): () => void {
@@ -58,7 +36,7 @@ export function startPowerWatch(): () => void {
};
const stopPower = bridge.onPower((event) => {
if (event === 'resume' || event === 'unlock-screen') wakeOnce();
- else if (event === 'suspend') runAll(sleepHandlers);
+ else if (event === 'suspend') runSleepHandlers();
});
// Wi-Fi to cable is a new network with no offline moment in between.
const connection = (navigator as unknown as { connection?: EventTarget }).connection;
diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts
new file mode 100644
index 000000000..f1b258077
--- /dev/null
+++ b/lib/desktop/system-events.ts
@@ -0,0 +1,56 @@
+/**
+ * What the local store and the OS integration say to each other, with nothing imported
+ * either way: lib/offline runs in every browser, and the notification code pulls in half
+ * the app's stores.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+type Handler = () => void;
+type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void;
+
+const wakeHandlers = new Set();
+const sleepHandlers = new Set();
+let createdListener: CreatedListener | null = null;
+
+export function addWakeHandler(handler: Handler): () => void {
+ wakeHandlers.add(handler);
+ return () => {
+ wakeHandlers.delete(handler);
+ };
+}
+
+export function addSleepHandler(handler: Handler): () => void {
+ sleepHandlers.add(handler);
+ return () => {
+ sleepHandlers.delete(handler);
+ };
+}
+
+function runAll(handlers: Set): void {
+ for (const handler of [...handlers]) {
+ try {
+ handler();
+ } catch (error) {
+ console.error('A wake or sleep handler failed:', error);
+ }
+ }
+}
+
+export function runWakeHandlers(): void {
+ runAll(wakeHandlers);
+}
+
+export function runSleepHandlers(): void {
+ runAll(sleepHandlers);
+}
+
+/** The notification path, while it runs. */
+export function setStoreCreatedListener(listener: CreatedListener | null): void {
+ createdListener = listener;
+}
+
+/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */
+export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
+ createdListener?.(client, accountId, ids);
+}
diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts
index 836e1286b..beff3d52e 100644
--- a/lib/offline/offline-mode.ts
+++ b/lib/offline/offline-mode.ts
@@ -60,6 +60,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
+import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
| { kind: 'queued'; subject: string }
@@ -180,6 +181,10 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
+ if (event.type === 'created' && event.collection === 'Email') {
+ const client = onlineClientFor(event.account);
+ if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
+ }
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
forgetMailSnapshot(event.account);
@@ -906,6 +911,11 @@ export function startOfflineMode(): () => void {
window.removeEventListener('offline', onOffline);
});
+ // The desktop app going to sleep. Waking comes with the two events above and below.
+ cleanups.push(addSleepHandler(() => {
+ void getStore().setOnline(false).catch(() => {});
+ }));
+
// Back in front: catch up now rather than at the next interval.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
From 42263e856dc8e46e21eb4c9542da2cf8e6139b75 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:54:17 +0100
Subject: [PATCH 04/24] fix: still notify about mail the local store hadn't got
when the push arrived
---
lib/desktop/__tests__/os-mail.test.ts | 21 +++++++++++++++++++++
lib/desktop/new-mail.ts | 6 ++++--
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
index 1338162d0..ca3f25bf1 100644
--- a/lib/desktop/__tests__/os-mail.test.ts
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -166,6 +166,27 @@ describe('the watch', () => {
watch.stop();
});
+ it('a message the store has not got yet is not written off: its created event still tells it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const held: Record = {};
+ const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ // The push got here first. The device answers, and it doesn't have the message.
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual([]);
+ // The store has synced it now.
+ held.a = email('a');
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
it('without a baseline the first push only sets one: old mail is not news', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts
index 31a6568ea..efe23173d 100644
--- a/lib/desktop/new-mail.ts
+++ b/lib/desktop/new-mail.ts
@@ -81,9 +81,11 @@ export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWat
const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => {
const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`));
if (fresh.length === 0 || stopped) return;
- for (const id of fresh) seen.push(`${accountId}/${id}`);
- seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]);
+ // Only what came back counts as told. With the store on, a push can overtake it: the
+ // device doesn't have the message yet, and its created event has to be able to tell it.
+ for (const email of emails) seen.push(`${accountId}/${email.id}`);
+ seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
const own = client.getAccountId();
const mail = emails
.filter((email) => notifyingMailbox(email, accountId, own, mailboxes))
From 3dc9e11b4db65071f63ed3b88abc05507c627014 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 11:32:51 +0100
Subject: [PATCH 05/24] test: give the store and OS integration test time for
its first import
---
lib/desktop/__tests__/os-store.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
index 7339a0aa4..cd876cfd2 100644
--- a/lib/desktop/__tests__/os-store.test.ts
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -4,6 +4,9 @@ import type { StorePageHooks } from '@/lib/store/client/api';
/** The local store and the OS integration: mail it found, and a machine going to sleep. */
+// The first import of offline-mode compiles a good part of the app.
+vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
+
const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null }));
const setOnline = vi.hoisted(() => vi.fn(async () => {}));
From 41fcbba61c63d119f0a31763b336f0a0ddfc848a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:24:14 +0100
Subject: [PATCH 06/24] fix: keep sender and subject off the OS until they're
known to be allowed, leave nothing on it after sign-out, and take files only
where they're meant
---
components/desktop/desktop-os-host.tsx | 10 +-
components/email/email-composer.tsx | 7 +-
components/email/email-viewer.tsx | 22 ++-
.../protocol-launch-handler-provider.tsx | 2 +
lib/desktop/__tests__/os-commands.test.ts | 30 +++-
lib/desktop/__tests__/os-mail.test.ts | 11 ++
lib/desktop/__tests__/os-store.test.ts | 11 ++
lib/desktop/__tests__/os-window.test.tsx | 168 +++++++++++++++++-
lib/desktop/bridge.ts | 5 +
lib/desktop/mail-notifications.ts | 12 +-
lib/desktop/os-commands.ts | 9 +
lib/desktop/os-summary.ts | 26 ++-
lib/desktop/power.ts | 24 ++-
lib/desktop/types.ts | 11 +-
lib/desktop/window-files.ts | 45 ++++-
lib/offline/offline-mode.ts | 3 +
lib/sign-out-cleanup.ts | 2 +
locales/ar/common.json | 31 ++++
locales/ca/common.json | 31 ++++
locales/cs/common.json | 31 ++++
locales/da/common.json | 31 ++++
locales/de/common.json | 31 ++++
locales/en/common.json | 31 ++++
locales/es/common.json | 31 ++++
locales/et/common.json | 31 ++++
locales/fa/common.json | 31 ++++
locales/fr/common.json | 31 ++++
locales/he/common.json | 31 ++++
locales/hu/common.json | 31 ++++
locales/it/common.json | 31 ++++
locales/ja/common.json | 31 ++++
locales/ko/common.json | 31 ++++
locales/lv/common.json | 31 ++++
locales/mn/common.json | 31 ++++
locales/nb/common.json | 31 ++++
locales/nl/common.json | 31 ++++
locales/pl/common.json | 31 ++++
locales/pt/common.json | 31 ++++
locales/ro/common.json | 31 ++++
locales/ru/common.json | 31 ++++
locales/sk/common.json | 31 ++++
locales/th/common.json | 31 ++++
locales/tr/common.json | 31 ++++
locales/uk/common.json | 31 ++++
locales/vi/common.json | 31 ++++
locales/zh-TW/common.json | 31 ++++
locales/zh/common.json | 31 ++++
47 files changed, 1277 insertions(+), 51 deletions(-)
diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx
index c8d2755d8..2e4ea7fd6 100644
--- a/components/desktop/desktop-os-host.tsx
+++ b/components/desktop/desktop-os-host.tsx
@@ -4,7 +4,8 @@ import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { getDesktopBridge } from "@/lib/desktop/bridge";
-import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
+import { isAppLocked } from "@/lib/desktop/app-lock-state";
+import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
@@ -49,7 +50,10 @@ export function DesktopOsHost() {
labelsRef.current = labels;
useEffect(() => {
- if (!getDesktopBridge()?.os) return;
+ const os = getDesktopBridge()?.os;
+ if (!os) return;
+ // Main shows no content anywhere until this page has said it may.
+ os.setLocked(isAppLocked());
const mail = startMailNotifications(labelsRef.current);
mailRef.current = mail;
const summary = startOsSummary(() => localeRef.current);
@@ -64,7 +68,7 @@ export function DesktopOsHost() {
const event = "eventId" in view;
void getDesktopBridge()?.os?.notify({
id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`,
- title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"),
+ title: (contentHiddenFor(useAuthStore.getState().activeAccountId) ? "" : event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"),
body: "",
context: "{}",
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 4c02f4eac..f0dab3ae6 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2650,8 +2650,11 @@ export function EmailComposer({
resetDragTimeout();
}, [resetDragTimeout]);
- // The desktop app: files dropped or pasted anywhere on the window come here.
- useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]);
+ // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
+ useEffect(() => setComposerFileTarget(
+ (files) => void addFiles(files),
+ () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
+ ), [addFiles, composerRootEl]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 9dbde37b4..22d9f7730 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -685,15 +685,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
- getBlob: async () => {
- if (attachment.blobId && client) {
- return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
- }
- const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null);
- if (!bytes || bytes.byteLength === 0) return null;
- const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
- return new Blob([buffer], { type: inertBlobType(attachment.type) });
- },
+ // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it
+ // stays with the browser's drag, which only writes it where the user drops it.
+ ...(attachment.decryptedAttachment ? {} : {
+ getBlob: async () => {
+ if (attachment.blobId && client) {
+ return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
+ }
+ const bytes = attachment.tnefData;
+ if (!bytes || bytes.byteLength === 0) return null;
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+ return new Blob([buffer], { type: inertBlobType(attachment.type) });
+ },
+ }),
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx
index 0078cd8bf..f3940efaa 100644
--- a/components/protocol/protocol-launch-handler-provider.tsx
+++ b/components/protocol/protocol-launch-handler-provider.tsx
@@ -111,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const router = useRouter();
const pathname = usePathname();
const tLaunch = useTranslations("pwaLaunch");
+ const tDesktop = useTranslations("desktopOs");
const sentToTarget = useRef(new Set());
const { dialogProps, confirm } = useConfirmDialog();
const [offer, setOffer] = useState(null);
@@ -213,6 +214,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
if (osEnv.current.pathname !== path) osEnv.current.push(path);
},
notifyFailed: () => toast.error(tLaunch("open_failed")),
+ notifyReplyLost: () => toast.error(tDesktop("reply_lost")),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
index 465180c64..d3f717f64 100644
--- a/lib/desktop/__tests__/os-commands.test.ts
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -33,7 +33,8 @@ async function fingerprint() {
const leaveFor = vi.fn();
const push = vi.fn();
const notifyFailed = vi.fn();
-const env = { leaveFor, push, notifyFailed };
+const notifyReplyLost = vi.fn();
+const env = { leaveFor, push, notifyFailed, notifyReplyLost };
const focus = vi.fn();
async function run(command: DesktopOsCommand) {
@@ -191,6 +192,33 @@ describe('a toast\'s buttons', () => {
stop();
});
+ it('behind the app lock an old toast acts on nothing, and the window comes up', async () => {
+ const { setAppLockCheck } = await import('../app-lock-state');
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ setAppLockCheck(() => true);
+ for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) {
+ await run({ type: 'notification', action, context: await context(), reply: 'x' });
+ }
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).not.toHaveBeenCalled();
+ expect(focus).toHaveBeenCalled();
+ setAppLockCheck(null);
+ stop();
+ });
+
+ it('typed text that has nowhere to go says so', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const stop = setMailLinkOpener(vi.fn());
+ await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' });
+ expect(notifyReplyLost).toHaveBeenCalled();
+ notifyReplyLost.mockClear();
+ await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) });
+ expect(notifyReplyLost).not.toHaveBeenCalled();
+ stop();
+ });
+
it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
index ca3f25bf1..da28971f6 100644
--- a/lib/desktop/__tests__/os-mail.test.ts
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -87,6 +87,8 @@ beforeEach(async () => {
});
const { setAppLockCheck } = await import('../app-lock-state');
setAppLockCheck(null);
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true });
});
afterEach(() => {
@@ -266,6 +268,15 @@ describe('what the notification says', () => {
expect(JSON.stringify(notification)).not.toContain('Alice Sender');
});
+ it('before the policy is known: the same', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: false });
+ const notification = await buildMailNotification([one()], labels);
+ expect(JSON.stringify(notification)).not.toContain('Secret');
+ expect(JSON.stringify(notification)).not.toContain('Alice Sender');
+ });
+
it('several at once are told about once, and that one has no message to act on', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
index cd876cfd2..e09aee90d 100644
--- a/lib/desktop/__tests__/os-store.test.ts
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -49,6 +49,17 @@ describe('the store and the OS integration', () => {
expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']);
});
+ it('a wipe clears what the OS shows, and a browser has nothing to clear', async () => {
+ const { clearDesktopSurfaces } = await import('../bridge');
+ expect(() => clearDesktopSurfaces()).not.toThrow();
+ const clearSurfaces = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
+ const { wipeAccount } = await import('@/lib/offline/offline-mode');
+ await wipeAccount('alice@one').catch(() => {});
+ expect(clearSurfaces).toHaveBeenCalled();
+ window.bulwarkDesktop = undefined;
+ });
+
it('other collections are not mail', async () => {
const { setStoreCreatedListener } = await import('../system-events');
const listener = vi.fn();
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
index 2e005476f..b6df496bb 100644
--- a/lib/desktop/__tests__/os-window.test.tsx
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -159,7 +159,7 @@ describe('files on the window', () => {
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
- const clear = setComposerFileTarget(target);
+ const clear = setComposerFileTarget(target, () => true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(target).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
@@ -167,6 +167,20 @@ describe('files on the window', () => {
stop();
});
+ it('a composer out of sight on a hidden tab takes nothing', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const hidden = vi.fn();
+ const clear = setComposerFileTarget(hidden, () => false);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(hidden).not.toHaveBeenCalled();
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ clear();
+ stop();
+ });
+
it('a pasted file is taken; pasted text is not', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
@@ -174,7 +188,7 @@ describe('files on the window', () => {
const stop = startWindowFiles();
const paste = (files: File[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
- Object.defineProperty(event, 'clipboardData', { value: { files } });
+ Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } });
window.dispatchEvent(event);
return event;
};
@@ -185,6 +199,54 @@ describe('files on the window', () => {
stop();
});
+ describe('a paste that is not for us', () => {
+ const pasteOn = (target: EventTarget, types: string[]) => {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } });
+ target.dispatchEvent(event);
+ return event;
+ };
+
+ it('text with a file, as a spreadsheet copies, stays text', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('in a search field or any other input, a file is not an attachment', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const input = document.body.appendChild(document.createElement('input'));
+ expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ input.remove();
+ stop();
+ });
+
+ it('in the body of a composer on screen, a bare file attaches', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ setBridge({});
+ const stop = startWindowFiles();
+ const target = vi.fn();
+ const clear = setComposerFileTarget(target, () => true);
+ const composer = document.body.appendChild(document.createElement('div'));
+ composer.setAttribute('data-testid', 'email-composer');
+ const body = composer.appendChild(document.createElement('div'));
+ body.setAttribute('contenteditable', 'true');
+ pasteOn(body, ['Files']);
+ expect(target).toHaveBeenCalledWith([file]);
+ composer.remove();
+ clear();
+ stop();
+ });
+ });
+
it('our own native drag over our own window is not a new file', async () => {
const { setOwnDragActive, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
@@ -247,6 +309,23 @@ describe('dragging an attachment out', () => {
await waitFor(() => expect(startDrag).toHaveBeenCalled());
});
+ it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const { name, type, getBlobUrl } = source;
+ const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true));
+ act(() => hook.result.current.onPointerEnter());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(startDrag).not.toHaveBeenCalled();
+ expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl));
+ expect(hook.result.current.onCopyAsFile).toBeUndefined();
+ });
+
it('in a browser: still DownloadURL, and no copy button', async () => {
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
@@ -313,15 +392,52 @@ describe('waking up', () => {
stop();
});
- it('a network change counts as waking', async () => {
- const connection = new EventTarget();
- vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection }));
+ it('a new kind of network counts as waking; a change in its speed estimate does not', async () => {
+ const connection = Object.assign(new EventTarget(), { type: 'wifi' });
+ vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
const { power, stop } = await started();
const wake = vi.fn();
const stopWake = power.addWakeHandler(wake);
- await new Promise((resolve) => setTimeout(resolve, 0));
connection.dispatchEvent(new Event('change'));
- expect(wake).toHaveBeenCalled();
+ expect(wake).not.toHaveBeenCalled();
+ connection.type = 'ethernet';
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).toHaveBeenCalledTimes(1);
+ stopWake();
+ stop();
+ });
+
+ it('where Chromium gives no type, a change is nothing to go on', async () => {
+ const connection = new EventTarget();
+ vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
+ const { power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).not.toHaveBeenCalled();
+ stopWake();
+ stop();
+ });
+
+ it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => {
+ const online = vi.fn();
+ window.addEventListener('online', online);
+ vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } }));
+ const { fire, power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ fire('resume');
+ expect(wake).toHaveBeenCalledTimes(1);
+ expect(online).not.toHaveBeenCalled();
+ const now = Date.now();
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000);
+ fire('unlock-screen');
+ expect(wake).toHaveBeenCalledTimes(1);
+ clock.mockReturnValue(now + 31_000);
+ fire('unlock-screen');
+ expect(wake).toHaveBeenCalledTimes(2);
+ clock.mockRestore();
+ window.removeEventListener('online', online);
stopWake();
stop();
});
@@ -357,6 +473,8 @@ describe('the summary', () => {
useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' });
useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never });
useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never });
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true });
});
afterEach(async () => {
@@ -374,6 +492,42 @@ describe('the summary', () => {
expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' });
});
+ it('before the policy is known it is empty', async () => {
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ usePolicyStore.setState({ loaded: false });
+ await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
+ expect(getEmails).not.toHaveBeenCalled();
+ });
+
+ it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => {
+ const setSummary = vi.fn();
+ setBridge({ setSummary });
+ const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ const { useAuthStore } = await import('@/stores/auth-store');
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ let answer: (value: unknown) => void = () => {};
+ getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ const summary = startOsSummary(() => 'en');
+ await vi.advanceTimersByTimeAsync(2100);
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
+ answer({ emails: [row], hasMore: false, total: 1 });
+ await vi.advanceTimersByTimeAsync(10);
+ await new Promise((resolve) => setImmediate(resolve));
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
+ expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch');
+
+ setSummary.mockClear();
+ usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } });
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(setSummary).toHaveBeenCalled();
+ summary.stop();
+ vi.useRealTimers();
+ });
+
it('while locked it is empty, without asking anyone anything', async () => {
const { setAppLockCheck } = await import('../app-lock-state');
const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts
index 922a5c1b5..33e36bd81 100644
--- a/lib/desktop/bridge.ts
+++ b/lib/desktop/bridge.ts
@@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
}
+/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */
+export function clearDesktopSurfaces(): void {
+ getDesktopBridge()?.os?.clearSurfaces?.();
+}
+
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts
index 05b3303ba..12cd12e5d 100644
--- a/lib/desktop/mail-notifications.ts
+++ b/lib/desktop/mail-notifications.ts
@@ -21,6 +21,7 @@ import { isPopoutWindow } from '@/lib/popout/window-role';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
+import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -72,17 +73,22 @@ async function senderInfo(client: IJMAPClient): Promise {
};
}
-function withoutContent(sender: Sender): boolean {
+/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */
+export function contentHiddenFor(accountEntryId: string | null | undefined): boolean {
if (isAppLocked()) return true;
// Unknown counts as on, as on the server's route.
- if (!sender.entryId) return true;
+ if (!accountEntryId || !usePolicyStore.getState().loaded) return true;
try {
- return devicesForAccount(sender.entryId).pushWithoutContent;
+ return devicesForAccount(accountEntryId).pushWithoutContent;
} catch {
return true;
}
}
+function withoutContent(sender: Sender): boolean {
+ return contentHiddenFor(sender.entryId);
+}
+
function senderName(mail: NewMail): string {
const first = mail.email.from?.[0];
return first?.name || first?.email || '';
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
index 8c7332225..534ff7544 100644
--- a/lib/desktop/os-commands.ts
+++ b/lib/desktop/os-commands.ts
@@ -5,6 +5,7 @@
* user's own OS, so it skips the question a web share gets (decision 17).
*/
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsCommand } from '@/lib/desktop/types';
import { openMailLink } from '@/lib/desktop/mail-links';
@@ -25,6 +26,8 @@ export interface OsCommandEnv {
push: (path: string) => void;
/** Says something couldn't be opened. */
notifyFailed: (names: readonly string[]) => void;
+ /** A typed reply that has nowhere to go: its message's login is gone from here. */
+ notifyReplyLost: () => void;
}
/** How long a toast's button waits for a cold start to sign in. */
@@ -77,6 +80,7 @@ async function openFromNotification(context: MailNotificationContext, env: OsCom
openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor);
return;
}
+ if (reply?.text) env.notifyReplyLost();
openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor);
}
@@ -89,6 +93,11 @@ function runAction(request: NotificationActionRequest): Promise {
+ // An old toast's Delete doesn't act behind the lock, and its message doesn't open over it.
+ if (isAppLocked()) {
+ showWindow();
+ return;
+ }
const context = parseContext(rawContext);
if (action === 'reply') {
await openFromNotification(context, env, { text: reply ?? '' });
diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts
index 12d014051..599dd942a 100644
--- a/lib/desktop/os-summary.ts
+++ b/lib/desktop/os-summary.ts
@@ -6,9 +6,8 @@
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
-import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types';
-import { devicesForAccount } from '@/lib/device-policy';
import type { CalendarEvent, Email } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
@@ -16,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
const ROWS = 5;
const REFRESH_DELAY_MS = 2000;
@@ -46,12 +46,10 @@ export function formatEventTime(start: number, now: number, locale: string | und
}
export async function readSummary(locale: string | undefined, now = Date.now()): Promise {
- if (isAppLocked()) return EMPTY_SUMMARY;
const auth = useAuthStore.getState();
const client: IJMAPClient | null = auth.client;
const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId);
- if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY;
- if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY;
+ if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY;
const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared);
let unread: DesktopOsTrayMessage[] = [];
@@ -91,12 +89,14 @@ export function startOsSummary(locale: () => string | undefined): { refresh: ()
const os = getDesktopBridge()?.os;
if (!os) return { refresh: () => {}, stop: () => {} };
let timer: ReturnType | null = null;
- let stale = false;
+ /** A read under way when the account changed answers for the old one, and is dropped. */
+ let generation = 0;
const run = () => {
timer = null;
+ const mine = ++generation;
void readSummary(locale()).then((summary) => {
- if (!stale) os.setSummary(summary);
+ if (mine === generation) os.setSummary(summary);
}, (error) => {
console.warn('Could not read the tray summary:', error);
});
@@ -113,16 +113,24 @@ export function startOsSummary(locale: () => string | undefined): { refresh: ()
refresh();
});
const stopAuth = useAuthStore.subscribe((state, previous) => {
- if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh();
+ if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return;
+ generation += 1;
+ if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY);
+ refresh();
+ });
+ // An organisation turning sender and subject off goes for what's already showing, too.
+ const stopPolicy = usePolicyStore.subscribe((state, previous) => {
+ if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh();
});
const interval = setInterval(refresh, REFRESH_EVERY_MS);
refresh();
return {
refresh,
stop() {
- stale = true;
+ generation += 1;
stopEmail();
stopAuth();
+ stopPolicy();
clearInterval(interval);
if (timer) clearTimeout(timer);
},
diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts
index 44d429dfc..1e36e52ae 100644
--- a/lib/desktop/power.ts
+++ b/lib/desktop/power.ts
@@ -13,12 +13,13 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
import { usePolicyStore } from '@/stores/policy-store';
-/** A resume and an unlock usually come together. */
-const WAKE_REPEAT_MS = 2000;
+/** A resume, an unlock and a network change come together, and each would mean a round of requests. */
+const WAKE_REPEAT_MS = 30_000;
function wake(): void {
document.dispatchEvent(new Event('visibilitychange'));
- window.dispatchEvent(new Event('online'));
+ // Woken up with no network: the browser's own event comes when there is one.
+ if (navigator.onLine !== false) window.dispatchEvent(new Event('online'));
const policy = usePolicyStore.getState();
if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
runWakeHandlers();
@@ -38,11 +39,20 @@ export function startPowerWatch(): () => void {
if (event === 'resume' || event === 'unlock-screen') wakeOnce();
else if (event === 'suspend') runSleepHandlers();
});
- // Wi-Fi to cable is a new network with no offline moment in between.
- const connection = (navigator as unknown as { connection?: EventTarget }).connection;
- connection?.addEventListener?.('change', wakeOnce);
+ // Wi-Fi to cable is a new network with no offline moment in between. This event also fires
+ // for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the
+ // type, that leaves the stream's own ping check and the online event.
+ const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection;
+ let lastType = connection?.type;
+ const onConnection = () => {
+ const type = connection?.type;
+ if (!type || type === lastType) return;
+ lastType = type;
+ wakeOnce();
+ };
+ connection?.addEventListener?.('change', onConnection);
return () => {
stopPower();
- connection?.removeEventListener?.('change', wakeOnce);
+ connection?.removeEventListener?.('change', onConnection);
};
}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index 501cc503a..859f2ea97 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -168,7 +168,7 @@ export interface DesktopOsNotification {
export interface DesktopOsNotifyResult {
shown: boolean;
- reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid';
+ reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed';
/** The page should play the sound the user picked. */
playSound: boolean;
}
@@ -214,8 +214,13 @@ export interface DesktopOsBridge {
setSummary: (summary: DesktopOsSummary) => void;
/** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */
setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void;
- /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */
+ /**
+ * App lock's state. Main counts as locked until the main window's page says otherwise,
+ * and clears the tray, jump list and toasts and strips what comes through.
+ */
setLocked: (locked: boolean) => void;
+ /** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */
+ clearSurfaces: () => void;
notify: (notification: DesktopOsNotification) => Promise;
onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void;
/** Writes the files to a private folder and starts a drag. Only with the mouse held. */
@@ -225,6 +230,8 @@ export interface DesktopOsBridge {
defaultAppStatus: () => Promise;
/** Offers the app to the OS and opens where the user chooses. */
openDefaultAppSettings: () => Promise;
+}
+
export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
export interface DesktopKeystoreCertificate {
diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts
index c9b95221a..63a67630b 100644
--- a/lib/desktop/window-files.ts
+++ b/lib/desktop/window-files.ts
@@ -8,29 +8,58 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { handOffDraft } from '@/lib/launch-handoff';
-type ComposerTarget = (files: File[]) => unknown;
+interface ComposerTarget {
+ take: (files: File[]) => unknown;
+ /** The Pro shell keeps composers mounted on tabs out of sight. */
+ visible: () => boolean;
+}
-let composerTarget: ComposerTarget | null = null;
+const composerTargets: ComposerTarget[] = [];
/** Our own native drag passing over our own window isn't a new file. */
let ownDrag = false;
-/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */
-export function setComposerFileTarget(target: ComposerTarget): () => void {
- composerTarget = target;
+/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */
+export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void {
+ const target = { take, visible };
+ composerTargets.push(target);
return () => {
- if (composerTarget === target) composerTarget = null;
+ const at = composerTargets.indexOf(target);
+ if (at !== -1) composerTargets.splice(at, 1);
};
}
+function visibleComposer(): ComposerTarget | undefined {
+ return [...composerTargets].reverse().find((target) => target.visible());
+}
+
export function setOwnDragActive(active: boolean): void {
ownDrag = active;
}
function take(files: File[]): void {
- if (composerTarget) composerTarget(files);
+ const composer = visibleComposer();
+ if (composer) composer.take(files);
else handOffDraft({ files });
}
+function isEditable(target: EventTarget | null): boolean {
+ const element = target as Partial & { closest?: (selector: string) => Element | null } | null;
+ return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
+}
+
+/**
+ * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a
+ * picture of them, and a field that takes text wants the text. The one editable place a
+ * bare file belongs in is the composer it's being pasted into.
+ */
+function pasteIsForUs(event: ClipboardEvent): boolean {
+ const types = [...(event.clipboardData?.types ?? [])];
+ if (types.includes('text/plain') || types.includes('text/html')) return false;
+ if (!isEditable(event.target)) return true;
+ const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]');
+ return inComposer && !!visibleComposer();
+}
+
function hasFiles(event: DragEvent): boolean {
return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
}
@@ -51,7 +80,7 @@ export function startWindowFiles(): () => void {
const onPaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return;
const files = Array.from(event.clipboardData?.files ?? []);
- if (files.length === 0) return;
+ if (files.length === 0 || !pasteIsForUs(event)) return;
event.preventDefault();
take(files);
};
diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts
index d71993589..e138070f1 100644
--- a/lib/offline/offline-mode.ts
+++ b/lib/offline/offline-mode.ts
@@ -62,6 +62,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
+import { clearDesktopSurfaces } from '@/lib/desktop/bridge';
import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
@@ -807,6 +808,7 @@ async function nameLost(scope: string, stillSignedIn: boolean): Promise {
}
export async function wipeAccount(account: string): Promise {
+ clearDesktopSurfaces();
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
@@ -838,6 +840,7 @@ export async function wipeAccount(account: string): Promise {
/** Nothing of offline mode stays on this device. */
export async function wipeEverything(): Promise {
+ clearDesktopSurfaces();
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
writeEnabled([]);
diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts
index 531c99f7f..67ff5ea9c 100644
--- a/lib/sign-out-cleanup.ts
+++ b/lib/sign-out-cleanup.ts
@@ -1,6 +1,7 @@
import { fileStorage } from '@/lib/plugin-storage';
import { clearHandoffs } from '@/lib/launch-handoff';
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
+import { clearDesktopSurfaces } from '@/lib/desktop/bridge';
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
import { purgeStoreFiles } from '@/lib/store/client/purge';
@@ -138,6 +139,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void {
}
// Files and links on their way into a draft or an import.
clearHandoffs();
+ clearDesktopSurfaces();
for (const cleanup of signedOutCleanups) {
try {
cleanup(options);
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 654025307..911ff01cf 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
},
+ "desktopOs": {
+ "section_title": "تطبيق سطح المكتب",
+ "section_description": "طريقة عمل التطبيق مع حاسوبك.",
+ "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق",
+ "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.",
+ "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب",
+ "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.",
+ "start_minimized_label": "البدء في شريط النظام",
+ "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.",
+ "attention_label": "وميض شريط المهام عند وصول بريد جديد",
+ "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد",
+ "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.",
+ "quiet_hours_label": "ساعات الهدوء",
+ "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.",
+ "quiet_hours_from": "من",
+ "quiet_hours_to": "إلى",
+ "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح",
+ "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.",
+ "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.",
+ "default_app_label": "تطبيق البريد الافتراضي",
+ "default_app_description": "فتح روابط البريد وملفات .eml و.ics و.vcf بهذا التطبيق.",
+ "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.",
+ "default_app_button": "اختيار التطبيق الافتراضي…",
+ "new_mail": "بريد جديد",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "نسخ كملف",
+ "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
+ "copy_failed": "تعذّر نسخ الملف.",
+ "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
+ },
"desktop_sign_in": {
"browser_button": "سجّل الدخول باستخدام المتصفح",
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0a9eb6fb2..275c62df5 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
},
+ "desktopOs": {
+ "section_title": "Aplicació d'escriptori",
+ "section_description": "Com funciona l'aplicació amb l'ordinador.",
+ "close_to_tray_label": "Continua a la safata en tancar",
+ "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.",
+ "start_at_login_label": "Inicia en entrar a l'ordinador",
+ "start_at_login_description": "L'aplicació s'inicia sola quan entreu.",
+ "start_minimized_label": "Inicia a la safata",
+ "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.",
+ "attention_label": "Fes parpellejar la barra de tasques amb correu nou",
+ "attention_mac_label": "Fes botar la icona del Dock amb correu nou",
+ "attention_description": "Només quan l'aplicació no és al davant.",
+ "quiet_hours_label": "Hores de silenci",
+ "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat",
+ "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.",
+ "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.",
+ "default_app_label": "Aplicació de correu predeterminada",
+ "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.",
+ "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.",
+ "default_app_button": "Tria l'aplicació predeterminada…",
+ "new_mail": "Correu nou",
+ "no_subject": "(sense assumpte)",
+ "action_delete": "Suprimeix",
+ "copy_as_file": "Copia com a fitxer",
+ "copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
+ "copy_failed": "No s'ha pogut copiar el fitxer.",
+ "reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà."
+ },
"desktop_sign_in": {
"browser_button": "Inicia la sessió amb el navegador",
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 18f0c03ee..d818f1a13 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
},
+ "desktopOs": {
+ "section_title": "Desktopová aplikace",
+ "section_description": "Jak aplikace spolupracuje s vaším počítačem.",
+ "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti",
+ "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.",
+ "start_at_login_label": "Spustit po přihlášení k počítači",
+ "start_at_login_description": "Aplikace se po přihlášení spustí sama.",
+ "start_minimized_label": "Spustit v oznamovací oblasti",
+ "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.",
+ "attention_label": "Při nové poště blikat na hlavním panelu",
+ "attention_mac_label": "Při nové poště poskakovat ikonou v Docku",
+ "attention_description": "Jen když aplikace není v popředí.",
+ "quiet_hours_label": "Doba klidu",
+ "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou",
+ "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.",
+ "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.",
+ "default_app_label": "Výchozí poštovní aplikace",
+ "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.",
+ "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.",
+ "default_app_button": "Vybrat výchozí aplikaci…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez předmětu)",
+ "action_delete": "Smazat",
+ "copy_as_file": "Kopírovat jako soubor",
+ "copied": "Soubor zkopírován. Můžete ho vložit do složky.",
+ "copy_failed": "Soubor se nepodařilo zkopírovat.",
+ "reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní."
+ },
"desktop_sign_in": {
"browser_button": "Přihlásit se v prohlížeči",
"browser_waiting": "Dokončete přihlášení v prohlížeči",
diff --git a/locales/da/common.json b/locales/da/common.json
index 5578eeca0..fa38d0449 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Denne besked er flyttet til {folder}.",
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
},
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Sådan arbejder appen sammen med din computer.",
+ "close_to_tray_label": "Fortsæt i systembakken ved lukning",
+ "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.",
+ "start_at_login_label": "Start, når du logger på computeren",
+ "start_at_login_description": "Appen starter selv, når du logger på.",
+ "start_minimized_label": "Start i systembakken",
+ "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.",
+ "attention_label": "Blink i proceslinjen ved ny post",
+ "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post",
+ "attention_description": "Kun når appen ikke er forrest.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en tastaturgenvej",
+ "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.",
+ "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.",
+ "default_app_label": "Standardmailapp",
+ "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.",
+ "default_app_is_default": "Denne app åbner dine maillinks.",
+ "default_app_button": "Vælg standardapp…",
+ "new_mail": "Ny post",
+ "no_subject": "(intet emne)",
+ "action_delete": "Slet",
+ "copy_as_file": "Kopiér som fil",
+ "copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres.",
+ "reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der."
+ },
"desktop_sign_in": {
"browser_button": "Log ind med din browser",
"browser_waiting": "Gør login færdigt i din browser",
diff --git a/locales/de/common.json b/locales/de/common.json
index efb641f2b..336c82000 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
},
+ "desktopOs": {
+ "section_title": "Desktop-App",
+ "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.",
+ "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen",
+ "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.",
+ "start_at_login_label": "Beim Anmelden am Computer starten",
+ "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.",
+ "start_minimized_label": "Im Infobereich starten",
+ "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.",
+ "attention_label": "Taskleiste bei neuen E-Mails blinken lassen",
+ "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen",
+ "attention_description": "Nur wenn die App nicht im Vordergrund ist.",
+ "quiet_hours_label": "Ruhezeiten",
+ "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.",
+ "quiet_hours_from": "Von",
+ "quiet_hours_to": "bis",
+ "show_shortcut_label": "App mit einer Tastenkombination anzeigen",
+ "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.",
+ "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.",
+ "default_app_label": "Standard-E-Mail-App",
+ "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.",
+ "default_app_is_default": "Diese App öffnet Ihre Mail-Links.",
+ "default_app_button": "Standard-App auswählen…",
+ "new_mail": "Neue E-Mail",
+ "no_subject": "(kein Betreff)",
+ "action_delete": "Löschen",
+ "copy_as_file": "Als Datei kopieren",
+ "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
+ "copy_failed": "Die Datei konnte nicht kopiert werden.",
+ "reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort."
+ },
"desktop_sign_in": {
"browser_button": "Mit dem Browser anmelden",
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
diff --git a/locales/en/common.json b/locales/en/common.json
index 7728594e1..7ec268e1c 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "This message was moved to {folder}.",
"window_not_ready": "The new window did not open in time. Your draft stays here."
},
+ "desktopOs": {
+ "section_title": "Desktop app",
+ "section_description": "How the app works with your computer.",
+ "close_to_tray_label": "Keep running in the tray when closed",
+ "close_to_tray_description": "New mail and reminders keep coming after you close the window.",
+ "start_at_login_label": "Start when you sign in to your computer",
+ "start_at_login_description": "The app starts by itself when you sign in.",
+ "start_minimized_label": "Start in the tray",
+ "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.",
+ "attention_label": "Flash the taskbar on new mail",
+ "attention_mac_label": "Bounce the Dock icon on new mail",
+ "attention_description": "Only when the app is not in front.",
+ "quiet_hours_label": "Quiet hours",
+ "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.",
+ "quiet_hours_from": "From",
+ "quiet_hours_to": "to",
+ "show_shortcut_label": "Show the app with a keyboard shortcut",
+ "show_shortcut_description": "{keys} brings the app to the front, from any other app.",
+ "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.",
+ "default_app_label": "Default mail app",
+ "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.",
+ "default_app_is_default": "This app opens your mail links.",
+ "default_app_button": "Choose default app…",
+ "new_mail": "New mail",
+ "no_subject": "(no subject)",
+ "action_delete": "Delete",
+ "copy_as_file": "Copy as file",
+ "copied": "File copied. You can paste it into a folder.",
+ "copy_failed": "The file could not be copied.",
+ "reply_lost": "Your typed reply could not be kept. Open the message and reply there."
+ },
"desktop_sign_in": {
"browser_button": "Sign in with your browser",
"browser_waiting": "Finish signing in in your browser",
diff --git a/locales/es/common.json b/locales/es/common.json
index 5a2af15a4..217aecbe4 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Este mensaje se movió a {folder}.",
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
},
+ "desktopOs": {
+ "section_title": "Aplicación de escritorio",
+ "section_description": "Cómo funciona la aplicación con tu ordenador.",
+ "close_to_tray_label": "Seguir en la bandeja al cerrar",
+ "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.",
+ "start_at_login_label": "Iniciar al entrar en el ordenador",
+ "start_at_login_description": "La aplicación se inicia sola cuando entras.",
+ "start_minimized_label": "Iniciar en la bandeja",
+ "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.",
+ "attention_label": "Hacer parpadear la barra de tareas con correo nuevo",
+ "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo",
+ "attention_description": "Solo cuando la aplicación no está delante.",
+ "quiet_hours_label": "Horas de silencio",
+ "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado",
+ "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.",
+ "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.",
+ "default_app_label": "Aplicación de correo predeterminada",
+ "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.",
+ "default_app_is_default": "Esta aplicación abre tus enlaces de correo.",
+ "default_app_button": "Elegir aplicación predeterminada…",
+ "new_mail": "Correo nuevo",
+ "no_subject": "(sin asunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como archivo",
+ "copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
+ "copy_failed": "No se pudo copiar el archivo.",
+ "reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí."
+ },
"desktop_sign_in": {
"browser_button": "Iniciar sesión con el navegador",
"browser_waiting": "Termina de iniciar sesión en el navegador",
diff --git a/locales/et/common.json b/locales/et/common.json
index b5ce8e291..82feaa2d2 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "See kiri teisaldati kausta {folder}.",
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
},
+ "desktopOs": {
+ "section_title": "Töölauarakendus",
+ "section_description": "Kuidas rakendus sinu arvutiga koos töötab.",
+ "close_to_tray_label": "Sulgemisel jätka salves",
+ "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.",
+ "start_at_login_label": "Käivita arvutisse sisselogimisel",
+ "start_at_login_description": "Rakendus käivitub sisselogimisel ise.",
+ "start_minimized_label": "Käivita salves",
+ "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.",
+ "attention_label": "Vilguta tegumiriba uue kirja korral",
+ "attention_mac_label": "Põrgata Docki ikooni uue kirja korral",
+ "attention_description": "Ainult siis, kui rakendus pole esiplaanil.",
+ "quiet_hours_label": "Vaikne aeg",
+ "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.",
+ "quiet_hours_from": "Alates",
+ "quiet_hours_to": "kuni",
+ "show_shortcut_label": "Näita rakendust kiirklahviga",
+ "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.",
+ "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.",
+ "default_app_label": "Vaikimisi e-posti rakendus",
+ "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.",
+ "default_app_is_default": "See rakendus avab sinu e-posti lingid.",
+ "default_app_button": "Vali vaikerakendus…",
+ "new_mail": "Uus kiri",
+ "no_subject": "(teema puudub)",
+ "action_delete": "Kustuta",
+ "copy_as_file": "Kopeeri failina",
+ "copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
+ "copy_failed": "Faili ei saanud kopeerida.",
+ "reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal."
+ },
"desktop_sign_in": {
"browser_button": "Logi sisse brauseriga",
"browser_waiting": "Lõpeta sisselogimine brauseris",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 22a2c1d43..1b1b3e88f 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "این پیام به {folder} منتقل شد.",
"window_not_ready": "پنجرهٔ جدید بهموقع باز نشد. پیشنویس شما همینجا میماند."
},
+ "desktopOs": {
+ "section_title": "برنامه دسکتاپ",
+ "section_description": "نحوه کار برنامه با رایانه شما.",
+ "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند",
+ "close_to_tray_description": "نامههای جدید و یادآوریها پس از بستن پنجره هم میرسند.",
+ "start_at_login_label": "هنگام ورود به رایانه شروع شود",
+ "start_at_login_description": "برنامه هنگام ورود خودش شروع میشود.",
+ "start_minimized_label": "شروع در سینی سیستم",
+ "start_minimized_description": "هنگام ورود پنجرهای باز نمیشود. با نماد سینی سیستم آن را باز کنید.",
+ "attention_label": "چشمک نوار وظیفه هنگام نامه جدید",
+ "attention_mac_label": "جهش نماد Dock هنگام نامه جدید",
+ "attention_description": "فقط وقتی برنامه در جلو نیست.",
+ "quiet_hours_label": "ساعات سکوت",
+ "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامهها همچنان میرسند.",
+ "quiet_hours_from": "از",
+ "quiet_hours_to": "تا",
+ "show_shortcut_label": "نمایش برنامه با میانبر صفحهکلید",
+ "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو میآورد.",
+ "show_shortcut_taken": "برنامه دیگری از {keys} استفاده میکند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.",
+ "default_app_label": "برنامه پیشفرض ایمیل",
+ "default_app_description": "باز کردن پیوندهای ایمیل و پروندههای .eml، .ics و .vcf با این برنامه.",
+ "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز میکند.",
+ "default_app_button": "انتخاب برنامه پیشفرض…",
+ "new_mail": "نامه جدید",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "رونوشت بهصورت پرونده",
+ "copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.",
+ "copy_failed": "رونوشت پرونده ممکن نشد.",
+ "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
+ },
"desktop_sign_in": {
"browser_button": "ورود با مرورگر",
"browser_waiting": "ورود را در مرورگر کامل کنید",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index e30800b16..864c7c969 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Ce message a été déplacé vers {folder}.",
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
},
+ "desktopOs": {
+ "section_title": "Application de bureau",
+ "section_description": "Comment l'application fonctionne avec votre ordinateur.",
+ "close_to_tray_label": "Rester dans la zone de notification à la fermeture",
+ "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.",
+ "start_at_login_label": "Démarrer à l'ouverture de session",
+ "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.",
+ "start_minimized_label": "Démarrer dans la zone de notification",
+ "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.",
+ "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier",
+ "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier",
+ "attention_description": "Seulement quand l'application n'est pas au premier plan.",
+ "quiet_hours_label": "Heures calmes",
+ "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "à",
+ "show_shortcut_label": "Afficher l'application avec un raccourci clavier",
+ "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.",
+ "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.",
+ "default_app_label": "Application de messagerie par défaut",
+ "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.",
+ "default_app_is_default": "Cette application ouvre vos liens de messagerie.",
+ "default_app_button": "Choisir l'application par défaut…",
+ "new_mail": "Nouveau courrier",
+ "no_subject": "(sans objet)",
+ "action_delete": "Supprimer",
+ "copy_as_file": "Copier en tant que fichier",
+ "copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
+ "copy_failed": "Le fichier n'a pas pu être copié.",
+ "reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci."
+ },
"desktop_sign_in": {
"browser_button": "Se connecter avec le navigateur",
"browser_waiting": "Terminez la connexion dans votre navigateur",
diff --git a/locales/he/common.json b/locales/he/common.json
index 571f30e34..8c3d28f99 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6546,6 +6546,37 @@
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
},
+ "desktopOs": {
+ "section_title": "יישום שולחן עבודה",
+ "section_description": "איך היישום עובד עם המחשב שלך.",
+ "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה",
+ "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.",
+ "start_at_login_label": "להפעיל בכניסה למחשב",
+ "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.",
+ "start_minimized_label": "להפעיל במגש המערכת",
+ "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.",
+ "attention_label": "הבהוב בשורת המשימות בדואר חדש",
+ "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש",
+ "attention_description": "רק כשהיישום לא בחזית.",
+ "quiet_hours_label": "שעות שקט",
+ "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.",
+ "quiet_hours_from": "מ-",
+ "quiet_hours_to": "עד",
+ "show_shortcut_label": "הצגת היישום בקיצור מקשים",
+ "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.",
+ "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.",
+ "default_app_label": "יישום הדואר המוגדר כברירת מחדל",
+ "default_app_description": "פתיחת קישורי דואר וקובצי .eml, .ics ו-.vcf ביישום זה.",
+ "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.",
+ "default_app_button": "בחירת יישום ברירת מחדל…",
+ "new_mail": "דואר חדש",
+ "no_subject": "(ללא נושא)",
+ "action_delete": "מחיקה",
+ "copy_as_file": "העתקה כקובץ",
+ "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
+ "copy_failed": "לא ניתן להעתיק את הקובץ.",
+ "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
+ },
"desktop_sign_in": {
"browser_button": "כניסה באמצעות הדפדפן",
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 2ab59129e..60c3ee5f3 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
},
+ "desktopOs": {
+ "section_title": "Asztali alkalmazás",
+ "section_description": "Hogyan működik az alkalmazás a számítógépével.",
+ "close_to_tray_label": "Bezáráskor fusson tovább a tálcán",
+ "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.",
+ "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor",
+ "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.",
+ "start_minimized_label": "Indítás a tálcán",
+ "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.",
+ "attention_label": "Tálca villogtatása új levélnél",
+ "attention_mac_label": "Dock-ikon ugráltatása új levélnél",
+ "attention_description": "Csak ha az alkalmazás nincs előtérben.",
+ "quiet_hours_label": "Csendes órák",
+ "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.",
+ "quiet_hours_from": "Ettől",
+ "quiet_hours_to": "eddig",
+ "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal",
+ "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.",
+ "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.",
+ "default_app_label": "Alapértelmezett levelezőalkalmazás",
+ "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.",
+ "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.",
+ "default_app_button": "Alapértelmezett alkalmazás kiválasztása…",
+ "new_mail": "Új levél",
+ "no_subject": "(nincs tárgy)",
+ "action_delete": "Törlés",
+ "copy_as_file": "Másolás fájlként",
+ "copied": "Fájl másolva. Beillesztheti egy mappába.",
+ "copy_failed": "A fájlt nem sikerült másolni.",
+ "reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott."
+ },
"desktop_sign_in": {
"browser_button": "Bejelentkezés böngészővel",
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
diff --git a/locales/it/common.json b/locales/it/common.json
index a861d3390..c7e482c6d 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
},
+ "desktopOs": {
+ "section_title": "App desktop",
+ "section_description": "Come l'app funziona con il tuo computer.",
+ "close_to_tray_label": "Resta nell'area di notifica alla chiusura",
+ "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.",
+ "start_at_login_label": "Avvia all'accesso al computer",
+ "start_at_login_description": "L'app si avvia da sola quando accedi.",
+ "start_minimized_label": "Avvia nell'area di notifica",
+ "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.",
+ "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta",
+ "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta",
+ "attention_description": "Solo quando l'app non è in primo piano.",
+ "quiet_hours_label": "Ore di silenzio",
+ "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.",
+ "quiet_hours_from": "Da",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera",
+ "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.",
+ "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.",
+ "default_app_label": "App di posta predefinita",
+ "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.",
+ "default_app_is_default": "Questa app apre i tuoi link di posta.",
+ "default_app_button": "Scegli app predefinita…",
+ "new_mail": "Nuova posta",
+ "no_subject": "(nessun oggetto)",
+ "action_delete": "Elimina",
+ "copy_as_file": "Copia come file",
+ "copied": "File copiato. Puoi incollarlo in una cartella.",
+ "copy_failed": "Impossibile copiare il file.",
+ "reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì."
+ },
"desktop_sign_in": {
"browser_button": "Accedi con il browser",
"browser_waiting": "Completa l'accesso nel browser",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 0606617f1..6927afe1a 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "このメッセージは {folder} に移動されました。",
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
},
+ "desktopOs": {
+ "section_title": "デスクトップアプリ",
+ "section_description": "アプリとパソコンの連携方法です。",
+ "close_to_tray_label": "閉じてもトレイで実行を続ける",
+ "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。",
+ "start_at_login_label": "パソコンへのサインイン時に起動",
+ "start_at_login_description": "サインインするとアプリが自動で起動します。",
+ "start_minimized_label": "トレイで起動",
+ "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。",
+ "attention_label": "新着メールでタスクバーを点滅",
+ "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる",
+ "attention_description": "アプリが前面にないときのみ。",
+ "quiet_hours_label": "おやすみ時間",
+ "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。",
+ "quiet_hours_from": "開始",
+ "quiet_hours_to": "終了",
+ "show_shortcut_label": "キーボードショートカットでアプリを表示",
+ "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。",
+ "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。",
+ "default_app_label": "既定のメールアプリ",
+ "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。",
+ "default_app_is_default": "メールのリンクはこのアプリで開きます。",
+ "default_app_button": "既定のアプリを選択…",
+ "new_mail": "新着メール",
+ "no_subject": "(件名なし)",
+ "action_delete": "削除",
+ "copy_as_file": "ファイルとしてコピー",
+ "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
+ "copy_failed": "ファイルをコピーできませんでした。",
+ "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
+ },
"desktop_sign_in": {
"browser_button": "ブラウザでサインイン",
"browser_waiting": "ブラウザでサインインを完了してください",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 7e0daa574..ad9c104f7 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
},
+ "desktopOs": {
+ "section_title": "데스크톱 앱",
+ "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.",
+ "close_to_tray_label": "닫을 때 트레이에서 계속 실행",
+ "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.",
+ "start_at_login_label": "컴퓨터에 로그인할 때 시작",
+ "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.",
+ "start_minimized_label": "트레이에서 시작",
+ "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.",
+ "attention_label": "새 메일이 오면 작업 표시줄 깜박이기",
+ "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기",
+ "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.",
+ "quiet_hours_label": "방해 금지 시간",
+ "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.",
+ "quiet_hours_from": "시작",
+ "quiet_hours_to": "종료",
+ "show_shortcut_label": "키보드 단축키로 앱 표시",
+ "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.",
+ "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.",
+ "default_app_label": "기본 메일 앱",
+ "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.",
+ "default_app_is_default": "이 앱이 메일 링크를 엽니다.",
+ "default_app_button": "기본 앱 선택…",
+ "new_mail": "새 메일",
+ "no_subject": "(제목 없음)",
+ "action_delete": "삭제",
+ "copy_as_file": "파일로 복사",
+ "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
+ "copy_failed": "파일을 복사할 수 없습니다.",
+ "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
+ },
"desktop_sign_in": {
"browser_button": "브라우저로 로그인",
"browser_waiting": "브라우저에서 로그인을 마치세요",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 05939fbc0..a5295665f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
},
+ "desktopOs": {
+ "section_title": "Darbvirsmas lietotne",
+ "section_description": "Kā lietotne darbojas ar jūsu datoru.",
+ "close_to_tray_label": "Aizverot turpināt darbu teknē",
+ "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.",
+ "start_at_login_label": "Sākt, piesakoties datorā",
+ "start_at_login_description": "Lietotne sākas pati, kad piesakāties.",
+ "start_minimized_label": "Sākt teknē",
+ "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.",
+ "attention_label": "Mirgot uzdevumjoslā, pienākot pastam",
+ "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam",
+ "attention_description": "Tikai tad, ja lietotne nav priekšplānā.",
+ "quiet_hours_label": "Klusuma stundas",
+ "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.",
+ "quiet_hours_from": "No",
+ "quiet_hours_to": "līdz",
+ "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu",
+ "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.",
+ "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.",
+ "default_app_label": "Noklusējuma pasta lietotne",
+ "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.",
+ "default_app_is_default": "Šī lietotne atver jūsu pasta saites.",
+ "default_app_button": "Izvēlēties noklusējuma lietotni…",
+ "new_mail": "Jauns pasts",
+ "no_subject": "(nav tēmas)",
+ "action_delete": "Dzēst",
+ "copy_as_file": "Kopēt kā failu",
+ "copied": "Fails nokopēts. Varat to ielīmēt mapē.",
+ "copy_failed": "Failu neizdevās nokopēt.",
+ "reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur."
+ },
"desktop_sign_in": {
"browser_button": "Pierakstīties ar pārlūku",
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index dfa942d34..7fe52244f 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
},
+ "desktopOs": {
+ "section_title": "Ширээний апп",
+ "section_description": "Апп таны компьютертэй хэрхэн ажиллах.",
+ "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах",
+ "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.",
+ "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх",
+ "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.",
+ "start_minimized_label": "Тавиур дээр эхлүүлэх",
+ "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.",
+ "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах",
+ "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх",
+ "attention_description": "Зөвхөн апп урд талд байхгүй үед.",
+ "quiet_hours_label": "Чимээгүй цаг",
+ "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.",
+ "quiet_hours_from": "Эхлэх",
+ "quiet_hours_to": "хүртэл",
+ "show_shortcut_label": "Гарын товчлолоор аппыг харуулах",
+ "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.",
+ "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.",
+ "default_app_label": "Үндсэн шуудангийн апп",
+ "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.",
+ "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.",
+ "default_app_button": "Үндсэн апп сонгох…",
+ "new_mail": "Шинэ захиа",
+ "no_subject": "(гарчиггүй)",
+ "action_delete": "Устгах",
+ "copy_as_file": "Файл болгож хуулах",
+ "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
+ "copy_failed": "Файлыг хуулж чадсангүй.",
+ "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
+ },
"desktop_sign_in": {
"browser_button": "Хөтчөөр нэвтрэх",
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index a0002acca..9dbd11a2c 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
},
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Slik samarbeider appen med datamaskinen din.",
+ "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking",
+ "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.",
+ "start_at_login_label": "Start når du logger på datamaskinen",
+ "start_at_login_description": "Appen starter av seg selv når du logger på.",
+ "start_minimized_label": "Start i systemstatusfeltet",
+ "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.",
+ "attention_label": "Blink i oppgavelinjen ved ny e-post",
+ "attention_mac_label": "La Dock-ikonet sprette ved ny e-post",
+ "attention_description": "Bare når appen ikke er foran.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en hurtigtast",
+ "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.",
+ "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.",
+ "default_app_label": "Standard e-postapp",
+ "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.",
+ "default_app_is_default": "Denne appen åpner e-postlenkene dine.",
+ "default_app_button": "Velg standardapp…",
+ "new_mail": "Ny e-post",
+ "no_subject": "(uten emne)",
+ "action_delete": "Slett",
+ "copy_as_file": "Kopier som fil",
+ "copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres.",
+ "reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der."
+ },
"desktop_sign_in": {
"browser_button": "Logg på med nettleseren",
"browser_waiting": "Fullfør påloggingen i nettleseren",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 5524a36da..ca060609f 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
},
+ "desktopOs": {
+ "section_title": "Desktop-app",
+ "section_description": "Hoe de app samenwerkt met je computer.",
+ "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak",
+ "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.",
+ "start_at_login_label": "Starten als je je aanmeldt op de computer",
+ "start_at_login_description": "De app start vanzelf als je je aanmeldt.",
+ "start_minimized_label": "Starten in het systeemvak",
+ "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.",
+ "attention_label": "Taakbalk laten knipperen bij nieuwe mail",
+ "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail",
+ "attention_description": "Alleen als de app niet vooraan staat.",
+ "quiet_hours_label": "Stille uren",
+ "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.",
+ "quiet_hours_from": "Van",
+ "quiet_hours_to": "tot",
+ "show_shortcut_label": "App tonen met een sneltoets",
+ "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.",
+ "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.",
+ "default_app_label": "Standaard-mailapp",
+ "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.",
+ "default_app_is_default": "Deze app opent je maillinks.",
+ "default_app_button": "Standaard-app kiezen…",
+ "new_mail": "Nieuwe mail",
+ "no_subject": "(geen onderwerp)",
+ "action_delete": "Verwijderen",
+ "copy_as_file": "Kopiëren als bestand",
+ "copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
+ "copy_failed": "Het bestand kon niet worden gekopieerd.",
+ "reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar."
+ },
"desktop_sign_in": {
"browser_button": "Inloggen met je browser",
"browser_waiting": "Rond het inloggen af in je browser",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index fa5b5481e..155963c34 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
},
+ "desktopOs": {
+ "section_title": "Aplikacja na komputer",
+ "section_description": "Jak aplikacja współpracuje z Twoim komputerem.",
+ "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku",
+ "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.",
+ "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera",
+ "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.",
+ "start_minimized_label": "Uruchamiaj w zasobniku",
+ "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.",
+ "attention_label": "Migaj na pasku zadań przy nowej poczcie",
+ "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie",
+ "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.",
+ "quiet_hours_label": "Godziny ciszy",
+ "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym",
+ "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.",
+ "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.",
+ "default_app_label": "Domyślna aplikacja poczty",
+ "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.",
+ "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.",
+ "default_app_button": "Wybierz domyślną aplikację…",
+ "new_mail": "Nowa poczta",
+ "no_subject": "(bez tematu)",
+ "action_delete": "Usuń",
+ "copy_as_file": "Kopiuj jako plik",
+ "copied": "Plik skopiowany. Możesz wkleić go do folderu.",
+ "copy_failed": "Nie udało się skopiować pliku.",
+ "reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej."
+ },
"desktop_sign_in": {
"browser_button": "Zaloguj się w przeglądarce",
"browser_waiting": "Dokończ logowanie w przeglądarce",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 87a2e2ff1..9940ba25c 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Esta mensagem foi movida para {folder}.",
"window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
},
+ "desktopOs": {
+ "section_title": "Aplicação de ambiente de trabalho",
+ "section_description": "Como a aplicação funciona com o seu computador.",
+ "close_to_tray_label": "Continuar na bandeja ao fechar",
+ "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.",
+ "start_at_login_label": "Iniciar ao entrar no computador",
+ "start_at_login_description": "A aplicação inicia sozinha quando entra.",
+ "start_minimized_label": "Iniciar na bandeja",
+ "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.",
+ "attention_label": "Piscar a barra de tarefas com correio novo",
+ "attention_mac_label": "Saltar o ícone da Dock com correio novo",
+ "attention_description": "Só quando a aplicação não está à frente.",
+ "quiet_hours_label": "Horas de silêncio",
+ "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "até",
+ "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado",
+ "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.",
+ "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.",
+ "default_app_label": "Aplicação de correio predefinida",
+ "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.",
+ "default_app_is_default": "Esta aplicação abre as suas ligações de correio.",
+ "default_app_button": "Escolher aplicação predefinida…",
+ "new_mail": "Correio novo",
+ "no_subject": "(sem assunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como ficheiro",
+ "copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
+ "copy_failed": "Não foi possível copiar o ficheiro.",
+ "reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela."
+ },
"desktop_sign_in": {
"browser_button": "Entrar com o navegador",
"browser_waiting": "Termine de entrar no navegador",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 80c31fa28..b12f0f293 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Acest mesaj a fost mutat în {folder}.",
"window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
},
+ "desktopOs": {
+ "section_title": "Aplicație desktop",
+ "section_description": "Cum funcționează aplicația cu computerul tău.",
+ "close_to_tray_label": "Rămâne în zona de notificări la închidere",
+ "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.",
+ "start_at_login_label": "Pornește la conectarea în computer",
+ "start_at_login_description": "Aplicația pornește singură când te conectezi.",
+ "start_minimized_label": "Pornește în zona de notificări",
+ "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.",
+ "attention_label": "Clipește în bara de activități la e-mail nou",
+ "attention_mac_label": "Sare pictograma din Dock la e-mail nou",
+ "attention_description": "Doar când aplicația nu este în față.",
+ "quiet_hours_label": "Ore de liniște",
+ "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.",
+ "quiet_hours_from": "De la",
+ "quiet_hours_to": "până la",
+ "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură",
+ "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.",
+ "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.",
+ "default_app_label": "Aplicația de e-mail implicită",
+ "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.",
+ "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.",
+ "default_app_button": "Alege aplicația implicită…",
+ "new_mail": "E-mail nou",
+ "no_subject": "(fără subiect)",
+ "action_delete": "Șterge",
+ "copy_as_file": "Copiază ca fișier",
+ "copied": "Fișier copiat. Îl poți lipi într-un dosar.",
+ "copy_failed": "Fișierul nu a putut fi copiat.",
+ "reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo."
+ },
"desktop_sign_in": {
"browser_button": "Conectează-te cu browserul",
"browser_waiting": "Finalizează conectarea în browser",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 55510f4c8..2679de84a 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Это письмо перемещено в {folder}.",
"window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
},
+ "desktopOs": {
+ "section_title": "Настольное приложение",
+ "section_description": "Как приложение работает с вашим компьютером.",
+ "close_to_tray_label": "При закрытии продолжать работу в трее",
+ "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.",
+ "start_at_login_label": "Запускать при входе в систему",
+ "start_at_login_description": "Приложение запускается само, когда вы входите.",
+ "start_minimized_label": "Запускать в трее",
+ "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.",
+ "attention_label": "Мигать на панели задач при новой почте",
+ "attention_mac_label": "Подпрыгивать значком в Dock при новой почте",
+ "attention_description": "Только когда приложение не на переднем плане.",
+ "quiet_hours_label": "Тихие часы",
+ "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.",
+ "quiet_hours_from": "С",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показывать приложение сочетанием клавиш",
+ "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.",
+ "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.",
+ "default_app_label": "Почтовое приложение по умолчанию",
+ "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.",
+ "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.",
+ "default_app_button": "Выбрать приложение по умолчанию…",
+ "new_mail": "Новая почта",
+ "no_subject": "(без темы)",
+ "action_delete": "Удалить",
+ "copy_as_file": "Копировать как файл",
+ "copied": "Файл скопирован. Его можно вставить в папку.",
+ "copy_failed": "Не удалось скопировать файл.",
+ "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
+ },
"desktop_sign_in": {
"browser_button": "Войти через браузер",
"browser_waiting": "Завершите вход в браузере",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 9e12b12d2..8d23419cf 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Táto správa bola presunutá do {folder}.",
"window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
},
+ "desktopOs": {
+ "section_title": "Počítačová aplikácia",
+ "section_description": "Ako aplikácia spolupracuje s vaším počítačom.",
+ "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení",
+ "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.",
+ "start_at_login_label": "Spustiť po prihlásení do počítača",
+ "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.",
+ "start_minimized_label": "Spustiť v oblasti oznámení",
+ "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.",
+ "attention_label": "Pri novej pošte blikať na paneli úloh",
+ "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku",
+ "attention_description": "Len keď aplikácia nie je v popredí.",
+ "quiet_hours_label": "Čas pokoja",
+ "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou",
+ "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.",
+ "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.",
+ "default_app_label": "Predvolená poštová aplikácia",
+ "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.",
+ "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.",
+ "default_app_button": "Vybrať predvolenú aplikáciu…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez predmetu)",
+ "action_delete": "Odstrániť",
+ "copy_as_file": "Kopírovať ako súbor",
+ "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
+ "copy_failed": "Súbor sa nepodarilo skopírovať.",
+ "reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej."
+ },
"desktop_sign_in": {
"browser_button": "Prihlásiť sa v prehliadači",
"browser_waiting": "Dokončite prihlásenie v prehliadači",
diff --git a/locales/th/common.json b/locales/th/common.json
index 0be3db9db..0f81a68cb 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6553,6 +6553,37 @@
"message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}",
"window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่"
},
+ "desktopOs": {
+ "section_title": "แอปเดสก์ท็อป",
+ "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ",
+ "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด",
+ "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง",
+ "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์",
+ "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้",
+ "start_minimized_label": "เริ่มในถาดระบบ",
+ "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ",
+ "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่",
+ "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่",
+ "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า",
+ "quiet_hours_label": "ช่วงเวลาเงียบ",
+ "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ",
+ "quiet_hours_from": "จาก",
+ "quiet_hours_to": "ถึง",
+ "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด",
+ "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้",
+ "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง",
+ "default_app_label": "แอปเมลเริ่มต้น",
+ "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้",
+ "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ",
+ "default_app_button": "เลือกแอปเริ่มต้น…",
+ "new_mail": "เมลใหม่",
+ "no_subject": "(ไม่มีหัวเรื่อง)",
+ "action_delete": "ลบ",
+ "copy_as_file": "คัดลอกเป็นไฟล์",
+ "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
+ "copy_failed": "คัดลอกไฟล์ไม่ได้",
+ "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
+ },
"desktop_sign_in": {
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index fa74d9864..a9e5517e7 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Bu ileti {folder} klasörüne taşındı.",
"window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor."
},
+ "desktopOs": {
+ "section_title": "Masaüstü uygulaması",
+ "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.",
+ "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et",
+ "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.",
+ "start_at_login_label": "Bilgisayarda oturum açınca başlat",
+ "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.",
+ "start_minimized_label": "Sistem tepsisinde başlat",
+ "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.",
+ "attention_label": "Yeni postada görev çubuğunu yanıp söndür",
+ "attention_mac_label": "Yeni postada Dock simgesini zıplat",
+ "attention_description": "Yalnızca uygulama önde değilken.",
+ "quiet_hours_label": "Sessiz saatler",
+ "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.",
+ "quiet_hours_from": "Başlangıç",
+ "quiet_hours_to": "bitiş",
+ "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster",
+ "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.",
+ "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.",
+ "default_app_label": "Varsayılan posta uygulaması",
+ "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.",
+ "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.",
+ "default_app_button": "Varsayılan uygulamayı seç…",
+ "new_mail": "Yeni posta",
+ "no_subject": "(konu yok)",
+ "action_delete": "Sil",
+ "copy_as_file": "Dosya olarak kopyala",
+ "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
+ "copy_failed": "Dosya kopyalanamadı.",
+ "reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın."
+ },
"desktop_sign_in": {
"browser_button": "Tarayıcıyla oturum aç",
"browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 391a5c203..f393aaf5a 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "Цього листа переміщено в {folder}.",
"window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут."
},
+ "desktopOs": {
+ "section_title": "Настільний застосунок",
+ "section_description": "Як застосунок працює з вашим комп’ютером.",
+ "close_to_tray_label": "Під час закриття працювати далі в треї",
+ "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.",
+ "start_at_login_label": "Запускати під час входу в систему",
+ "start_at_login_description": "Застосунок запускається сам, коли ви входите.",
+ "start_minimized_label": "Запускати в треї",
+ "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.",
+ "attention_label": "Блимати на панелі завдань за нової пошти",
+ "attention_mac_label": "Підстрибувати значком у Dock за нової пошти",
+ "attention_description": "Лише коли застосунок не на передньому плані.",
+ "quiet_hours_label": "Тихі години",
+ "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.",
+ "quiet_hours_from": "З",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показувати застосунок сполученням клавіш",
+ "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.",
+ "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.",
+ "default_app_label": "Поштовий застосунок за замовчуванням",
+ "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.",
+ "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.",
+ "default_app_button": "Вибрати застосунок за замовчуванням…",
+ "new_mail": "Нова пошта",
+ "no_subject": "(без теми)",
+ "action_delete": "Видалити",
+ "copy_as_file": "Копіювати як файл",
+ "copied": "Файл скопійовано. Його можна вставити в папку.",
+ "copy_failed": "Не вдалося скопіювати файл.",
+ "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
+ },
"desktop_sign_in": {
"browser_button": "Увійти через браузер",
"browser_waiting": "Завершіть вхід у браузері",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 347fe2cd0..4d83b90c8 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6557,6 +6557,37 @@
"message_moved_to": "Thư này đã được chuyển đến {folder}.",
"window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây."
},
+ "desktopOs": {
+ "section_title": "Ứng dụng máy tính",
+ "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.",
+ "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng",
+ "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.",
+ "start_at_login_label": "Khởi động khi đăng nhập vào máy tính",
+ "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.",
+ "start_minimized_label": "Khởi động ở khay hệ thống",
+ "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.",
+ "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới",
+ "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới",
+ "attention_description": "Chỉ khi ứng dụng không ở phía trước.",
+ "quiet_hours_label": "Giờ yên tĩnh",
+ "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.",
+ "quiet_hours_from": "Từ",
+ "quiet_hours_to": "đến",
+ "show_shortcut_label": "Hiện ứng dụng bằng phím tắt",
+ "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.",
+ "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.",
+ "default_app_label": "Ứng dụng thư mặc định",
+ "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.",
+ "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.",
+ "default_app_button": "Chọn ứng dụng mặc định…",
+ "new_mail": "Thư mới",
+ "no_subject": "(không có chủ đề)",
+ "action_delete": "Xóa",
+ "copy_as_file": "Sao chép dưới dạng tệp",
+ "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
+ "copy_failed": "Không thể sao chép tệp.",
+ "reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó."
+ },
"desktop_sign_in": {
"browser_button": "Đăng nhập bằng trình duyệt",
"browser_waiting": "Hoàn tất đăng nhập trong trình duyệt",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 97dacbb06..ecd9e0cce 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "這封郵件已移到 {folder}。",
"window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。"
},
+ "desktopOs": {
+ "section_title": "桌面應用程式",
+ "section_description": "應用程式如何與您的電腦配合。",
+ "close_to_tray_label": "關閉時繼續在系統匣中執行",
+ "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。",
+ "start_at_login_label": "登入電腦時啟動",
+ "start_at_login_description": "您登入時應用程式會自動啟動。",
+ "start_minimized_label": "在系統匣中啟動",
+ "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。",
+ "attention_label": "有新郵件時閃爍工作列",
+ "attention_mac_label": "有新郵件時讓 Dock 圖示跳動",
+ "attention_description": "僅在應用程式不在前景時。",
+ "quiet_hours_label": "勿擾時段",
+ "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。",
+ "quiet_hours_from": "從",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式",
+ "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。",
+ "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。",
+ "default_app_label": "預設郵件應用程式",
+ "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。",
+ "default_app_is_default": "此應用程式會開啟您的郵件連結。",
+ "default_app_button": "選擇預設應用程式…",
+ "new_mail": "新郵件",
+ "no_subject": "(無主旨)",
+ "action_delete": "刪除",
+ "copy_as_file": "複製為檔案",
+ "copied": "檔案已複製。您可以將其貼到資料夾中。",
+ "copy_failed": "無法複製檔案。",
+ "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
+ },
"desktop_sign_in": {
"browser_button": "使用瀏覽器登入",
"browser_waiting": "請在瀏覽器中完成登入",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index de9e40f3c..5650920a2 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6545,6 +6545,37 @@
"message_moved_to": "这封邮件已移动到 {folder}。",
"window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。"
},
+ "desktopOs": {
+ "section_title": "桌面应用",
+ "section_description": "应用如何与你的电脑配合。",
+ "close_to_tray_label": "关闭时继续在托盘中运行",
+ "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。",
+ "start_at_login_label": "登录电脑时启动",
+ "start_at_login_description": "你登录时应用会自动启动。",
+ "start_minimized_label": "在托盘中启动",
+ "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。",
+ "attention_label": "有新邮件时闪烁任务栏",
+ "attention_mac_label": "有新邮件时让 Dock 图标跳动",
+ "attention_description": "仅在应用不在前台时。",
+ "quiet_hours_label": "免打扰时段",
+ "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。",
+ "quiet_hours_from": "从",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用键盘快捷键显示应用",
+ "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。",
+ "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。",
+ "default_app_label": "默认邮件应用",
+ "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。",
+ "default_app_is_default": "此应用会打开你的邮件链接。",
+ "default_app_button": "选择默认应用…",
+ "new_mail": "新邮件",
+ "no_subject": "(无主题)",
+ "action_delete": "删除",
+ "copy_as_file": "复制为文件",
+ "copied": "文件已复制。你可以将其粘贴到文件夹中。",
+ "copy_failed": "无法复制文件。",
+ "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
+ },
"desktop_sign_in": {
"browser_button": "使用浏览器登录",
"browser_waiting": "请在浏览器中完成登录",
From 4fd5c8c1aa3f054a900217f3fe38716362588ec1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:25:50 +0100
Subject: [PATCH 07/24] feat: keep a record of calendar reminders in the local
store, so each fires once and missed ones can be summed up
---
lib/store/__tests__/reminders.test.ts | 393 ++++++++++++++++++++++++++
lib/store/areas.ts | 3 +-
lib/store/calendar/reminders.ts | 38 ++-
lib/store/reminders/index.ts | 20 ++
lib/store/reminders/ledger.ts | 229 +++++++++++++++
lib/store/reminders/schema.ts | 20 ++
6 files changed, 699 insertions(+), 4 deletions(-)
create mode 100644 lib/store/__tests__/reminders.test.ts
create mode 100644 lib/store/reminders/index.ts
create mode 100644 lib/store/reminders/ledger.ts
create mode 100644 lib/store/reminders/schema.ts
diff --git a/lib/store/__tests__/reminders.test.ts b/lib/store/__tests__/reminders.test.ts
new file mode 100644
index 000000000..16cf60d2f
--- /dev/null
+++ b/lib/store/__tests__/reminders.test.ts
@@ -0,0 +1,393 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_SCOPE } from '../area';
+import { calendarArea } from '../calendar';
+import { AccountEngine } from '../engine/account-engine';
+import { remindersArea } from '../reminders';
+import { FRESH_MS, MISSED_REACH_MS, type MissedBatch, type TickResult } from '../reminders/ledger';
+import type { StorageAdapter } from '../storage';
+import { CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar';
+import { openMemoryStore } from './helpers/node-adapter';
+
+const MIN = 60_000;
+/** 10:00 in Berlin on 2026-11-10, a Tuesday. The events' alert goes 15 minutes before. */
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' };
+const PARAMS = { timeZone: 'Europe/Berlin', tasks: true };
+
+interface Rig {
+ server: FakeCalendarJmap;
+ db: StorageAdapter;
+ engine: AccountEngine;
+ now: number;
+ /** What a tab, a window or the next start of the app asks. */
+ tick(params?: Partial & { muted?: boolean }): Promise;
+ call(name: string, params?: unknown): Promise;
+ restart(): void;
+}
+
+function makeEngine(rig: Pick): AccountEngine {
+ return new AccountEngine({
+ account: 'a@fake.test',
+ db: rig.db,
+ session: CALENDAR_SESSION,
+ transport: rig.server,
+ areas: [calendarArea, remindersArea],
+ scope: DEFAULT_SCOPE,
+ now: () => rig.now,
+ sleep: async () => {},
+ });
+}
+
+function setup(version: StalwartVersion = '1.0', startAt = FIRE - 60 * MIN): Rig {
+ const rig = {
+ server: new FakeCalendarJmap(version),
+ db: openMemoryStore(),
+ now: startAt,
+ } as Rig;
+ rig.engine = makeEngine(rig);
+ rig.call = (name: string, params?: unknown) => rig.engine.areaCall(name, params) as Promise;
+ rig.tick = (params = {}) => rig.call('reminders.tick', { ...PARAMS, ...params });
+ rig.restart = () => {
+ rig.engine = makeEngine(rig);
+ };
+ return rig;
+}
+
+/** Synced, with a first check done, so the store has been watching since before the alert. */
+async function synced(rig: Rig): Promise {
+ await rig.engine.requestSync();
+ await rig.tick();
+ return rig;
+}
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+/** Comes back every minute or so, with no gap that would look like being away. */
+async function walkTo(rig: Rig, to: number): Promise {
+ const due: TickResult['due'] = [];
+ while (rig.now < to) {
+ rig.now = Math.min(rig.now + MIN, to);
+ due.push(...(await rig.tick()).due);
+ }
+ return due;
+}
+
+describe('firing', () => {
+ it('fires from what is stored with the network gone', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.engine.setOnline(false);
+ rig.server.request = (() => {
+ throw new Error('Nothing may reach the server for a reminder');
+ }) as unknown as FakeCalendarJmap['request'];
+ const due = await walkTo(rig, FIRE);
+ expect(due.map((alert) => [alert.title, alert.fireTimeMs, alert.startMs])).toEqual([['Standup', FIRE, START]]);
+ });
+
+ it('does not fire early, and says when to ask next', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.now = FIRE - 90_000;
+ const before = await rig.tick();
+ expect(before.due).toEqual([]);
+ expect(before.nextAt).toBe(FIRE);
+ });
+
+ it('gives an alert to one caller only, across tabs and restarts', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.now = FIRE - MIN;
+ await rig.tick();
+ rig.now = FIRE;
+ const [first, second] = await Promise.all([rig.tick(), rig.tick()]);
+ expect([first.due.length, second.due.length]).toEqual([1, 0]);
+ rig.restart();
+ rig.now = FIRE + MIN;
+ expect((await rig.tick()).due).toEqual([]);
+ });
+
+ it('expands a series itself: every occurrence fires, under its own key', async () => {
+ for (const version of ['1.0', '0.16'] as const) {
+ const rig = setup(version);
+ rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY }));
+ await synced(rig);
+ const first = await walkTo(rig, FIRE);
+ const week = 7 * 24 * 60 * MIN;
+ rig.now = FIRE + week - MIN;
+ await rig.tick();
+ await rig.tick();
+ rig.now = FIRE + week;
+ const second = (await rig.tick()).due;
+ expect(first.map((alert) => alert.fireTimeMs)).toEqual([FIRE]);
+ expect(second.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]);
+ expect(second[0].baseEventId).toBe('e1');
+ expect(second[0].key).not.toBe(first[0].key);
+ expect(second[0].recurrenceId).toBeTruthy();
+ }
+ });
+
+ it('keeps one key when a series gets an override, so nothing fires twice', async () => {
+ const rig = setup();
+ rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY }));
+ await synced(rig);
+ const [before] = await walkTo(rig, FIRE);
+ // Another occurrence changes. On a server with synthetic ids this reshuffles them.
+ rig.server.patchEvent('e1', { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'Other' } } });
+ expect(before.key).toBe(`${before.jmap}:uid-1:a:${FIRE}`);
+ await rig.engine.requestSync();
+ rig.now = FIRE + MIN;
+ expect((await rig.tick()).due).toEqual([]);
+ });
+
+ it('reads `acknowledged` on a series as covering what fired up to then', async () => {
+ const rig = setup();
+ const week = 7 * 24 * 60 * MIN;
+ rig.server.addEvent(event({
+ start: '2026-10-06T10:00:00',
+ recurrenceRule: WEEKLY,
+ alerts: { a: { ...ALERT, acknowledged: new Date(FIRE + 60_000).toISOString() } },
+ }));
+ await synced(rig);
+ expect(await walkTo(rig, FIRE + 2 * MIN)).toEqual([]);
+ rig.now = FIRE + week - MIN;
+ await rig.tick();
+ await rig.tick();
+ rig.now = FIRE + week;
+ expect((await rig.tick()).due.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]);
+ });
+
+ it('uses the calendar defaults while the event does, and leaves tasks out when asked', async () => {
+ const rig = setup('0.16');
+ rig.server.calendars.get('cal1')!.defaultAlertsWithTime = { d1: ALERT };
+ rig.server.addEvent(event({ id: 'def', uid: 'def', useDefaultAlerts: true, alerts: null }));
+ rig.server.addEvent({ id: 'task', uid: 'task', '@type': 'Task', title: 'Pay', start: undefined, due: '2026-11-10T09:00:00Z', alerts: { a: ALERT } });
+ await rig.engine.requestSync();
+ rig.now = FIRE - MIN;
+ await rig.tick({ tasks: false });
+ rig.now = FIRE;
+ const due = (await rig.tick({ tasks: false })).due;
+ expect(due.map((alert) => [alert.baseEventId, alert.alertId])).toEqual([['def', 'd1']]);
+ });
+
+ it('holds a join link only for an event that has one', async () => {
+ const rig = setup();
+ rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }));
+ rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Lunch', locations: { l: { '@type': 'Location', name: 'Cafe' } } }));
+ await synced(rig);
+ const due = await walkTo(rig, FIRE);
+ expect(Object.fromEntries(due.map((alert) => [alert.title, alert.joinUrl]))).toEqual({
+ Standup: 'https://meet.jit.si/standup',
+ Lunch: null,
+ });
+ });
+});
+
+describe('away', () => {
+ async function awayWithThree(): Promise {
+ const rig = setup();
+ rig.server.addEvent(event());
+ rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T11:00:00' }));
+ rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Lunch', start: '2026-11-10T12:00:00' }));
+ await synced(rig);
+ rig.now = FIRE - MIN;
+ await rig.tick();
+ return rig;
+ }
+
+ it('sends what came due while away to one summary, not a burst, and only once', async () => {
+ const rig = await awayWithThree();
+ rig.restart();
+ rig.now = FIRE + 3 * 60 * MIN;
+ const back = await rig.tick();
+ expect(back.due).toEqual([]);
+ expect(back.missed).toBe(3);
+ const [one, two] = await Promise.all([
+ rig.call('reminders.takeMissed', PARAMS),
+ rig.call('reminders.takeMissed', PARAMS),
+ ]);
+ expect(one!.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Review', 'Lunch']);
+ expect(two).toBeNull();
+ rig.restart();
+ rig.now += MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 0 });
+ expect(await rig.call('reminders.takeMissed', PARAMS)).toBeNull();
+ });
+
+ it('summarises the ones within ten minutes as well: a wake-up is no time for a burst', async () => {
+ const rig = await awayWithThree();
+ rig.now = START + 2 * 60 * MIN - 15 * MIN + 2 * MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 3 });
+ });
+
+ it('shows a single alert that just came due as a normal reminder', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.now = FIRE - MIN;
+ await rig.tick();
+ rig.now = FIRE + 5 * MIN;
+ expect(await rig.tick()).toMatchObject({ due: [{ title: 'Standup' }], missed: 0 });
+ });
+
+ it('summarises a single alert that is long past', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.now = FIRE - MIN;
+ await rig.tick();
+ rig.now = FIRE + FRESH_MS + MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 1 });
+ });
+
+ it('counts nothing as missed on the very first check, however old the calendar', async () => {
+ const rig = setup('1.0', FIRE + 60 * MIN);
+ rig.server.addEvent(event());
+ await rig.engine.requestSync();
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 0 });
+ });
+
+ it('reaches back a week at most', async () => {
+ const rig = await awayWithThree();
+ rig.now = FIRE + MISSED_REACH_MS + 3 * 60 * MIN;
+ expect((await rig.tick()).missed).toBe(0);
+ });
+
+ it('leaves out what was deleted while away', async () => {
+ const rig = await awayWithThree();
+ rig.now = FIRE + 3 * 60 * MIN;
+ expect((await rig.tick()).missed).toBe(3);
+ rig.server.removeEvent('e2');
+ await rig.engine.requestSync();
+ const batch = await rig.call('reminders.takeMissed', PARAMS);
+ expect(batch.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Lunch']);
+ });
+
+ it('puts a summary that could not be shown back, and reads a shown one again for a late click', async () => {
+ const rig = await awayWithThree();
+ rig.now = FIRE + 3 * 60 * MIN;
+ await rig.tick();
+ const refused = await rig.call('reminders.takeMissed', PARAMS);
+ await rig.call('reminders.releaseMissed', { batch: refused.batch });
+ expect((await rig.tick()).missed).toBe(3);
+ const shown = await rig.call('reminders.takeMissed', PARAMS);
+ expect(shown.alerts).toHaveLength(3);
+ rig.restart();
+ const read = await rig.call('reminders.batch', { batch: shown.batch });
+ expect(read).toHaveLength(3);
+ await rig.call('reminders.dismiss', { keys: shown.alerts.map((alert) => alert.key) });
+ expect(await rig.call('reminders.batch', { batch: shown.batch })).toEqual([]);
+ });
+
+ it('moves a fired alert nobody could be told about to the summary', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ const [fired] = await walkTo(rig, FIRE);
+ await rig.call('reminders.hold', { keys: [fired.key] });
+ rig.now += MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 1 });
+ });
+
+ it('keeps quiet while reminders are off, and nothing from that time is missed later', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ rig.now = FIRE - MIN;
+ await rig.tick();
+ rig.now = FIRE + 60 * MIN;
+ expect(await rig.tick({ muted: true })).toMatchObject({ due: [], missed: 0, nextAt: null });
+ rig.now += MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 0 });
+ });
+});
+
+describe('snooze', () => {
+ async function fired(version: StalwartVersion = '1.0') {
+ const rig = setup(version);
+ rig.server.addEvent(event());
+ await synced(rig);
+ const [alert] = await walkTo(rig, FIRE);
+ return { rig, alert };
+ }
+
+ it('comes back after the minutes asked for, once, and survives a restart', async () => {
+ const { rig, alert } = await fired();
+ expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 5 })).toBe(true);
+ rig.now = FIRE + 2 * MIN;
+ const during = await rig.tick();
+ expect(during.due).toEqual([]);
+ expect(during.nextAt).toBe(FIRE + 5 * MIN);
+
+ rig.restart();
+ rig.now = FIRE + 4 * MIN;
+ expect((await rig.tick()).due).toEqual([]);
+ rig.now = FIRE + 5 * MIN;
+ const back = (await rig.tick()).due;
+ expect(back.map((a) => [a.title, a.snoozedUntil])).toEqual([['Standup', FIRE + 5 * MIN]]);
+ rig.now += MIN;
+ expect((await rig.tick()).due).toEqual([]);
+ });
+
+ it('can be snoozed again', async () => {
+ const { rig, alert } = await fired();
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 5 });
+ rig.now = FIRE + 5 * MIN;
+ expect((await rig.tick()).due).toHaveLength(1);
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 10 });
+ rig.now = FIRE + 15 * MIN;
+ const back = (await rig.tick()).due;
+ expect(back[0].snoozedUntil).toBe(FIRE + 15 * MIN);
+ });
+
+ it('goes to the summary when it ran out while the app was quit', async () => {
+ const { rig, alert } = await fired();
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 5 });
+ rig.restart();
+ rig.now = FIRE + 3 * 60 * MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 1 });
+ });
+
+ it('does not come back for an event deleted in the meantime', async () => {
+ const { rig, alert } = await fired();
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 5 });
+ rig.server.removeEvent('e1');
+ await rig.engine.requestSync();
+ rig.now = FIRE + 5 * MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], missed: 0 });
+ });
+
+ it('does not come back once dismissed in another window', async () => {
+ const { rig, alert } = await fired();
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 5 });
+ await rig.call('reminders.dismiss', { keys: [alert.key] });
+ rig.now = FIRE + 5 * MIN;
+ expect(await rig.tick()).toMatchObject({ due: [], nextAt: null });
+ });
+
+ it('refuses an unknown reminder and silly minutes', async () => {
+ const { rig, alert } = await fired();
+ expect(await rig.call('reminders.snooze', { key: 'nope', minutes: 5 })).toBe(false);
+ expect(await rig.call('reminders.snooze', { key: alert.key, minutes: -1 })).toBe(false);
+ expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 99_999 })).toBe(false);
+ });
+});
+
+it('keeps reminders off the server', async () => {
+ const rig = setup();
+ rig.server.addEvent(event());
+ await synced(rig);
+ const before = JSON.stringify(rig.server.events.get('e1'));
+ const [alert] = await walkTo(rig, FIRE);
+ await rig.call('reminders.snooze', { key: alert.key, minutes: 5 });
+ await rig.call('reminders.dismiss', { keys: [alert.key] });
+ await rig.engine.requestSync();
+ expect(JSON.stringify(rig.server.events.get('e1'))).toBe(before);
+});
diff --git a/lib/store/areas.ts b/lib/store/areas.ts
index 08fe2c607..c17d15b7f 100644
--- a/lib/store/areas.ts
+++ b/lib/store/areas.ts
@@ -4,5 +4,6 @@ import type { StoreArea } from './area';
import { calendarArea } from './calendar';
import { mailArea } from './mail';
import { outboxArea } from './outbox';
+import { remindersArea } from './reminders';
-export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea];
+export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, remindersArea];
diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts
index b42307346..e293d1618 100644
--- a/lib/store/calendar/reminders.ts
+++ b/lib/store/calendar/reminders.ts
@@ -12,6 +12,7 @@ import { format, parseISO } from 'date-fns';
import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts';
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization';
+import { findMeetingLink, type EventPlaces } from '@/lib/event-links';
import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { listObjects } from '../engine/core-schema';
@@ -22,6 +23,8 @@ export interface UpcomingAlert {
/** Stable for this alert at this time. Use it to remember what was shown. */
key: string;
jmap: string;
+ /** The series' uid, or the id where there's none: the same one whichever path shows the reminder. */
+ uid: string;
kind: 'event' | 'task';
/** The stored event or task: the master of a series. */
baseEventId: string;
@@ -32,6 +35,10 @@ export interface UpcomingAlert {
title: string;
/** The start as a wall clock, or a task's due when it has no start. */
start: string | null;
+ /** The start as an instant, to say how long until it. Null when it can't be told. */
+ startMs: number | null;
+ /** Joins the meeting, when the event has a video link. */
+ joinUrl: string | null;
timeZone: string | null;
calendarId: string | null;
calendarName: string | null;
@@ -82,6 +89,24 @@ function withInstants(object: CalendarObject, zone: string): CalendarObject {
};
}
+/**
+ * RFC 8984 4.5.2: on a series `acknowledged` sits on the master and covers what fired
+ * up to then. Read as "never again", one dismissal in another app would silence every
+ * later occurrence. An event once is done with it either way.
+ */
+function acknowledgedAlready(alert: CalendarEventAlert, fireTimeMs: number, series: boolean): boolean {
+ if (!alert.acknowledged) return false;
+ if (!series) return true;
+ const at = new Date(alert.acknowledged).getTime();
+ return Number.isNaN(at) || fireTimeMs <= at;
+}
+
+function instant(value: string | null | undefined): number | null {
+ if (!value) return null;
+ const ms = new Date(value).getTime();
+ return Number.isNaN(ms) ? null : ms;
+}
+
function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } {
const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null;
return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null };
@@ -101,7 +126,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp
const object = JSON.parse(row.json) as CalendarObject;
const calendar = firstCalendar(object.calendarIds, calendars);
// With the account: Stalwart's ids repeat across accounts, and one meeting can be in two.
- const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`;
+ const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id;
+ const stableId = `${row.jmap}:${uid}`;
if (isTaskLikeObject(object)) {
const task = object as unknown as CalendarTask;
@@ -113,6 +139,7 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp
add({
key: buildAlertKey(stableId, alertId, fireTimeMs),
jmap: row.jmap,
+ uid,
kind: 'task',
baseEventId: object.id,
recurrenceId: null,
@@ -120,6 +147,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp
fireTimeMs,
title: task.title ?? '',
start: task.start ?? task.due ?? null,
+ startMs: instant(task.start ?? task.due),
+ joinUrl: findMeetingLink(task as unknown as EventPlaces)?.uri ?? null,
timeZone: task.timeZone ?? null,
calendarId: calendar.id,
calendarName: calendar.name,
@@ -138,13 +167,14 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp
if (occurrence.status === 'cancelled') continue;
const isMasterOfSeries = series && occurrence === master;
for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) {
- if (alert.action !== 'display' || alert.acknowledged) continue;
+ if (alert.action !== 'display') continue;
if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue;
const fireTimeMs = computeFireTime(occurrence, alert.trigger);
- if (fireTimeMs === null) continue;
+ if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue;
add({
key: buildAlertKey(stableId, alertId, fireTimeMs),
jmap: row.jmap,
+ uid,
kind: 'event',
baseEventId: object.id,
recurrenceId: occurrence.recurrenceId ?? null,
@@ -152,6 +182,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp
fireTimeMs,
title: occurrence.title ?? '',
start: occurrence.start,
+ startMs: instant(occurrence.utcStart) ?? instant(occurrence.start),
+ joinUrl: findMeetingLink(occurrence as unknown as EventPlaces)?.uri ?? null,
timeZone: occurrence.timeZone ?? null,
calendarId: calendar.id,
calendarName: calendar.name,
diff --git a/lib/store/reminders/index.ts b/lib/store/reminders/index.ts
new file mode 100644
index 000000000..337a16b38
--- /dev/null
+++ b/lib/store/reminders/index.ts
@@ -0,0 +1,20 @@
+import type { StoreArea } from '../area';
+import { dismiss, holdFired, readBatch, readReminder, releaseMissed, snooze, takeMissed, tick, type TickParams } from './ledger';
+import { REMINDER_MIGRATIONS } from './schema';
+
+export const remindersArea: StoreArea = {
+ name: 'reminders',
+ migrations: REMINDER_MIGRATIONS,
+ methods: {},
+ syncers: [],
+ calls: {
+ 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), params),
+ 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), params),
+ 'reminders.releaseMissed': (ctx, params: { batch: number }) => releaseMissed(ctx.db, ctx.now(), params.batch),
+ 'reminders.batch': (ctx, params: { batch: number }) => readBatch(ctx.db, params.batch),
+ 'reminders.hold': (ctx, params: { keys: string[] }) => holdFired(ctx.db, ctx.now(), params.keys),
+ 'reminders.snooze': (ctx, params: { key: string; minutes: number }) => snooze(ctx.db, ctx.now(), params.key, params.minutes),
+ 'reminders.dismiss': (ctx, params: { keys: string[] }) => dismiss(ctx.db, ctx.now(), params.keys),
+ 'reminders.get': (ctx, params: { key: string }) => readReminder(ctx.db, params.key),
+ },
+};
diff --git a/lib/store/reminders/ledger.ts b/lib/store/reminders/ledger.ts
new file mode 100644
index 000000000..3f82a4fb6
--- /dev/null
+++ b/lib/store/reminders/ledger.ts
@@ -0,0 +1,229 @@
+/**
+ * What has become of each reminder on this device: the one place that decides what
+ * fires. Every call is one synchronous transaction in the store's worker, and all tabs
+ * and windows reach the same worker, so whichever asks first gets an alert and nobody
+ * else ever does, across restarts too. localStorage can't do this: a worker can't read
+ * it, and two tabs can read it at the same moment.
+ *
+ * A row's status:
+ * - `fired`: shown, nothing more to do.
+ * - `snoozed`: comes back at `snooze_until`.
+ * - `missed`: waits for a summary, because the device was away or notifications were
+ * paused.
+ * - `summarised`: went into the summary `batch`.
+ * - `dismissed`: the user is done with it.
+ *
+ * None of this goes to the server. JSCalendar's `acknowledged` is read (reminders.ts),
+ * never written: decisions file, reminders.
+ */
+
+import { metaGet, metaSet } from '../engine/core-schema';
+import { listUpcomingAlerts, type UpcomingAlert } from '../calendar/reminders';
+import type { StorageAdapter } from '../storage';
+
+/** Younger than this, an alert still fires by itself. As lib/calendar-alerts.ts. */
+export const FRESH_MS = 10 * 60_000;
+/** A gap between two checks longer than this means the device was away. A little over the page's refresh. */
+export const AWAY_MS = 6 * 60_000;
+/** How far back a summary reaches. After a month away nobody needs a month of reminders. */
+export const MISSED_REACH_MS = 7 * 24 * 3_600_000;
+/** How far ahead a check looks for the next fire time. */
+export const LOOK_AHEAD_MS = 6 * 3_600_000;
+/** Rows older than the reach can't come up again. */
+const KEEP_MS = MISSED_REACH_MS + 24 * 3_600_000;
+const CURSOR = 'reminder_cursor';
+const LONGEST_SNOOZE_MIN = 24 * 60;
+
+export interface ReminderAlert extends UpcomingAlert {
+ /** Set when it comes back from a snooze: when that was due. */
+ snoozedUntil?: number;
+}
+
+export interface TickParams {
+ /** Reads floating events: the user's zone. */
+ timeZone: string;
+ /** Tasks' alerts too. */
+ tasks: boolean;
+ /** Reminders are switched off: time passes, and nothing counts as missed later. */
+ muted?: boolean;
+}
+
+export interface TickResult {
+ /** Yours to show. No other caller gets these. */
+ due: ReminderAlert[];
+ /** How many wait for a summary. */
+ missed: number;
+ /** When to ask again at the latest for the next reminder. Null: none in sight. */
+ nextAt: number | null;
+ /** The calendar has synced at least once, so an empty answer means something. */
+ ready: boolean;
+}
+
+interface Row {
+ key: string;
+ status: string;
+ fire_time: number;
+ snooze_until: number | null;
+ alert: string;
+}
+
+function put(db: StorageAdapter, alert: ReminderAlert, status: string, now: number): void {
+ db.run(
+ `INSERT INTO reminder (key, status, fire_time, snooze_until, batch, alert, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)
+ ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, alert = excluded.alert, updated_at = excluded.updated_at`,
+ [alert.key, status, alert.fireTimeMs, JSON.stringify(alert), now],
+ );
+}
+
+function readAlert(row: Pick): ReminderAlert {
+ return JSON.parse(row.alert) as ReminderAlert;
+}
+
+function missedCount(db: StorageAdapter): number {
+ return Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM reminder WHERE status = 'missed'")?.n ?? 0);
+}
+
+function calendarReady(db: StorageAdapter): boolean {
+ return !!db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'CalendarEvent' AND state IS NOT NULL LIMIT 1");
+}
+
+/** Whether a snoozed alert still stands: the event may have gone, moved or been dismissed elsewhere. */
+function alertNow(db: StorageAdapter, row: Pick, timeZone: string): UpcomingAlert | undefined {
+ return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone }).find((alert) => alert.key === row.key);
+}
+
+/**
+ * Hands out what's due and settles the rest. Alerts that came due while the device was
+ * away go to the summary instead of arriving as a burst. A single recent one is just a
+ * reminder that came a little late.
+ */
+export function tick(db: StorageAdapter, now: number, params: TickParams): TickResult {
+ return db.transaction(() => {
+ const raw = metaGet(db, CURSOR);
+ const cursor = raw === null ? null : Number(raw);
+ metaSet(db, CURSOR, String(now));
+ if (params.muted) return { due: [], missed: missedCount(db), nextAt: null, ready: calendarReady(db) };
+
+ const freshFrom = now - FRESH_MS;
+ const from = cursor === null ? freshFrom : Math.max(Math.min(cursor, freshFrom), now - MISSED_REACH_MS);
+ const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone })
+ .filter((alert) => params.tasks || alert.kind !== 'task');
+ const past = listed.filter((alert) => alert.fireTimeMs <= now);
+ const known = new Set(
+ past.length === 0
+ ? []
+ : db.all<{ key: string }>('SELECT key FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]).map((row) => row.key),
+ );
+ const unseen = past.filter((alert) => !known.has(alert.key));
+
+ const away = cursor === null || now - cursor > AWAY_MS;
+ const lastSeen = cursor ?? freshFrom;
+ // Behind the cursor: it reached the store late, through a sync. Nobody was away from that.
+ const inGap = away ? unseen.filter((alert) => alert.fireTimeMs > lastSeen) : [];
+ const summarise = new Set(
+ inGap.length === 1 && inGap[0].fireTimeMs >= freshFrom ? [] : inGap.map((alert) => alert.key),
+ );
+ const due: ReminderAlert[] = [];
+ for (const alert of unseen) {
+ if (summarise.has(alert.key) || alert.fireTimeMs < freshFrom) {
+ put(db, alert, 'missed', now);
+ } else {
+ put(db, alert, 'fired', now);
+ due.push(alert);
+ }
+ }
+
+ const expired = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]);
+ for (const row of expired) {
+ const alert = alertNow(db, row, params.timeZone);
+ if (!alert) {
+ db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, row.key]);
+ continue;
+ }
+ if (now - row.snooze_until! < FRESH_MS) {
+ const back: ReminderAlert = { ...alert, snoozedUntil: row.snooze_until! };
+ put(db, back, 'fired', now);
+ due.push(back);
+ } else {
+ put(db, alert, 'missed', now);
+ }
+ }
+
+ db.run("DELETE FROM reminder WHERE fire_time < ? AND status NOT IN ('snoozed', 'missed')", [now - KEEP_MS]);
+
+ const coming = listed.find((alert) => alert.fireTimeMs > now)?.fireTimeMs ?? null;
+ const snooze = db.get<{ at: number | null }>("SELECT MIN(snooze_until) AS at FROM reminder WHERE status = 'snoozed'")?.at ?? null;
+ const nextAt = coming === null ? snooze : snooze === null ? coming : Math.min(coming, snooze);
+ return { due, missed: missedCount(db), nextAt, ready: calendarReady(db) };
+ });
+}
+
+export interface MissedBatch {
+ /** Says which summary, for a toast clicked later. */
+ batch: number;
+ alerts: ReminderAlert[];
+}
+
+/**
+ * The summary, for one caller only. What's gone from the calendar since doesn't come
+ * back to haunt anyone. Null: nothing to say.
+ */
+export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string }): MissedBatch | null {
+ return db.transaction(() => {
+ const rows = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key");
+ if (rows.length === 0) return null;
+ const standing = new Map(
+ listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone })
+ .map((alert) => [alert.key, alert]),
+ );
+ const newest = db.get<{ batch: number | null }>('SELECT MAX(batch) AS batch FROM reminder')?.batch ?? 0;
+ const batch = Math.max(now, newest + 1);
+ const alerts: ReminderAlert[] = [];
+ for (const row of rows) {
+ const alert = standing.get(row.key);
+ if (!alert) {
+ db.run("UPDATE reminder SET status = 'dismissed', updated_at = ? WHERE key = ?", [now, row.key]);
+ continue;
+ }
+ db.run("UPDATE reminder SET status = 'summarised', batch = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, JSON.stringify(alert), now, row.key]);
+ alerts.push(alert);
+ }
+ return alerts.length > 0 ? { batch, alerts } : null;
+ });
+}
+
+/** The summary couldn't be shown after all (paused, quiet hours): it stays due. */
+export function releaseMissed(db: StorageAdapter, now: number, batch: number): void {
+ db.run("UPDATE reminder SET status = 'missed', batch = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]);
+}
+
+/** What a summary said, for a click on it, possibly after a restart. */
+export function readBatch(db: StorageAdapter, batch: number): ReminderAlert[] {
+ return db.all("SELECT alert FROM reminder WHERE status = 'summarised' AND batch = ? ORDER BY fire_time, key", [batch]).map(readAlert);
+}
+
+/** Fired, but nobody could be told: it goes in the next summary. */
+export function holdFired(db: StorageAdapter, now: number, keys: readonly string[]): void {
+ db.transaction(() => {
+ for (const key of keys) db.run("UPDATE reminder SET status = 'missed', updated_at = ? WHERE status = 'fired' AND key = ?", [now, key]);
+ });
+}
+
+/** False when this device knows no such reminder, or the minutes make no sense. */
+export function snooze(db: StorageAdapter, now: number, key: string, minutes: number): boolean {
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > LONGEST_SNOOZE_MIN) return false;
+ const until = now + minutes * 60_000;
+ return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0;
+}
+
+export function dismiss(db: StorageAdapter, now: number, keys: readonly string[]): void {
+ db.transaction(() => {
+ for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, key]);
+ });
+}
+
+/** One reminder as it was last shown: what a toast's Join reads, since the toast holds only the key. */
+export function readReminder(db: StorageAdapter, key: string): ReminderAlert | null {
+ const row = db.get>('SELECT alert FROM reminder WHERE key = ?', [key]);
+ return row ? readAlert(row) : null;
+}
diff --git a/lib/store/reminders/schema.ts b/lib/store/reminders/schema.ts
new file mode 100644
index 000000000..445e368e1
--- /dev/null
+++ b/lib/store/reminders/schema.ts
@@ -0,0 +1,20 @@
+import type { Migration } from '../storage';
+
+export const REMINDER_MIGRATIONS: readonly Migration[] = [
+ {
+ id: 850,
+ name: 'reminders',
+ sql: `
+ CREATE TABLE reminder (
+ key TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ fire_time INTEGER NOT NULL,
+ snooze_until INTEGER,
+ batch INTEGER,
+ alert TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ CREATE INDEX reminder_status ON reminder (status, snooze_until);
+ `,
+ },
+];
From 7c8ae2fe89b15c4d363f0bf0348068ca23da5eb2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:29:11 +0100
Subject: [PATCH 08/24] fix: keep a hidden composer from taking files dropped
on the window
---
components/email/email-composer.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index f0dab3ae6..be8263737 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2650,12 +2650,6 @@ export function EmailComposer({
resetDragTimeout();
}, [resetDragTimeout]);
- // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
- useEffect(() => setComposerFileTarget(
- (files) => void addFiles(files),
- () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
- ), [addFiles, composerRootEl]);
-
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -2705,6 +2699,12 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState(null);
+
+ // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
+ useEffect(() => setComposerFileTarget(
+ (files) => void addFiles(files),
+ () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
+ ), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
From a802411cddfa6d2855b3590ca1bedd1508dc54de Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:31:58 +0100
Subject: [PATCH 09/24] feat: fire calendar reminders from the local store,
with native snooze and join buttons and one summary of what was missed
---
.../calendar/missed-reminders-dialog.tsx | 70 ++++
components/desktop/desktop-os-host.tsx | 19 -
.../providers/calendar-alert-provider.tsx | 4 +
.../ui/__tests__/toast-extra-actions.test.tsx | 33 ++
components/ui/toast.tsx | 3 +
.../use-calendar-alerts-hooks.test.tsx | 48 +++
hooks/__tests__/use-store-reminders.test.tsx | 380 ++++++++++++++++++
hooks/use-calendar-alerts.ts | 136 ++++---
hooks/use-store-reminders.ts | 278 +++++++++++++
.../__tests__/claims.test.ts | 98 +++++
.../__tests__/runner.test.ts | 268 ++++++++++++
lib/calendar-reminders/channel.ts | 32 ++
lib/calendar-reminders/claims.ts | 70 ++++
lib/calendar-reminders/runner.ts | 156 +++++++
lib/calendar-reminders/state.ts | 55 +++
lib/calendar-reminders/system-notification.ts | 70 ++++
.../__tests__/reminder-notifications.test.ts | 239 +++++++++++
lib/desktop/os-commands.ts | 2 +
lib/desktop/reminder-notifications.ts | 218 ++++++++++
lib/desktop/types.ts | 2 +
lib/offline/offline-mode.ts | 2 +
lib/offline/store-events.ts | 18 +
lib/sign-out-cleanup.ts | 1 +
locales/ar/common.json | 14 +
locales/ca/common.json | 14 +
locales/cs/common.json | 14 +
locales/da/common.json | 14 +
locales/de/common.json | 14 +
locales/en/common.json | 14 +
locales/es/common.json | 14 +
locales/et/common.json | 14 +
locales/fa/common.json | 14 +
locales/fr/common.json | 14 +
locales/he/common.json | 14 +
locales/hu/common.json | 14 +
locales/it/common.json | 14 +
locales/ja/common.json | 14 +
locales/ko/common.json | 14 +
locales/lv/common.json | 14 +
locales/mn/common.json | 14 +
locales/nb/common.json | 14 +
locales/nl/common.json | 14 +
locales/pl/common.json | 14 +
locales/pt/common.json | 14 +
locales/ro/common.json | 14 +
locales/ru/common.json | 14 +
locales/sk/common.json | 14 +
locales/th/common.json | 14 +
locales/tr/common.json | 14 +
locales/uk/common.json | 14 +
locales/vi/common.json | 14 +
locales/zh-TW/common.json | 14 +
locales/zh/common.json | 14 +
public/sw.js | 1 +
stores/calendar-notification-store.ts | 2 +
55 files changed, 2555 insertions(+), 70 deletions(-)
create mode 100644 components/calendar/missed-reminders-dialog.tsx
create mode 100644 components/ui/__tests__/toast-extra-actions.test.tsx
create mode 100644 hooks/__tests__/use-store-reminders.test.tsx
create mode 100644 hooks/use-store-reminders.ts
create mode 100644 lib/calendar-reminders/__tests__/claims.test.ts
create mode 100644 lib/calendar-reminders/__tests__/runner.test.ts
create mode 100644 lib/calendar-reminders/channel.ts
create mode 100644 lib/calendar-reminders/claims.ts
create mode 100644 lib/calendar-reminders/runner.ts
create mode 100644 lib/calendar-reminders/state.ts
create mode 100644 lib/calendar-reminders/system-notification.ts
create mode 100644 lib/desktop/__tests__/reminder-notifications.test.ts
create mode 100644 lib/desktop/reminder-notifications.ts
create mode 100644 lib/offline/store-events.ts
diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx
new file mode 100644
index 000000000..6309994fd
--- /dev/null
+++ b/components/calendar/missed-reminders-dialog.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useLocale, useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { getPathPrefix } from "@/lib/browser-navigation";
+import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
+import { dismissReminders, joinMeeting } from "@/lib/desktop/reminder-notifications";
+import { useSettingsStore } from "@/stores/settings-store";
+
+/** What came due while the app was closed, the machine slept or notifications were paused. */
+export function MissedRemindersDialog() {
+ const t = useTranslations("desktopReminders");
+ const tAlert = useTranslations("calendar.notifications");
+ const locale = useLocale();
+ const timeZone = useSettingsStore((s) => s.timeZone);
+ const account = useMissedReminders((s) => s.account);
+ const alerts = useMissedReminders((s) => s.alerts);
+ const close = useMissedReminders((s) => s.close);
+
+ if (!account || alerts.length === 0) return null;
+
+ const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) });
+ const finish = () => {
+ dismissReminders(account, alerts.map((alert) => alert.key));
+ close();
+ };
+
+ return (
+
+ );
+}
diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx
index c8d2755d8..df31098c0 100644
--- a/components/desktop/desktop-os-host.tsx
+++ b/components/desktop/desktop-os-host.tsx
@@ -9,11 +9,8 @@ import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
-import { calendarHooks } from "@/lib/plugin-hooks";
-import type { CalendarAlertView } from "@/lib/plugin-types";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
-import { useSettingsStore } from "@/stores/settings-store";
/**
* The main window's end of the desktop app's OS integration: new mail as native
@@ -25,7 +22,6 @@ export function DesktopOsHost() {
const t = useTranslations("desktopOs");
const tNotify = useTranslations("notifications");
const tPush = useTranslations("pwaNotify");
- const tCalendar = useTranslations("calendar.notifications");
const locale = useLocale();
const archiveEnabled = useArchiveActionEnabled();
const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n"));
@@ -56,29 +52,14 @@ export function DesktopOsHost() {
summaryRef.current = summary;
const stopPower = startPowerWatch();
const stopFiles = startWindowFiles();
- // Reminders' own native notification (snooze, join) comes with the reminders work.
- // Until then a reminder must not fire into a hidden window unseen.
- const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => {
- const { calendarNotificationsEnabled } = useSettingsStore.getState();
- if (!calendarNotificationsEnabled) return;
- const event = "eventId" in view;
- void getDesktopBridge()?.os?.notify({
- id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`,
- title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"),
- body: "",
- context: "{}",
- });
- });
return () => {
mail.stop();
summary.stop();
stopPower();
stopFiles();
- reminders.dispose();
mailRef.current = null;
summaryRef.current = null;
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx
index cb0b72b6a..561586746 100644
--- a/components/providers/calendar-alert-provider.tsx
+++ b/components/providers/calendar-alert-provider.tsx
@@ -1,12 +1,15 @@
"use client";
+import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
+import { useStoreReminders } from '@/hooks/use-store-reminders';
import { ToastContainer } from '@/components/ui/toast';
import { LiveRegions } from '@/components/ui/live-regions';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
useCalendarAlerts();
+ useStoreReminders();
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode
<>
{children}
+
>
);
diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx
new file mode 100644
index 000000000..10354d4f3
--- /dev/null
+++ b/components/ui/__tests__/toast-extra-actions.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ToastItem } from '../toast';
+
+describe('a toast with more than three actions', () => {
+ it('shows them in order, and each closes it', () => {
+ vi.useFakeTimers();
+ const clicked = vi.fn();
+ const onDismiss = vi.fn();
+ const action = (label: string) => ({ label, onClick: () => clicked(label) });
+ render(
+ ,
+ );
+ expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']);
+ fireEvent.click(screen.getByText('join'));
+ expect(clicked).toHaveBeenCalledWith('join');
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+});
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 28bb16c0a..a3d96a2be 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -36,6 +36,8 @@ export interface Toast {
* that has one lays its actions out on a row of their own.
*/
tertiaryAction?: ToastAction;
+ /** More actions at the end of that row. Needs a tertiaryAction. */
+ extraActions?: ToastAction[];
/**
* Work in progress, e.g. an upload: 0..1, or null while the amount is
* unknown. The bottom line then shows progress instead of a countdown,
@@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
{actionButton(toast.tertiaryAction)}
{toast.secondaryAction && actionButton(toast.secondaryAction)}
{toast.action && actionButton(toast.action)}
+ {toast.extraActions?.map((action) => {actionButton(action)})}
)}
diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
index 331953d64..0f3ca9527 100644
--- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
+++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
@@ -76,6 +76,54 @@ afterEach(() => {
vi.restoreAllMocks();
});
+describe('the 24-hour fetch', () => {
+ it('fires the occurrence of a series the server answered with its master', async () => {
+ const now = Date.now();
+ const occurrenceStart = new Date(now + 5 * 60_000);
+ const stamp = (date: Date) => {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
+ };
+ // The same weekday and time, years ago: only the expansion finds today's.
+ const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000);
+ const master = {
+ id: 'series',
+ uid: 'uid-series',
+ title: 'Weekly',
+ start: stamp(first),
+ duration: 'PT30M',
+ calendarIds: { c1: true },
+ useDefaultAlerts: false,
+ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }],
+ alerts: {
+ a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } },
+ },
+ } as unknown as CalendarEvent;
+ useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true });
+ useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never });
+
+ renderHook(() => useCalendarAlerts());
+
+ await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly']));
+ });
+});
+
+describe('two tabs', () => {
+ it('rings in one, also when an id changed between the two', async () => {
+ const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent;
+ renderHook(() => useCalendarAlerts());
+ await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1));
+
+ // The other tab has the same reminder under the id the server gives it now.
+ useCalendarStore.setState({ events: [synthetic] });
+ renderHook(() => useCalendarAlerts());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 700));
+ });
+ expect(useToastStore.getState().toasts).toHaveLength(1);
+ });
+});
+
describe('reminder hooks', () => {
it('reports a fired reminder once, and its dismissal when the user closes it', async () => {
const fired = vi.fn();
diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx
new file mode 100644
index 000000000..afcad0f8a
--- /dev/null
+++ b/hooks/__tests__/use-store-reminders.test.tsx
@@ -0,0 +1,380 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
+import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
+import { ToastContainer } from '@/components/ui/toast';
+import { useCalendarAlerts } from '../use-calendar-alerts';
+import { useStoreReminders } from '../use-store-reminders';
+import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
+import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
+import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types';
+import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
+import { useOfflineStore } from '@/lib/offline/state';
+import { emitStoreEvent } from '@/lib/offline/store-events';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { setStoreBackend } from '@/lib/store/client/api';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useToastStore } from '@/stores/toast-store';
+
+const sounds = vi.hoisted(() => ({ play: vi.fn() }));
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
+vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() }));
+vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true }));
+const popout = vi.hoisted(() => ({ value: false }));
+vi.mock('@/lib/popout/window-role', async (original) => ({
+ ...await original(),
+ isPopoutWindow: () => popout.value,
+}));
+
+const MIN = 60_000;
+const ACCOUNT = 'acc';
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } };
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+let notifyResult: DesktopOsNotifyResult;
+const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult);
+const openExternal = vi.fn();
+
+function desktop() {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
+}
+
+function setVisibility(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
+}
+
+function Toasts() {
+ const toasts = useToastStore((s) => s.toasts);
+ const removeToast = useToastStore((s) => s.removeToast);
+ return ;
+}
+
+async function advance(ms: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+}
+
+async function syncedWith(...events: Record[]) {
+ for (const partial of events) server.addEvent(partial);
+ await store.sync(ACCOUNT);
+}
+
+const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title);
+
+beforeEach(() => {
+ vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ vi.clearAllMocks();
+ popout.value = false;
+ clearAllHooks();
+ localStorage.clear();
+ setAppLockCheck(null);
+ setVisibility('visible');
+ notifyResult = { shown: true, playSound: false };
+ useToastStore.setState({ toasts: [] });
+ useReminderSource.setState({ driven: {} });
+ useMissedReminders.getState().close();
+ useCalendarNotificationStore.getState().clearAll();
+ useOfflineStore.setState({ enabled: [ACCOUNT] });
+ useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT });
+ useCalendarStore.setState({ events: [], calendars: [] });
+ useSettingsStore.setState({
+ calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true,
+ doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin',
+ });
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+ setStoreBackend(store);
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ window.bulwarkDesktop = undefined;
+ setAppLockCheck(null);
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+describe('in the desktop app', () => {
+ beforeEach(desktop);
+
+ it('fires a native notification with the window hidden, at the time, with the network gone', async () => {
+ await syncedWith(event(LINK));
+ await store.setOnline(false);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() - 1000);
+ expect(notify).not.toHaveBeenCalled();
+ await advance(1000);
+ expect(notify).toHaveBeenCalledTimes(1);
+ const sent = notify.mock.calls[0][0];
+ expect(sent.title).toBe('Standup');
+ expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('shows no Join for an event without a link', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join');
+ });
+
+ it('plays the sound main asks for, and asks for none in Do not disturb', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: true, playSound: true };
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+
+ useSettingsStore.setState({ doNotDisturb: true });
+ await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' }));
+ emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] });
+ await advance(MIN);
+ expect(notify.mock.calls[1][0].sound).toBe(false);
+ });
+
+ it('shows it in the app when the window is in front, with the same choices', async () => {
+ await syncedWith(event(LINK));
+ notifyResult = { shown: false, reason: 'focused', playSound: true };
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']);
+ fireEvent.click(screen.getByText('join'));
+ expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
+ });
+
+ it('snoozing from the in-app toast brings it back, and closing it does not', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ fireEvent.click(screen.getAllByText('snooze')[0]);
+ await advance(5 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+
+ await advance(1000);
+ const again = useToastStore.getState().toasts.at(-1)!;
+ act(() => again.onDismiss!());
+ await advance(60 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+ });
+
+ it('holds what fires while paused or in quiet hours for the summary afterwards', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: false, reason: 'quiet-hours', playSound: false };
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(toastTitles()).toEqual([]);
+
+ // Still quiet: the summary is tried and put back.
+ await advance(5 * MIN);
+ const tried = notify.mock.calls.length;
+ expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/);
+
+ notifyResult = { shown: true, playSound: false };
+ await advance(5 * MIN);
+ expect(notify.mock.calls.length).toBe(tried + 1);
+ expect(notify.mock.calls.at(-1)![0].body).toBe('Standup');
+
+ await advance(30 * MIN);
+ expect(notify.mock.calls.length).toBe(tried + 1);
+ });
+
+ it('says nothing of the event while locked, and tells afterwards', async () => {
+ await syncedWith(event());
+ let locked = true;
+ setAppLockCheck(() => locked);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup');
+ expect(notify.mock.calls[0][0].actions).toBeUndefined();
+ expect(toastTitles()).toEqual([]);
+
+ await advance(10 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+
+ locked = false;
+ await advance(5 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+ expect(notify.mock.calls[1][0].body).toBe('Standup');
+ });
+
+ it('summarises what was missed once, with a native notification, across a restart', async () => {
+ await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const before = renderHook(() => useStoreReminders());
+ await advance(0);
+ before.unmount();
+ vi.setSystemTime(FIRE + 3 * 60 * MIN);
+
+ const after = renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' });
+ after.unmount();
+
+ renderHook(() => useStoreReminders());
+ await advance(60 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+
+ it('summarises in the app when the window is in front, and the dialog lists them', async () => {
+ await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ const before = renderHook(() => useStoreReminders());
+ await advance(0);
+ before.unmount();
+ vi.setSystemTime(FIRE + 3 * 60 * MIN);
+ render(<>>);
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(toastTitles()).toEqual(['missed_title']);
+ fireEvent.click(screen.getByText('missed_show'));
+ await advance(0);
+ const list = screen.getByTestId('missed-reminders');
+ expect(list.textContent).toContain('Standup');
+ expect(list.textContent).toContain('Review');
+ expect(screen.getAllByText('join')).toHaveLength(1);
+ fireEvent.click(screen.getByText('done'));
+ await advance(0);
+ expect(screen.queryByTestId('missed-reminders')).toBeNull();
+ });
+
+ it('tells plugins, and lets one take the reminder over', async () => {
+ await syncedWith(event());
+ const fired = vi.fn();
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onBeforeCalendarAlert.register('t', () => false);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' }));
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ function calendarStoreHas(uid: string, title: string) {
+ useCalendarStore.setState({
+ events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
+ });
+ }
+
+ it('makes the old path stand down once the store has this account', async () => {
+ await syncedWith(event());
+ // Not the same event as far as the claim can tell: only standing down keeps it quiet.
+ calendarStoreHas('another-uid', 'Old path');
+ renderHook(() => {
+ useCalendarAlerts();
+ useStoreReminders();
+ });
+ await advance(FIRE - Date.now() + 2 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('lets the two paths tell each other what fired, for the moment the store takes over', async () => {
+ await syncedWith(event());
+ calendarStoreHas('uid-1', 'Standup');
+ const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims');
+ // The old path got there just before the store was ready.
+ expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 2 * MIN);
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('in a browser', () => {
+ function grantNotifications(showNotification = vi.fn(async () => {})) {
+ vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' }));
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) },
+ });
+ return showNotification;
+ }
+
+ afterEach(() => {
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+ });
+
+ it('keeps the in-app toast, without desktop buttons', async () => {
+ await syncedWith(event(LINK));
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ const toast = useToastStore.getState().toasts[0];
+ expect(toast.title).toBe('Standup');
+ expect(toast.action).toBeUndefined();
+ expect(toast.extraActions).toBeUndefined();
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ });
+
+ it('adds a system notification only where permission is granted and the tab is out of view', async () => {
+ await syncedWith(event());
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(shown).toHaveBeenCalledTimes(1);
+ expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false }));
+ });
+
+ it('asks for no permission and shows none when it was not granted', async () => {
+ await syncedWith(event());
+ const request = vi.fn();
+ vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request }));
+ setVisibility('hidden');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it('fires in one of two tabs', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + MIN);
+ expect(toastTitles()).toEqual(['Standup']);
+ });
+
+ it('closes the toast in this window when another acknowledges the reminder', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(toastTitles()).toEqual(['Standup']);
+ const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`;
+ const other = new BroadcastChannel('bulwark-calendar-reminders');
+ other.postMessage({ kind: 'acknowledged', keys: [key] });
+ other.close();
+ vi.useRealTimers();
+ await vi.waitFor(() => expect(toastTitles()).toEqual([]));
+ });
+
+ it('gives nothing to a pop-out', async () => {
+ popout.value = true;
+ await syncedWith(event());
+ const area = vi.spyOn(store, 'area');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + MIN);
+ expect(area).not.toHaveBeenCalled();
+ expect(toastTitles()).toEqual([]);
+ });
+});
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index 8c65c12ee..365647480 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -2,7 +2,7 @@
import { useEffect, useRef, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
-import { parseISO } from 'date-fns';
+import { format, parseISO } from 'date-fns';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
@@ -11,10 +11,16 @@ import { useTaskStore } from '@/stores/task-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts';
+import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims';
+import { useReminderSource } from '@/lib/calendar-reminders/state';
+import { showSystemReminder } from '@/lib/calendar-reminders/system-notification';
+import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { playNotificationSound } from '@/lib/notification-sound';
import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
@@ -23,6 +29,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events';
const CHECK_INTERVAL_MS = 60 * 1000;
const PROACTIVE_FETCH_HOURS = 24;
const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
+// Wall clocks: an occurrence's can be a day off the instant it's at.
+const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000;
// Longest single wait for the next reminder; the periodic check covers the rest.
const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000;
/** How long a reminder stays at the shortest "Notification duration". */
@@ -42,7 +50,7 @@ export function reminderToastDuration(): number {
* Whether a reminder plays its sound. Do not disturb keeps reminders on
* screen (they were asked for) but silent.
*/
-function reminderSoundOn(soundSetting: boolean): boolean {
+export function reminderSoundOn(soundSetting: boolean): boolean {
return soundSetting && !useSettingsStore.getState().doNotDisturb;
}
@@ -51,7 +59,7 @@ function reminderSoundOn(soundSetting: boolean): boolean {
* is in the background. Reminders were asked for, so Do not disturb keeps
* this too.
*/
-function reminderTitleAlert(text: string): void {
+export function reminderTitleAlert(text: string): void {
if (useSettingsStore.getState().visualAlerts) startTitleAlert(text);
}
@@ -61,7 +69,7 @@ function reminderTitleAlert(text: string): void {
* itself does. onCalendarAlert fires either way. With no plugin asked, the
* reminder is shown at once.
*/
-function remind(view: CalendarAlertView, show: () => void): void {
+export function remind(view: CalendarAlertView, show: () => void): void {
void calendarHooks.onCalendarAlert.emit(view);
if (calendarHooks.onBeforeCalendarAlert.size === 0) {
show();
@@ -73,9 +81,11 @@ function remind(view: CalendarAlertView, show: () => void): void {
}
export function useCalendarAlerts() {
- const { isAuthenticated: signedIn, client } = useAuthStore();
+ const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore();
+ // Reminders of an account with a local store come from there: useStoreReminders.
+ const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]);
// The main window rings; a pop-out would ring the same alert again.
- const isAuthenticated = signedIn && !isPopoutWindow();
+ const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore;
const { events, calendars, supportsCalendar } = useCalendarStore();
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
const { tasks: storeTasks } = useTaskStore();
@@ -91,6 +101,15 @@ export function useCalendarAlerts() {
const wakeTimerRef = useRef | null>(null);
const checkAlertsRef = useRef<() => void>(() => {});
+ // Beside the toast: the desktop app's native notification, a browser's system one.
+ const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => {
+ if (getDesktopBridge()?.os) {
+ notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId);
+ return;
+ }
+ void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`);
+ }, [t, locale, activeAccountId]);
+
const checkAlerts = useCallback(() => {
if (wakeTimerRef.current) {
clearTimeout(wakeTimerRef.current);
@@ -127,27 +146,33 @@ export function useCalendarAlerts() {
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
- remind({
- id: alert.alertId,
- eventId: alert.eventId,
- eventTitle: alert.event.title || '',
- triggerTime: new Date(alert.fireTimeMs).toISOString(),
- }, () => {
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
- reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: alert.event.title || t('alert_title'),
- message,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
- },
+ const uid = alert.event.uid || alert.event.originalId || alert.eventId;
+ claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => {
+ remind({
+ id: alert.alertId,
+ eventId: alert.eventId,
+ eventTitle: alert.event.title || '',
+ triggerTime: new Date(alert.fireTimeMs).toISOString(),
+ }, () => {
+ const sound = reminderSoundOn(calendarNotificationSound);
+ if (sound) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ const title = alert.event.title || t('alert_title');
+ reminderTitleAlert(t('title_alert', { title }));
+ addToast({
+ type: 'info',
+ title,
+ message,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
+ },
+ });
+ tellOutsideTheWindow(key, title, message, !sound);
});
});
}
@@ -166,27 +191,33 @@ export function useCalendarAlerts() {
? `${t('task_due')} · ${taskAlert.calendarName}`
: t('task_due');
- remind({
- id: taskAlert.alertId,
- taskId: taskAlert.taskId,
- taskTitle: taskAlert.task.title || '',
- triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
- }, () => {
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
- reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: taskAlert.task.title || t('alert_title'),
- message: taskMsg,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
- },
+ const uid = taskAlert.task.uid || taskAlert.taskId;
+ claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => {
+ remind({
+ id: taskAlert.alertId,
+ taskId: taskAlert.taskId,
+ taskTitle: taskAlert.task.title || '',
+ triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
+ }, () => {
+ const sound = reminderSoundOn(calendarNotificationSound);
+ if (sound) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ const title = taskAlert.task.title || t('alert_title');
+ reminderTitleAlert(t('title_alert', { title }));
+ addToast({
+ type: 'info',
+ title,
+ message: taskMsg,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
+ },
+ });
+ tellOutsideTheWindow(key, title, taskMsg, !sound);
});
});
}
@@ -196,8 +227,8 @@ export function useCalendarAlerts() {
}
}, [
calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice,
- isAuthenticated, events, calendars, acknowledgedAlerts,
- acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks,
+ isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts,
+ acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow,
]);
checkAlertsRef.current = checkAlerts;
@@ -211,7 +242,10 @@ export function useCalendarAlerts() {
const start = new Date(now - 10 * 60 * 1000).toISOString();
const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString();
const fetched = await client.queryCalendarEvents({ after: start, before: end });
- proactiveEventsRef.current = fetched;
+ // Without synthetic ids the server answers with a series' master, whose start can
+ // be years ago: only its occurrences have a fire time. Passes real occurrences through.
+ const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss");
+ proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS));
lastProactiveFetchRef.current = Date.now();
checkAlertsRef.current();
} catch {
diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts
new file mode 100644
index 000000000..adeb0468b
--- /dev/null
+++ b/hooks/use-store-reminders.ts
@@ -0,0 +1,278 @@
+"use client";
+
+import { useEffect, useRef } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts';
+import { getPathPrefix } from '@/lib/browser-navigation';
+import { onAcknowledged } from '@/lib/calendar-reminders/channel';
+import { claimKey, claimNow } from '@/lib/calendar-reminders/claims';
+import { createReminderRunner } from '@/lib/calendar-reminders/runner';
+import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
+import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import {
+ buildMissedNotification,
+ buildReminderNotification,
+ dismissReminders,
+ joinMeeting,
+ missedBody,
+ SNOOZE_MINUTES,
+ snoozeReminder,
+ type ReminderLabels,
+} from '@/lib/desktop/reminder-notifications';
+import { addWakeHandler } from '@/lib/desktop/system-events';
+import { playNotificationSound } from '@/lib/notification-sound';
+import { offlineActive } from '@/lib/offline/offline-mode';
+import { visibleAccounts, useOfflineStore } from '@/lib/offline/state';
+import { addStoreEventListener } from '@/lib/offline/store-events';
+import { calendarHooks } from '@/lib/plugin-hooks';
+import type { CalendarAlertView } from '@/lib/plugin-types';
+import { isPopoutWindow } from '@/lib/popout/window-role';
+import { getStore } from '@/lib/store/client/api';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useToastStore } from '@/stores/toast-store';
+
+function viewOf(alert: ReminderAlert): CalendarAlertView {
+ const triggerTime = new Date(alert.fireTimeMs).toISOString();
+ return alert.kind === 'task'
+ ? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime }
+ : { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime };
+}
+
+function acknowledgeView(alert: ReminderAlert) {
+ return alert.kind === 'task'
+ ? { alertId: alert.alertId, taskId: alert.baseEventId }
+ : { alertId: alert.alertId, eventId: alert.baseEventId };
+}
+
+/**
+ * Reminders from the local store: on time with no network, and in the desktop app with
+ * the window hidden. For accounts without a store useCalendarAlerts carries on as before.
+ */
+export function useStoreReminders(): void {
+ const signedIn = useAuthStore((s) => s.isAuthenticated);
+ const t = useTranslations('desktopReminders');
+ const tAlert = useTranslations('calendar.notifications');
+ const locale = useLocale();
+ const addToast = useToastStore((s) => s.addToast);
+ const removeToast = useToastStore((s) => s.removeToast);
+ const enabledKey = useOfflineStore((s) => s.enabled.join('\n'));
+
+ const labels: ReminderLabels = {
+ generic: t('generic_title'),
+ untitled: tAlert('alert_title'),
+ when: (alert) => {
+ const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000);
+ const time = alert.kind === 'task'
+ ? tAlert('task_due')
+ : minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes });
+ return alert.calendarName ? `${time} · ${alert.calendarName}` : time;
+ },
+ snooze: (minutes) => t('snooze', { count: minutes }),
+ snoozeShort: (minutes) => t('snooze_short', { count: minutes }),
+ open: t('open'),
+ join: t('join'),
+ missedTitle: (count) => t('missed_title', { count }),
+ missedMore: (count) => t('missed_more', { count }),
+ };
+ const labelsRef = useRef(labels);
+ labelsRef.current = labels;
+ const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`;
+ const calendarUrlRef = useRef(calendarUrl);
+ calendarUrlRef.current = calendarUrl;
+ const runnerRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!signedIn || isPopoutWindow()) return;
+ const toastsByKey = new Map();
+
+ const inAppReminder = (account: string, alert: ReminderAlert) => {
+ const current = labelsRef.current;
+ const desktop = !!getDesktopBridge()?.os;
+ // Set when a button says what happens next, so closing the toast doesn't undo it.
+ let handled = false;
+ const snoozeAction = (minutes: number) => ({
+ label: current.snooze(minutes),
+ onClick: () => {
+ handled = true;
+ void snoozeReminder(account, alert.key, minutes).catch(() => {});
+ },
+ });
+ const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction);
+ const id = addToast({
+ type: 'info',
+ title: alert.title || current.untitled,
+ message: current.when(alert),
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = calendarUrlRef.current;
+ },
+ onDismiss: () => {
+ toastsByKey.delete(alert.key);
+ void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert));
+ if (!handled) dismissReminders(account, [alert.key]);
+ },
+ ...(desktop ? {
+ tertiaryAction: five,
+ secondaryAction: ten,
+ action: fifteen,
+ extraActions: [
+ ...(alert.joinUrl ? [{ label: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []),
+ { label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } },
+ ],
+ } : {}),
+ });
+ toastsByKey.set(alert.key, id);
+ };
+
+ const showReminder = async (account: string, alert: ReminderAlert) => {
+ const settings = useSettingsStore.getState();
+ const sound = reminderSoundOn(settings.calendarNotificationSound);
+ const os = getDesktopBridge()?.os;
+ const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {});
+ if (os) {
+ const locked = isAppLocked();
+ const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound));
+ // Paused, quiet hours, or nothing may be said now: the summary says it later.
+ if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') {
+ await hold();
+ return;
+ }
+ if (result.reason === 'duplicate') return;
+ if (result.playSound) playNotificationSound(settings.notificationSoundChoice);
+ if (result.shown) return;
+ inAppReminder(account, alert);
+ return;
+ }
+ if (sound) playNotificationSound(settings.notificationSoundChoice);
+ reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled }));
+ inAppReminder(account, alert);
+ void showSystemReminder({
+ key: alert.key,
+ title: alert.title || labelsRef.current.untitled,
+ body: labelsRef.current.when(alert),
+ silent: !sound,
+ }, calendarUrlRef.current);
+ };
+
+ const showMissed = async (account: string) => {
+ if (isAppLocked()) return;
+ const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone);
+ const batch = await getStore().area(account, 'reminders.takeMissed', { timeZone });
+ if (!batch) return;
+ const current = labelsRef.current;
+ const os = getDesktopBridge()?.os;
+ if (os) {
+ const result = await os.notify(await buildMissedNotification(account, batch, current));
+ if (result.shown) return;
+ // Told nobody, so it has to wait for a time when it can.
+ if (result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') {
+ await getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch });
+ return;
+ }
+ }
+ const keys = batch.alerts.map((alert) => alert.key);
+ let opened = false;
+ const body = missedBody(batch.alerts, current);
+ const id = addToast({
+ type: 'info',
+ title: current.missedTitle(batch.alerts.length),
+ message: body,
+ duration: 0,
+ action: {
+ label: t('missed_show'),
+ onClick: () => {
+ opened = true;
+ useMissedReminders.getState().show(account, batch.alerts);
+ },
+ },
+ onDismiss: () => {
+ for (const key of keys) toastsByKey.delete(key);
+ if (!opened) dismissReminders(account, keys);
+ },
+ });
+ for (const key of keys) toastsByKey.set(key, id);
+ if (!os) {
+ void showSystemReminder({
+ key: `missed:${batch.batch}`,
+ title: current.missedTitle(batch.alerts.length),
+ body,
+ silent: true,
+ }, calendarUrlRef.current);
+ }
+ };
+
+ const runner = createReminderRunner({
+ accounts: () => visibleAccounts().filter((account) => offlineActive(account)),
+ store: () => getStore(),
+ params: () => {
+ const settings = useSettingsStore.getState();
+ return {
+ timeZone: reminderTimeZone(settings.timeZone),
+ tasks: settings.enableCalendarTasks,
+ muted: !settings.calendarNotificationsEnabled,
+ };
+ },
+ hidden: () => document.visibilityState === 'hidden',
+ offline: () => navigator.onLine === false,
+ onDue: (account, alerts) => {
+ for (const alert of alerts) {
+ // Whichever of the two paths got there first: one comes back from a snooze.
+ const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs);
+ if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) continue;
+ remind(viewOf(alert), () => {
+ void showReminder(account, alert).catch((error) => {
+ console.error('Failed to show a reminder:', error);
+ });
+ });
+ }
+ },
+ onMissed: showMissed,
+ onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven),
+ });
+ runnerRef.current = runner;
+ setReminderPoke(() => runner.poke());
+ runner.start();
+
+ const stopEvents = addStoreEventListener((event) => {
+ if (event.type === 'synced') runner.synced(event.account);
+ else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke();
+ });
+ const stopWake = addWakeHandler(() => runner.wake());
+ const onOnline = () => runner.wake();
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') runner.poke();
+ };
+ window.addEventListener('online', onOnline);
+ document.addEventListener('visibilitychange', onVisible);
+ const stopAcks = onAcknowledged((keys) => {
+ for (const key of keys) {
+ const id = toastsByKey.get(key);
+ if (id) removeToast(id);
+ toastsByKey.delete(key);
+ }
+ void closeSystemReminders(keys);
+ });
+
+ return () => {
+ runner.stop();
+ runnerRef.current = null;
+ setReminderPoke(null);
+ stopEvents();
+ stopWake();
+ stopAcks();
+ window.removeEventListener('online', onOnline);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ // Labels go through a ref: a language change mustn't restart the check.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [signedIn]);
+
+ // An account just turned on or off.
+ useEffect(() => {
+ runnerRef.current?.poke();
+ }, [enabledKey]);
+}
diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts
new file mode 100644
index 000000000..862738746
--- /dev/null
+++ b/lib/calendar-reminders/__tests__/claims.test.ts
@@ -0,0 +1,98 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims';
+
+const FIRE = Date.UTC(2026, 10, 10, 9);
+
+function setVisibility(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
+}
+
+/** A Web Lock: one holder at a time, across "tabs". */
+function fakeLocks() {
+ let tail: Promise = Promise.resolve();
+ return {
+ request: vi.fn((_name: string, callback: () => T | Promise) => {
+ const run = tail.then(callback);
+ tail = run.catch(() => {});
+ return run;
+ }),
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ setVisibility('visible');
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ Reflect.deleteProperty(navigator, 'locks');
+});
+
+describe('claimNow', () => {
+ it('says yes once', () => {
+ const key = claimKey('acc', 'uid-1', 'a', FIRE);
+ expect(claimNow(key, FIRE)).toBe(true);
+ expect(claimNow(key, FIRE)).toBe(false);
+ });
+
+ it('tells occurrences and accounts apart', () => {
+ expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true);
+ expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ });
+
+ it('forgets what is a day old', () => {
+ vi.useFakeTimers({ now: FIRE });
+ claimNow('old', FIRE - 25 * 3_600_000);
+ claimNow('new', FIRE);
+ expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']);
+ });
+
+ it('holds only the key, nothing of the event', () => {
+ claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE);
+ expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE }));
+ });
+});
+
+describe('claimAlert', () => {
+ it('lets one of two tabs through when their timers run out together', async () => {
+ Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() });
+ const won = vi.fn();
+ claimAlert('k', FIRE, () => won('tab 1'));
+ claimAlert('k', FIRE, () => won('tab 2'));
+ await vi.waitFor(() => expect(won).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(won.mock.calls).toEqual([['tab 1']]);
+ });
+
+ it('runs at once where there is no Web Lock', () => {
+ const won = vi.fn();
+ claimAlert('k', FIRE, won);
+ expect(won).toHaveBeenCalledTimes(1);
+ });
+
+ it('lets a tab in view go first', () => {
+ vi.useFakeTimers({ now: FIRE });
+ setVisibility('hidden');
+ const hidden = vi.fn();
+ claimAlert('k', FIRE, hidden);
+ setVisibility('visible');
+ const visible = vi.fn();
+ claimAlert('k', FIRE, visible);
+ expect(visible).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(HIDDEN_GRACE_MS);
+ expect(hidden).not.toHaveBeenCalled();
+ });
+
+ it('still fires in a hidden tab when no other took it', () => {
+ vi.useFakeTimers({ now: FIRE });
+ setVisibility('hidden');
+ const won = vi.fn();
+ claimAlert('k', FIRE, won);
+ expect(won).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(HIDDEN_GRACE_MS);
+ expect(won).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts
new file mode 100644
index 000000000..fd45f0ad4
--- /dev/null
+++ b/lib/calendar-reminders/__tests__/runner.test.ts
@@ -0,0 +1,268 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { HIDDEN_GRACE_MS } from '../claims';
+import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner';
+
+const MIN = 60_000;
+const ACCOUNT = 'acc';
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+interface Tab {
+ runner: ReminderRunner;
+ due: ReminderAlert[];
+ firedAt: number[];
+ missed: MissedBatch[];
+ source: boolean[];
+ env: ReminderRunnerEnv;
+ hidden: boolean;
+ offline: boolean;
+ muted: boolean;
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+const tabs: Tab[] = [];
+
+function tab(options: Partial> = {}): Tab {
+ const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab;
+ made.env = {
+ accounts: () => [ACCOUNT],
+ store: () => store,
+ params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }),
+ hidden: () => made.hidden,
+ offline: () => made.offline,
+ onDue: (_account, alerts) => {
+ made.due.push(...alerts);
+ made.firedAt.push(...alerts.map(() => Date.now()));
+ },
+ onMissed: async (account) => {
+ const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
+ if (batch) made.missed.push(batch);
+ },
+ onSource: (_account, driven) => {
+ made.source.push(driven);
+ },
+ };
+ made.runner = createReminderRunner(made.env);
+ tabs.push(made);
+ return made;
+}
+
+async function syncedStore(...events: Record[]): Promise {
+ for (const partial of events) server.addEvent(partial);
+ await store.sync(ACCOUNT);
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+});
+
+afterEach(() => {
+ for (const made of tabs.splice(0)) made.runner.stop();
+ vi.useRealTimers();
+});
+
+describe('firing from the store', () => {
+ it('fires at the moment it is due, with the network gone', async () => {
+ await syncedStore(event());
+ await store.setOnline(false);
+ server.request = (() => {
+ throw new Error('A reminder needs no server');
+ }) as unknown as FakeCalendarJmap['request'];
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000);
+ expect(one.due).toEqual([]);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(one.due.map((alert) => alert.title)).toEqual(['Standup']);
+ expect(one.firedAt).toEqual([FIRE]);
+ });
+
+ it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => {
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(false);
+ await syncedStore(event());
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(true);
+ store.failArea = () => true;
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(false);
+ });
+
+ it('fires in one tab only, when both ask at the same moment', async () => {
+ await syncedStore(event());
+ const [one, two] = [tab(), tab()];
+ one.runner.start();
+ two.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
+ expect(one.due.length + two.due.length).toBe(1);
+ });
+
+ it('leaves it to the tab in view when another is hidden', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ const visible = tab();
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ visible.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN);
+ expect(visible.due).toHaveLength(1);
+ expect(hidden.due).toHaveLength(0);
+ });
+
+ it('still fires when only a hidden tab is left', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS);
+ expect(hidden.due).toHaveLength(1);
+ });
+
+ it('notices an event that just arrived, and a snooze just set', async () => {
+ await syncedStore();
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN);
+ await syncedStore(event());
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(2 * MIN);
+ expect(one.firedAt).toEqual([FIRE]);
+
+ await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 });
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(5 * MIN);
+ expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]);
+ });
+
+ it('brings a snoozed reminder back after a restart', async () => {
+ await syncedStore(event());
+ const before = tab();
+ before.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now());
+ await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 });
+ before.runner.stop();
+ await vi.advanceTimersByTimeAsync(MIN);
+
+ const after = tab();
+ after.runner.start();
+ await vi.advanceTimersByTimeAsync(10 * MIN);
+ expect(before.due).toHaveLength(1);
+ expect(after.firedAt).toEqual([FIRE + 10 * MIN]);
+ expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN);
+ });
+
+ it('stays quiet while reminders are off', async () => {
+ await syncedStore(event());
+ const one = tab();
+ one.muted = true;
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
+ expect(one.due).toEqual([]);
+ });
+});
+
+describe('after being away', () => {
+ async function awayFromBothAlerts(): Promise {
+ await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const before = tab();
+ before.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ before.runner.stop();
+ vi.setSystemTime(FIRE + 60 * MIN);
+ return before;
+ }
+
+ it('waits a moment for the first sync, then summarises once, not as a burst', async () => {
+ await awayFromBothAlerts();
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000);
+ expect(one.missed).toEqual([]);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(one.due).toEqual([]);
+ expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]);
+
+ await vi.advanceTimersByTimeAsync(30 * MIN);
+ one.runner.wake();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
+ expect(one.missed).toHaveLength(1);
+ });
+
+ it('does not wait once the sync is done, and has nothing to wait for offline', async () => {
+ await awayFromBothAlerts();
+ const synced = tab();
+ synced.runner.start();
+ await vi.advanceTimersByTimeAsync(100);
+ synced.runner.synced(ACCOUNT);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(synced.missed).toHaveLength(1);
+ });
+
+ it('gives the summary at once with no network', async () => {
+ await awayFromBothAlerts();
+ const offline = tab({ offline: true });
+ offline.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(offline.missed).toHaveLength(1);
+ });
+
+ it('gives the summary to one window of two, and neither again after a restart', async () => {
+ await awayFromBothAlerts();
+ const [one, two] = [tab({ offline: true }), tab({ offline: true })];
+ one.runner.start();
+ two.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.missed.length + two.missed.length).toBe(1);
+ one.runner.stop();
+ two.runner.stop();
+ await vi.advanceTimersByTimeAsync(MIN);
+ const next = tab({ offline: true });
+ next.runner.start();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
+ expect(next.missed).toEqual([]);
+ });
+
+ it('sees a jump of the clock without being told, as after a sleep', async () => {
+ await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(MIN);
+ // Asleep: timers stood still and the clock did not.
+ vi.setSystemTime(FIRE + 60 * MIN);
+ await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS);
+ expect(one.due).toEqual([]);
+ expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]);
+ });
+
+ it('tries a summary again that could not be shown', async () => {
+ await awayFromBothAlerts();
+ const one = tab({ offline: true });
+ let refuse = true;
+ const plain = one.env.onMissed;
+ one.env.onMissed = async (account) => {
+ if (!refuse) return plain(account);
+ const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
+ await store.area(account, 'reminders.releaseMissed', { batch: batch.batch });
+ };
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.missed).toEqual([]);
+ refuse = false;
+ await vi.advanceTimersByTimeAsync(5 * MIN);
+ expect(one.missed).toHaveLength(1);
+ });
+});
diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts
new file mode 100644
index 000000000..9e32f74ac
--- /dev/null
+++ b/lib/calendar-reminders/channel.ts
@@ -0,0 +1,32 @@
+/**
+ * Telling the other windows that a reminder has been dealt with, so its toast and system
+ * notification go there too. What's done is recorded in the store; this only clears what's
+ * on screen. Only keys cross it, and they say nothing about the event but its uid.
+ */
+
+const CHANNEL_NAME = 'bulwark-calendar-reminders';
+
+interface AcknowledgedMessage {
+ kind: 'acknowledged';
+ keys: string[];
+}
+
+function open(): BroadcastChannel | null {
+ return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME);
+}
+
+export function announceAcknowledged(keys: readonly string[]): void {
+ const channel = open();
+ if (!channel) return;
+ channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage);
+ channel.close();
+}
+
+export function onAcknowledged(listener: (keys: string[]) => void): () => void {
+ const channel = open();
+ if (!channel) return () => {};
+ channel.onmessage = (event: MessageEvent) => {
+ if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys);
+ };
+ return () => channel.close();
+}
diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts
new file mode 100644
index 000000000..069660aea
--- /dev/null
+++ b/lib/calendar-reminders/claims.ts
@@ -0,0 +1,70 @@
+/**
+ * One tab fires a reminder when there's no local store to decide it. The list sits in
+ * localStorage and holds no more than the event's uid, and the Web Lock makes reading
+ * and writing it one step across tabs: their timers run out at the same moment, so a
+ * plain read and write would let both through.
+ *
+ * Keys are the series' uid, never the id: synthetic ids reshuffle when an override
+ * changes, and a reminder fired again inside its ten minutes.
+ */
+
+const STORAGE_KEY = 'calendar-alert-claims';
+const LOCK = 'bulwark-calendar-alert-claim';
+const KEEP_MS = 24 * 60 * 60 * 1000;
+/** A tab out of view waits, so the one in view is who shows the reminder. */
+export const HIDDEN_GRACE_MS = 2000;
+
+export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string {
+ return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`;
+}
+
+function readClaims(): Record {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown;
+ return parsed && typeof parsed === 'object' ? parsed as Record : {};
+ } catch {
+ return {};
+ }
+}
+
+/** True for the first to ask. Also what the store path tells the other path with. */
+export function claimNow(key: string, fireTimeMs: number): boolean {
+ const claims = readClaims();
+ if (key in claims) return false;
+ const now = Date.now();
+ const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS));
+ kept[key] = fireTimeMs;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(kept));
+ } catch {
+ // Without storage each tab is on its own, as it always was.
+ }
+ return true;
+}
+
+/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */
+export function claimAlert(key: string, fireTimeMs: number, won: () => void): void {
+ const attempt = () => {
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
+ if (!locks) {
+ if (claimNow(key, fireTimeMs)) won();
+ return;
+ }
+ void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => {
+ if (claimed) won();
+ });
+ };
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS);
+ else attempt();
+}
+
+/** A sign-out: the next person starts with a clean slate. */
+export function clearClaims(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Nothing was kept, then.
+ }
+}
+
+export const CLAIMS_STORAGE_KEY = STORAGE_KEY;
diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts
new file mode 100644
index 000000000..26bb9a569
--- /dev/null
+++ b/lib/calendar-reminders/runner.ts
@@ -0,0 +1,156 @@
+/**
+ * Asks the local store what's due, at the moment it's due. Runs in the page: in the
+ * desktop app that's the main window, which keeps its timers while hidden in the tray,
+ * and the store's worker answers with no network. Every tab or window may run one: the
+ * store hands each alert to whichever asks first.
+ *
+ * Between checks it sleeps until the store's next fire time, and looks in every few
+ * minutes in case something changed with nobody saying so.
+ */
+
+import type { StoreBackend } from '@/lib/store/client/api';
+import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger';
+import { HIDDEN_GRACE_MS } from './claims';
+
+const REFRESH_MS = 5 * 60_000;
+/** Timers stand still while a machine sleeps. The clock doesn't. */
+const CLOCK_WATCH_MS = 60_000;
+const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS;
+/**
+ * After a start or a wake, how long a summary waits for the first sync, so it leaves out
+ * what was deleted elsewhere in the meantime. Reminders that are due don't wait.
+ */
+export const SUMMARY_WAIT_MS = 20_000;
+
+export interface ReminderRunnerEnv {
+ /** The local accounts with a store right now. */
+ accounts(): string[];
+ store(): Pick;
+ params(): TickParams;
+ /** Out of view: another tab in view should get to go first. */
+ hidden(): boolean;
+ offline(): boolean;
+ /** Runs in the one tab that got these. */
+ onDue(account: string, alerts: ReminderAlert[]): void;
+ /** There's a summary to give. Resolves when that's been tried. */
+ onMissed(account: string): Promise;
+ /** Whether this account's reminders come from the store now, or the app's old path keeps them. */
+ onSource(account: string, driven: boolean): void;
+}
+
+export interface ReminderRunner {
+ start(): void;
+ stop(): void;
+ /** Check now: the calendar changed, a snooze was set. */
+ poke(): void;
+ /** Back from sleep, or back online. */
+ wake(): void;
+ /** The store finished a sync for this account. */
+ synced(account: string): void;
+}
+
+export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner {
+ let timer: ReturnType | null = null;
+ let clockWatch: ReturnType | null = null;
+ let lastWatch = 0;
+ let running = false;
+ let again = false;
+ let stopped = true;
+ let summaryAt = 0;
+ const presenting = new Set();
+ const known = new Set();
+
+ const schedule = (at: number) => {
+ if (timer) clearTimeout(timer);
+ const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0);
+ timer = setTimeout(() => void run(), wait);
+ };
+
+ const check = async (account: string): Promise => {
+ let result: TickResult;
+ try {
+ result = await env.store().area(account, 'reminders.tick', env.params());
+ } catch {
+ // The store is out of reach: the old path takes these over until it's back.
+ env.onSource(account, false);
+ return null;
+ }
+ env.onSource(account, result.ready);
+ if (result.due.length > 0) env.onDue(account, result.due);
+ if (result.missed === 0) return result.nextAt;
+ const wait = env.offline() ? 0 : summaryAt - Date.now();
+ if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt);
+ if (!presenting.has(account)) {
+ presenting.add(account);
+ void env.onMissed(account)
+ .catch(() => {})
+ .finally(() => presenting.delete(account));
+ }
+ return result.nextAt;
+ };
+
+ const run = async () => {
+ if (stopped) return;
+ if (running) {
+ again = true;
+ return;
+ }
+ running = true;
+ let next = Date.now() + REFRESH_MS;
+ const accounts = env.accounts();
+ for (const gone of [...known]) {
+ if (!accounts.includes(gone)) {
+ known.delete(gone);
+ env.onSource(gone, false);
+ }
+ }
+ for (const account of accounts) {
+ known.add(account);
+ const at = await check(account);
+ if (at !== null) next = Math.min(next, at);
+ }
+ running = false;
+ if (stopped) return;
+ if (again) {
+ again = false;
+ void run();
+ return;
+ }
+ schedule(next);
+ };
+
+ return {
+ start() {
+ stopped = false;
+ summaryAt = Date.now() + SUMMARY_WAIT_MS;
+ lastWatch = Date.now();
+ clockWatch = setInterval(() => {
+ const now = Date.now();
+ const jumped = now - lastWatch > CLOCK_JUMP_MS;
+ lastWatch = now;
+ if (jumped) this.wake();
+ }, CLOCK_WATCH_MS);
+ void run();
+ },
+ stop() {
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ if (clockWatch) clearInterval(clockWatch);
+ timer = null;
+ clockWatch = null;
+ for (const account of known) env.onSource(account, false);
+ known.clear();
+ },
+ poke() {
+ void run();
+ },
+ wake() {
+ summaryAt = Date.now() + SUMMARY_WAIT_MS;
+ void run();
+ },
+ synced() {
+ summaryAt = 0;
+ void run();
+ },
+ };
+}
diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts
new file mode 100644
index 000000000..109df52ee
--- /dev/null
+++ b/lib/calendar-reminders/state.ts
@@ -0,0 +1,55 @@
+/**
+ * What the reminder code shares across the page, kept free of the rest of the app so
+ * useCalendarAlerts can read it without pulling in the local store.
+ */
+
+import { create } from 'zustand';
+import type { ReminderAlert } from '@/lib/store/reminders/ledger';
+
+interface ReminderSourceState {
+ /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */
+ driven: Record;
+ setDriven: (account: string, driven: boolean) => void;
+}
+
+export const useReminderSource = create()((set) => ({
+ driven: {},
+ setDriven: (account, driven) => set((state) => {
+ if (!!state.driven[account] === driven) return state;
+ const next = { ...state.driven };
+ if (driven) next[account] = true;
+ else delete next[account];
+ return { driven: next };
+ }),
+}));
+
+interface MissedRemindersState {
+ account: string | null;
+ alerts: ReminderAlert[];
+ show: (account: string, alerts: ReminderAlert[]) => void;
+ close: () => void;
+}
+
+/** The summary dialog's contents. Empty: it's closed. */
+export const useMissedReminders = create()((set) => ({
+ account: null,
+ alerts: [],
+ show: (account, alerts) => set({ account, alerts }),
+ close: () => set({ account: null, alerts: [] }),
+}));
+
+let poke: (() => void) | null = null;
+
+/** The running check, so something done from a toast takes effect at once. */
+export function setReminderPoke(next: (() => void) | null): void {
+ poke = next;
+}
+
+export function pokeReminders(): void {
+ poke?.();
+}
+
+/** The zone that reads a floating event: the setting's, or the system's on `auto`. */
+export function reminderTimeZone(setting: string | undefined): string {
+ return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts
new file mode 100644
index 000000000..538669c1b
--- /dev/null
+++ b/lib/calendar-reminders/system-notification.ts
@@ -0,0 +1,70 @@
+/**
+ * A browser's system notification for a reminder, where the user has already granted the
+ * permission. This never asks for it. Through the service worker where there is one, since
+ * Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever
+ * tab asks.
+ */
+
+export interface SystemReminder {
+ key: string;
+ title: string;
+ body: string;
+ /** Do not disturb: on screen, but quiet. */
+ silent: boolean;
+}
+
+function tagOf(key: string): string {
+ return `calendar-reminder:${key}`;
+}
+
+function permitted(): boolean {
+ return typeof Notification !== 'undefined' && Notification.permission === 'granted';
+}
+
+async function registration(): Promise {
+ try {
+ return await navigator.serviceWorker?.getRegistration();
+ } catch {
+ return undefined;
+ }
+}
+
+/** In front of the user the in-app toast says it already. */
+export function wantsSystemNotification(): boolean {
+ if (!permitted() || typeof document === 'undefined') return false;
+ return document.visibilityState === 'hidden' || !document.hasFocus();
+}
+
+export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise {
+ if (!wantsSystemNotification()) return;
+ const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent };
+ try {
+ const worker = await registration();
+ if (worker) {
+ await worker.showNotification(reminder.title, { ...options, data: { kind: 'calendar-reminder' } });
+ return;
+ }
+ const shown = new Notification(reminder.title, options);
+ shown.onclick = () => {
+ window.focus();
+ window.location.href = openUrl;
+ shown.close();
+ };
+ } catch (error) {
+ console.warn('A system notification could not be shown:', error);
+ }
+}
+
+/** Dealt with elsewhere: it shouldn't stay in the notification list. */
+export async function closeSystemReminders(keys: readonly string[]): Promise {
+ if (!permitted() || keys.length === 0) return;
+ const worker = await registration();
+ if (!worker) return;
+ try {
+ for (const key of keys) {
+ for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close();
+ }
+ } catch {
+ // Nothing that matters is left behind.
+ }
+}
diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts
new file mode 100644
index 000000000..1a19e29d2
--- /dev/null
+++ b/lib/desktop/__tests__/reminder-notifications.test.ts
@@ -0,0 +1,239 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useMissedReminders } from '@/lib/calendar-reminders/state';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { setStoreBackend } from '@/lib/store/client/api';
+import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger';
+import { setAppLockCheck } from '../app-lock-state';
+import {
+ buildMissedNotification,
+ buildReminderNotification,
+ handleReminderCommand,
+ missedBody,
+ notifyWithoutStore,
+ type ReminderLabels,
+} from '../reminder-notifications';
+import type { BulwarkDesktopBridge } from '../types';
+
+const ACCOUNT = 'alice@one';
+const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
+let activeAccountId: string | null = ACCOUNT;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }) } }));
+
+const policy = vi.hoisted(() => ({ pushWithoutContent: false }));
+vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) }));
+
+const MIN = 60_000;
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+
+const labels: ReminderLabels = {
+ generic: 'GENERIC',
+ when: () => 'In 15 min · Personal',
+ untitled: 'UNTITLED',
+ snooze: (minutes) => `Snooze ${minutes} min`,
+ snoozeShort: (minutes) => `${minutes} min`,
+ open: 'Open',
+ join: 'Join',
+ missedTitle: (count) => `${count} missed`,
+ missedMore: (count) => `and ${count} more`,
+};
+
+function alert(partial: Partial = {}): ReminderAlert {
+ return {
+ key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE,
+ title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal',
+ ...partial,
+ };
+}
+
+const focus = vi.fn();
+const openExternal = vi.fn();
+const notify = vi.fn(async () => ({ shown: true, playSound: false }));
+
+function setPlatform(platform: string) {
+ window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+const env = { leaveFor: vi.fn(), push: vi.fn() };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ policy.pushWithoutContent = false;
+ activeAccountId = ACCOUNT;
+ setAppLockCheck(null);
+ setPlatform('win32');
+ useMissedReminders.getState().close();
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+ setStoreBackend(store);
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ window.bulwarkDesktop = undefined;
+ setAppLockCheck(null);
+ vi.useRealTimers();
+});
+
+async function contextFor(reminder: ReminderAlert): Promise {
+ return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context;
+}
+
+describe('what a reminder says', () => {
+ it('offers Join only when the event has a link', async () => {
+ const without = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false);
+ expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ });
+
+ it('uses short snooze labels where five buttons share a row', async () => {
+ const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min');
+ setPlatform('darwin');
+ const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min');
+ });
+
+ it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => {
+ setAppLockCheck(() => true);
+ const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true);
+ expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' });
+ expect(locked.actions).toBeUndefined();
+ expect(JSON.stringify(locked)).not.toContain('Secret');
+ expect(JSON.stringify(locked)).not.toContain('uid-1');
+ });
+
+ it('keeps the title off when the organisation says so, the buttons stay', async () => {
+ policy.pushWithoutContent = true;
+ const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(hidden).toMatchObject({ title: 'GENERIC', body: '' });
+ expect(hidden.actions).toHaveLength(4);
+ });
+
+ it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => {
+ const first = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false);
+ expect(back.id).not.toBe(first.id);
+ });
+
+ it('falls back on a neutral title for an event without one', async () => {
+ expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED');
+ });
+
+ it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => {
+ const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title }));
+ expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more');
+ const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels);
+ expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' });
+ setAppLockCheck(() => true);
+ expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe('');
+ });
+
+ it('without a store says what it can, and nothing when locked', () => {
+ notifyWithoutStore('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' }));
+ setAppLockCheck(() => true);
+ notifyWithoutStore('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' }));
+ });
+});
+
+describe('what its buttons do', () => {
+ /** A reminder really on record in the store. */
+ async function fired(partial: Record = {}): Promise {
+ vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
+ server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial });
+ await store.sync(ACCOUNT);
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ vi.setSystemTime(FIRE);
+ return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0];
+ }
+
+ it('snoozes from the store with the window left hidden', async () => {
+ const reminder = await fired();
+ expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true);
+ expect(focus).not.toHaveBeenCalled();
+ expect(env.leaveFor).not.toHaveBeenCalled();
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 9 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ vi.setSystemTime(FIRE + 10 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1);
+ });
+
+ it('refuses a snooze length no button offers', async () => {
+ const reminder = await fired();
+ await handleReminderCommand('snooze-999', await contextFor(reminder), env);
+ expect(focus).toHaveBeenCalled();
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 16 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ });
+
+ it('joins through the OS from what the store holds, with the window left hidden', async () => {
+ const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } });
+ await handleReminderCommand('join', await contextFor(reminder), env);
+ expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
+ expect(focus).not.toHaveBeenCalled();
+ });
+
+ it('opens the event in the account on screen', async () => {
+ const reminder = await fired();
+ await handleReminderCommand('open', await contextFor(reminder), env);
+ expect(focus).toHaveBeenCalled();
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1');
+ });
+
+ it('opens only the calendar for an account that is not on screen', async () => {
+ const reminder = await fired();
+ const context = await contextFor(reminder);
+ activeAccountId = 'someone-else';
+ await handleReminderCommand('open', context, env);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(env.push).not.toHaveBeenCalled();
+ });
+
+ it('touches nothing when the slot has gone to another login since', async () => {
+ const reminder = await fired();
+ const context = JSON.parse(await contextFor(reminder)) as { login: string };
+ context.login = 'not-this-login';
+ await handleReminderCommand('snooze-5', JSON.stringify(context), env);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 5 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ });
+
+ it('opens the summary from a click on its toast, even after a restart', async () => {
+ vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
+ server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ await store.sync(ACCOUNT);
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ vi.setSystemTime(FIRE + 60 * MIN);
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params);
+ const toast = await buildMissedNotification(ACCOUNT, batch, labels);
+
+ await handleReminderCommand('open', toast.context, env);
+ expect(focus).toHaveBeenCalled();
+ expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']);
+ expect(useMissedReminders.getState().account).toBe(ACCOUNT);
+ });
+
+ it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => {
+ expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false);
+ expect(await handleReminderCommand('open', '', env)).toBe(false);
+ expect(await handleReminderCommand('open', '{broken', env)).toBe(false);
+ });
+});
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
index 8c7332225..3a6fa8703 100644
--- a/lib/desktop/os-commands.ts
+++ b/lib/desktop/os-commands.ts
@@ -8,6 +8,7 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsCommand } from '@/lib/desktop/types';
import { openMailLink } from '@/lib/desktop/mail-links';
+import { handleReminderCommand } from '@/lib/desktop/reminder-notifications';
import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
import { parseMailto } from '@/lib/protocol-handlers/mailto';
@@ -89,6 +90,7 @@ function runAction(request: NotificationActionRequest): Promise {
+ if (await handleReminderCommand(action, rawContext, env)) return;
const context = parseContext(rawContext);
if (action === 'reply') {
await openFromNotification(context, env, { text: reply ?? '' });
diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts
new file mode 100644
index 000000000..3979c1d96
--- /dev/null
+++ b/lib/desktop/reminder-notifications.ts
@@ -0,0 +1,218 @@
+/**
+ * Calendar reminders as native notifications, and what their buttons do.
+ *
+ * A toast may be clicked a day later and after a restart, so what it hands back says
+ * which login and which reminder and no more: the rest comes from the store. Main keeps
+ * pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at
+ * all while the app is locked, or when the organisation says so for that login
+ * (pushWithoutContent, the rule new mail follows).
+ */
+
+import { announceAcknowledged } from '@/lib/calendar-reminders/channel';
+import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import type { DesktopOsNotification } from '@/lib/desktop/types';
+import { devicesForAccount } from '@/lib/device-policy';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { getStore } from '@/lib/store/client/api';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+export const SNOOZE_MINUTES = [5, 10, 15] as const;
+const SNOOZE_PREFIX = 'snooze-';
+const JOIN = 'join';
+const OPEN = 'open';
+/** How many titles a summary names. */
+const SUMMARY_TITLES = 3;
+
+export interface ReminderLabels {
+ /** Says nothing about the event. */
+ generic: string;
+ /** "In 10 min · Personal". */
+ when: (alert: ReminderAlert) => string;
+ untitled: string;
+ snooze: (minutes: number) => string;
+ /** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */
+ snoozeShort: (minutes: number) => string;
+ open: string;
+ join: string;
+ missedTitle: (count: number) => string;
+ missedMore: (count: number) => string;
+}
+
+export interface ReminderToastContext {
+ /** The reminder. */
+ r?: string;
+ /** The summary. */
+ rb?: number;
+ /** No store to ask: the click only goes to the calendar. */
+ cal?: boolean;
+ slot?: number;
+ login?: string;
+}
+
+async function loginOf(account: string): Promise> {
+ const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account);
+ if (!entry) return {};
+ return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) };
+}
+
+function hideContent(account: string): boolean {
+ if (isAppLocked()) return true;
+ try {
+ return devicesForAccount(account).pushWithoutContent;
+ } catch {
+ return true;
+ }
+}
+
+function reminderId(alert: ReminderAlert): string {
+ // Main drops an id it saw in the last few minutes, and a snooze can be shorter.
+ return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`;
+}
+
+export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise {
+ const base = { group: 'reminders', kind: 'reminder' as const, sound };
+ // Not even the uid. Two at the same moment are one "you have a reminder".
+ if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' };
+ const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) };
+ const short = getDesktopBridge()?.platform === 'win32';
+ const actions = [
+ ...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []),
+ { id: OPEN, label: labels.open },
+ ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })),
+ ];
+ const hidden = hideContent(account);
+ return {
+ ...base,
+ id: reminderId(alert),
+ title: hidden ? labels.generic : alert.title || labels.untitled,
+ body: hidden ? '' : labels.when(alert),
+ context: JSON.stringify(context),
+ actions,
+ };
+}
+
+/** The first few titles of a summary, for a place too small for all of them. */
+export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string {
+ const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', ');
+ const more = alerts.length - SUMMARY_TITLES;
+ return more > 0 ? `${named}, ${labels.missedMore(more)}` : named;
+}
+
+export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise {
+ const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) };
+ return {
+ id: `reminder-missed:${batch.batch}`,
+ group: 'reminders',
+ kind: 'reminder',
+ title: labels.missedTitle(batch.alerts.length),
+ body: hideContent(account) ? '' : missedBody(batch.alerts, labels),
+ context: JSON.stringify(context),
+ };
+}
+
+/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */
+export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void {
+ const hidden = isAppLocked() || !account || hideContent(account);
+ void getDesktopBridge()?.os?.notify({
+ id,
+ group: 'reminders',
+ kind: 'reminder',
+ title: hidden ? labels.generic : title,
+ body: hidden ? '' : body,
+ context: JSON.stringify({ cal: true } satisfies ReminderToastContext),
+ });
+}
+
+async function accountOf(context: ReminderToastContext): Promise {
+ if (context.slot === undefined || !context.login) return null;
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
+ if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null;
+ return entry.id;
+}
+
+/** Goes to the event when it's in the account on screen, otherwise to the calendar. */
+export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null {
+ if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null;
+ const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : '';
+ return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`;
+}
+
+export function joinMeeting(url: string): void {
+ const shell = getDesktopBridge()?.shell;
+ if (shell) shell.openExternal(url);
+ else window.open(url, '_blank', 'noopener,noreferrer');
+}
+
+export function dismissReminders(account: string, keys: string[]): void {
+ announceAcknowledged(keys);
+ void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {});
+}
+
+export async function snoozeReminder(account: string, key: string, minutes: number): Promise {
+ announceAcknowledged([key]);
+ await getStore().area(account, 'reminders.snooze', { key, minutes });
+ pokeReminders();
+}
+
+export interface ReminderCommandEnv {
+ leaveFor: (path: '/calendar') => void | Promise;
+ push: (path: string) => void;
+}
+
+function parse(raw: string): ReminderToastContext | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as ReminderToastContext;
+ return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function snoozeMinutes(action: string): number | null {
+ if (!action.startsWith(SNOOZE_PREFIX)) return null;
+ const minutes = Number(action.slice(SNOOZE_PREFIX.length));
+ return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null;
+}
+
+/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */
+export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise {
+ const context = parse(rawContext);
+ if (!context) return false;
+ const account = await accountOf(context);
+ const show = () => getDesktopBridge()?.windows?.focus?.();
+
+ if (context.rb !== undefined) {
+ show();
+ const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : [];
+ if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts);
+ else await env.leaveFor('/calendar');
+ return true;
+ }
+
+ const key = context.r;
+ const minutes = snoozeMinutes(action);
+ if (minutes !== null && account && key) {
+ await snoozeReminder(account, key, minutes);
+ return true;
+ }
+
+ const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null;
+ if (action === JOIN && alert?.joinUrl) {
+ joinMeeting(alert.joinUrl);
+ dismissReminders(account!, [alert.key]);
+ return true;
+ }
+
+ // Open, a click on the toast, or a button that can't be carried out any more.
+ show();
+ await env.leaveFor('/calendar');
+ const path = account ? calendarPathFor(account, alert) : null;
+ if (path) env.push(path);
+ if (account && alert) dismissReminders(account, [alert.key]);
+ return true;
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index 696ab3183..249a45cba 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -235,6 +235,8 @@ export interface DesktopOsNotification {
sound?: boolean;
/** New mail: flash the taskbar or bounce the dock if that's on. */
attention?: boolean;
+ /** What a locked app says instead: new mail unless this says otherwise. */
+ kind?: 'mail' | 'reminder';
}
export interface DesktopOsNotifyResult {
diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts
index d71993589..77122dd0e 100644
--- a/lib/offline/offline-mode.ts
+++ b/lib/offline/offline-mode.ts
@@ -30,6 +30,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification';
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
import { emitPushStateChange } from '@/lib/push-events';
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
+import { emitStoreEvent } from '@/lib/offline/store-events';
import { STORAGE_ERROR_AREA } from '@/lib/store/area';
import type { DraftError } from '@/lib/store/drafts/pending';
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
@@ -197,6 +198,7 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
+ emitStoreEvent(event);
if (event.type === 'created' && event.collection === 'Email') {
const client = onlineClientFor(event.account);
if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
diff --git a/lib/offline/store-events.ts b/lib/offline/store-events.ts
new file mode 100644
index 000000000..b857ec351
--- /dev/null
+++ b/lib/offline/store-events.ts
@@ -0,0 +1,18 @@
+/** The store's events, for the parts of the page that aren't offline mode itself. */
+
+import type { StoreEvent } from '@/lib/store/area';
+
+type Listener = (event: StoreEvent) => void;
+
+const listeners = new Set();
+
+export function addStoreEventListener(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function emitStoreEvent(event: StoreEvent): void {
+ for (const listener of [...listeners]) listener(event);
+}
diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts
index 531c99f7f..2400d48f3 100644
--- a/lib/sign-out-cleanup.ts
+++ b/lib/sign-out-cleanup.ts
@@ -21,6 +21,7 @@ const SIGNED_OUT_STORAGE_KEYS = [
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
+ 'calendar-alert-claims',
// Open tabs name messages and their subjects.
'pro-tabs',
'search-history-storage',
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 169c04ec5..05c769ae3 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "نسخ كملف",
"copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
"copy_failed": "تعذّر نسخ الملف."
+ },
+ "desktopReminders": {
+ "generic_title": "تذكير",
+ "snooze": "تأجيل {count} د",
+ "snooze_short": "{count} د",
+ "open": "فتح",
+ "join": "انضمام",
+ "missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}",
+ "missed_more": "و{count} أخرى",
+ "missed_show": "عرض",
+ "missed_dialog_title": "التذكيرات الفائتة",
+ "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.",
+ "open_calendar": "فتح التقويم",
+ "done": "تم"
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 5dd448d8e..b3a56b969 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copia com a fitxer",
"copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
"copy_failed": "No s'ha pogut copiar el fitxer."
+ },
+ "desktopReminders": {
+ "generic_title": "Recordatori",
+ "snooze": "Posposa {count} min",
+ "snooze_short": "{count} min",
+ "open": "Obre",
+ "join": "Uneix-te",
+ "missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}",
+ "missed_more": "i {count} més",
+ "missed_show": "Mostra",
+ "missed_dialog_title": "Recordatoris perduts",
+ "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.",
+ "open_calendar": "Obre el calendari",
+ "done": "Fet"
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 567020087..626c9d150 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopírovat jako soubor",
"copied": "Soubor zkopírován. Můžete ho vložit do složky.",
"copy_failed": "Soubor se nepodařilo zkopírovat."
+ },
+ "desktopReminders": {
+ "generic_title": "Připomenutí",
+ "snooze": "Odložit o {count} min",
+ "snooze_short": "{count} min",
+ "open": "Otevřít",
+ "join": "Připojit se",
+ "missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}",
+ "missed_more": "a další ({count})",
+ "missed_show": "Zobrazit",
+ "missed_dialog_title": "Zmeškaná připomenutí",
+ "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.",
+ "open_calendar": "Otevřít kalendář",
+ "done": "Hotovo"
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index 352cd2805..5c9d9a580 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiér som fil",
"copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
+ },
+ "desktopReminders": {
+ "generic_title": "Påmindelse",
+ "snooze": "Udsæt {count} min.",
+ "snooze_short": "{count} min.",
+ "open": "Åbn",
+ "join": "Deltag",
+ "missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}",
+ "missed_more": "og {count} mere",
+ "missed_show": "Vis",
+ "missed_dialog_title": "Oversete påmindelser",
+ "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.",
+ "open_calendar": "Åbn kalender",
+ "done": "Færdig"
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index 65fa8c8dd..ea62fa855 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Als Datei kopieren",
"copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
"copy_failed": "Die Datei konnte nicht kopiert werden."
+ },
+ "desktopReminders": {
+ "generic_title": "Erinnerung",
+ "snooze": "{count} Min. schlummern",
+ "snooze_short": "{count} Min.",
+ "open": "Öffnen",
+ "join": "Teilnehmen",
+ "missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}",
+ "missed_more": "und {count} weitere",
+ "missed_show": "Anzeigen",
+ "missed_dialog_title": "Verpasste Erinnerungen",
+ "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.",
+ "open_calendar": "Kalender öffnen",
+ "done": "Fertig"
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 4512e406e..7857501d3 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copy as file",
"copied": "File copied. You can paste it into a folder.",
"copy_failed": "The file could not be copied."
+ },
+ "desktopReminders": {
+ "generic_title": "Reminder",
+ "snooze": "Snooze {count} min",
+ "snooze_short": "{count} min",
+ "open": "Open",
+ "join": "Join",
+ "missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}",
+ "missed_more": "and {count} more",
+ "missed_show": "Show",
+ "missed_dialog_title": "Reminders you missed",
+ "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.",
+ "open_calendar": "Open calendar",
+ "done": "Done"
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 6ed72a0f4..7542536a1 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiar como archivo",
"copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
"copy_failed": "No se pudo copiar el archivo."
+ },
+ "desktopReminders": {
+ "generic_title": "Recordatorio",
+ "snooze": "Posponer {count} min",
+ "snooze_short": "{count} min",
+ "open": "Abrir",
+ "join": "Unirse",
+ "missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}",
+ "missed_more": "y {count} más",
+ "missed_show": "Mostrar",
+ "missed_dialog_title": "Recordatorios perdidos",
+ "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.",
+ "open_calendar": "Abrir calendario",
+ "done": "Listo"
}
}
diff --git a/locales/et/common.json b/locales/et/common.json
index d0d6356b6..40abb6d20 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopeeri failina",
"copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
"copy_failed": "Faili ei saanud kopeerida."
+ },
+ "desktopReminders": {
+ "generic_title": "Meeldetuletus",
+ "snooze": "Lükka {count} min edasi",
+ "snooze_short": "{count} min",
+ "open": "Ava",
+ "join": "Liitu",
+ "missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}",
+ "missed_more": "ja veel {count}",
+ "missed_show": "Näita",
+ "missed_dialog_title": "Kasutamata meeldetuletused",
+ "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.",
+ "open_calendar": "Ava kalender",
+ "done": "Valmis"
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index de207bb10..8d5df86ea 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "رونوشت بهصورت پرونده",
"copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.",
"copy_failed": "رونوشت پرونده ممکن نشد."
+ },
+ "desktopReminders": {
+ "generic_title": "یادآوری",
+ "snooze": "تعویق {count} دقیقه",
+ "snooze_short": "{count} دقیقه",
+ "open": "باز کردن",
+ "join": "پیوستن",
+ "missed_title": "{count, plural, one {# یادآوری از دسترفته} other {# یادآوری از دسترفته}}",
+ "missed_more": "و {count} مورد دیگر",
+ "missed_show": "نمایش",
+ "missed_dialog_title": "یادآوریهای از دسترفته",
+ "missed_dialog_description": "این یادآوریها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلانها متوقف بودند.",
+ "open_calendar": "باز کردن تقویم",
+ "done": "تمام"
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index b51367064..93edc4bd6 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copier en tant que fichier",
"copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
"copy_failed": "Le fichier n'a pas pu être copié."
+ },
+ "desktopReminders": {
+ "generic_title": "Rappel",
+ "snooze": "Reporter de {count} min",
+ "snooze_short": "{count} min",
+ "open": "Ouvrir",
+ "join": "Rejoindre",
+ "missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}",
+ "missed_more": "et {count} de plus",
+ "missed_show": "Afficher",
+ "missed_dialog_title": "Rappels manqués",
+ "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.",
+ "open_calendar": "Ouvrir le calendrier",
+ "done": "Terminé"
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index 613e36392..6d227dd04 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6592,5 +6592,19 @@
"copy_as_file": "העתקה כקובץ",
"copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
"copy_failed": "לא ניתן להעתיק את הקובץ."
+ },
+ "desktopReminders": {
+ "generic_title": "תזכורת",
+ "snooze": "דחייה ב-{count} דק'",
+ "snooze_short": "{count} דק'",
+ "open": "פתיחה",
+ "join": "הצטרפות",
+ "missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}",
+ "missed_more": "ועוד {count}",
+ "missed_show": "הצגה",
+ "missed_dialog_title": "תזכורות שהוחמצו",
+ "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.",
+ "open_calendar": "פתיחת היומן",
+ "done": "סיום"
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index b72bcfc0d..1794a8340 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Másolás fájlként",
"copied": "Fájl másolva. Beillesztheti egy mappába.",
"copy_failed": "A fájlt nem sikerült másolni."
+ },
+ "desktopReminders": {
+ "generic_title": "Emlékeztető",
+ "snooze": "Halasztás {count} perccel",
+ "snooze_short": "{count} perc",
+ "open": "Megnyitás",
+ "join": "Csatlakozás",
+ "missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}",
+ "missed_more": "és még {count}",
+ "missed_show": "Megjelenítés",
+ "missed_dialog_title": "Elmulasztott emlékeztetők",
+ "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.",
+ "open_calendar": "Naptár megnyitása",
+ "done": "Kész"
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index 421714d43..aaa5bc1ed 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copia come file",
"copied": "File copiato. Puoi incollarlo in una cartella.",
"copy_failed": "Impossibile copiare il file."
+ },
+ "desktopReminders": {
+ "generic_title": "Promemoria",
+ "snooze": "Rimanda di {count} min",
+ "snooze_short": "{count} min",
+ "open": "Apri",
+ "join": "Partecipa",
+ "missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}",
+ "missed_more": "e altri {count}",
+ "missed_show": "Mostra",
+ "missed_dialog_title": "Promemoria persi",
+ "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.",
+ "open_calendar": "Apri calendario",
+ "done": "Fatto"
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 1b72f2fb8..b072304ec 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "ファイルとしてコピー",
"copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
"copy_failed": "ファイルをコピーできませんでした。"
+ },
+ "desktopReminders": {
+ "generic_title": "リマインダー",
+ "snooze": "{count} 分後に再通知",
+ "snooze_short": "{count} 分",
+ "open": "開く",
+ "join": "参加",
+ "missed_title": "{count, plural, other {見逃したリマインダー # 件}}",
+ "missed_more": "ほか {count} 件",
+ "missed_show": "表示",
+ "missed_dialog_title": "見逃したリマインダー",
+ "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。",
+ "open_calendar": "カレンダーを開く",
+ "done": "完了"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index ce7ac48d8..9b06f3d24 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "파일로 복사",
"copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
"copy_failed": "파일을 복사할 수 없습니다."
+ },
+ "desktopReminders": {
+ "generic_title": "알림",
+ "snooze": "{count}분 후 다시 알림",
+ "snooze_short": "{count}분",
+ "open": "열기",
+ "join": "참가",
+ "missed_title": "{count, plural, other {놓친 알림 #개}}",
+ "missed_more": "외 {count}개",
+ "missed_show": "보기",
+ "missed_dialog_title": "놓친 알림",
+ "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.",
+ "open_calendar": "캘린더 열기",
+ "done": "완료"
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index b45fabbb4..891aeb6e2 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopēt kā failu",
"copied": "Fails nokopēts. Varat to ielīmēt mapē.",
"copy_failed": "Failu neizdevās nokopēt."
+ },
+ "desktopReminders": {
+ "generic_title": "Atgādinājums",
+ "snooze": "Atlikt par {count} min",
+ "snooze_short": "{count} min",
+ "open": "Atvērt",
+ "join": "Pievienoties",
+ "missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}",
+ "missed_more": "un vēl {count}",
+ "missed_show": "Rādīt",
+ "missed_dialog_title": "Nokavētie atgādinājumi",
+ "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.",
+ "open_calendar": "Atvērt kalendāru",
+ "done": "Gatavs"
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 243f02639..2a182da0e 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Файл болгож хуулах",
"copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
"copy_failed": "Файлыг хуулж чадсангүй."
+ },
+ "desktopReminders": {
+ "generic_title": "Сануулга",
+ "snooze": "{count} минут хойшлуулах",
+ "snooze_short": "{count} мин",
+ "open": "Нээх",
+ "join": "Нэгдэх",
+ "missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}",
+ "missed_more": "бас {count}",
+ "missed_show": "Харуулах",
+ "missed_dialog_title": "Алдсан сануулгууд",
+ "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.",
+ "open_calendar": "Хуанли нээх",
+ "done": "Болсон"
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index f2f0c0de7..3573c7773 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopier som fil",
"copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
+ },
+ "desktopReminders": {
+ "generic_title": "Påminnelse",
+ "snooze": "Utsett {count} min",
+ "snooze_short": "{count} min",
+ "open": "Åpne",
+ "join": "Bli med",
+ "missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}",
+ "missed_more": "og {count} til",
+ "missed_show": "Vis",
+ "missed_dialog_title": "Tapte påminnelser",
+ "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.",
+ "open_calendar": "Åpne kalender",
+ "done": "Ferdig"
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 6ab31ef9b..17329f729 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiëren als bestand",
"copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
"copy_failed": "Het bestand kon niet worden gekopieerd."
+ },
+ "desktopReminders": {
+ "generic_title": "Herinnering",
+ "snooze": "{count} min uitstellen",
+ "snooze_short": "{count} min",
+ "open": "Openen",
+ "join": "Deelnemen",
+ "missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}",
+ "missed_more": "en nog {count}",
+ "missed_show": "Tonen",
+ "missed_dialog_title": "Gemiste herinneringen",
+ "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.",
+ "open_calendar": "Agenda openen",
+ "done": "Klaar"
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 4590d48b3..6b50e2feb 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiuj jako plik",
"copied": "Plik skopiowany. Możesz wkleić go do folderu.",
"copy_failed": "Nie udało się skopiować pliku."
+ },
+ "desktopReminders": {
+ "generic_title": "Przypomnienie",
+ "snooze": "Odłóż o {count} min",
+ "snooze_short": "{count} min",
+ "open": "Otwórz",
+ "join": "Dołącz",
+ "missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}",
+ "missed_more": "i jeszcze {count}",
+ "missed_show": "Pokaż",
+ "missed_dialog_title": "Pominięte przypomnienia",
+ "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.",
+ "open_calendar": "Otwórz kalendarz",
+ "done": "Gotowe"
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 97ed21d66..0b67e7311 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiar como ficheiro",
"copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
"copy_failed": "Não foi possível copiar o ficheiro."
+ },
+ "desktopReminders": {
+ "generic_title": "Lembrete",
+ "snooze": "Adiar {count} min",
+ "snooze_short": "{count} min",
+ "open": "Abrir",
+ "join": "Entrar",
+ "missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}",
+ "missed_more": "e mais {count}",
+ "missed_show": "Mostrar",
+ "missed_dialog_title": "Lembretes perdidos",
+ "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.",
+ "open_calendar": "Abrir calendário",
+ "done": "Concluído"
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 0936d12f4..f03956657 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiază ca fișier",
"copied": "Fișier copiat. Îl poți lipi într-un dosar.",
"copy_failed": "Fișierul nu a putut fi copiat."
+ },
+ "desktopReminders": {
+ "generic_title": "Memento",
+ "snooze": "Amână {count} min",
+ "snooze_short": "{count} min",
+ "open": "Deschide",
+ "join": "Participă",
+ "missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}",
+ "missed_more": "și încă {count}",
+ "missed_show": "Afișează",
+ "missed_dialog_title": "Mementouri ratate",
+ "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.",
+ "open_calendar": "Deschide calendarul",
+ "done": "Gata"
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index eb1641895..32c3a46a1 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Копировать как файл",
"copied": "Файл скопирован. Его можно вставить в папку.",
"copy_failed": "Не удалось скопировать файл."
+ },
+ "desktopReminders": {
+ "generic_title": "Напоминание",
+ "snooze": "Отложить на {count} мин",
+ "snooze_short": "{count} мин",
+ "open": "Открыть",
+ "join": "Подключиться",
+ "missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}",
+ "missed_more": "и ещё {count}",
+ "missed_show": "Показать",
+ "missed_dialog_title": "Пропущенные напоминания",
+ "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.",
+ "open_calendar": "Открыть календарь",
+ "done": "Готово"
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 4431aa2a7..cb16abd32 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopírovať ako súbor",
"copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
"copy_failed": "Súbor sa nepodarilo skopírovať."
+ },
+ "desktopReminders": {
+ "generic_title": "Pripomienka",
+ "snooze": "Odložiť o {count} min",
+ "snooze_short": "{count} min",
+ "open": "Otvoriť",
+ "join": "Pripojiť sa",
+ "missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}",
+ "missed_more": "a ďalšie ({count})",
+ "missed_show": "Zobraziť",
+ "missed_dialog_title": "Zmeškané pripomienky",
+ "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.",
+ "open_calendar": "Otvoriť kalendár",
+ "done": "Hotovo"
}
}
diff --git a/locales/th/common.json b/locales/th/common.json
index 47156ac85..594e81712 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6599,5 +6599,19 @@
"copy_as_file": "คัดลอกเป็นไฟล์",
"copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
"copy_failed": "คัดลอกไฟล์ไม่ได้"
+ },
+ "desktopReminders": {
+ "generic_title": "การแจ้งเตือน",
+ "snooze": "เลื่อน {count} นาที",
+ "snooze_short": "{count} นาที",
+ "open": "เปิด",
+ "join": "เข้าร่วม",
+ "missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}",
+ "missed_more": "และอีก {count} รายการ",
+ "missed_show": "แสดง",
+ "missed_dialog_title": "การแจ้งเตือนที่พลาด",
+ "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว",
+ "open_calendar": "เปิดปฏิทิน",
+ "done": "เสร็จ"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 391215501..946a1ab7b 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Dosya olarak kopyala",
"copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
"copy_failed": "Dosya kopyalanamadı."
+ },
+ "desktopReminders": {
+ "generic_title": "Anımsatıcı",
+ "snooze": "{count} dk ertele",
+ "snooze_short": "{count} dk",
+ "open": "Aç",
+ "join": "Katıl",
+ "missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}",
+ "missed_more": "ve {count} tane daha",
+ "missed_show": "Göster",
+ "missed_dialog_title": "Kaçırılan anımsatıcılar",
+ "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.",
+ "open_calendar": "Takvimi aç",
+ "done": "Bitti"
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index ebed90c4e..45a4898b1 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "Копіювати як файл",
"copied": "Файл скопійовано. Його можна вставити в папку.",
"copy_failed": "Не вдалося скопіювати файл."
+ },
+ "desktopReminders": {
+ "generic_title": "Нагадування",
+ "snooze": "Відкласти на {count} хв",
+ "snooze_short": "{count} хв",
+ "open": "Відкрити",
+ "join": "Приєднатися",
+ "missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}",
+ "missed_more": "і ще {count}",
+ "missed_show": "Показати",
+ "missed_dialog_title": "Пропущені нагадування",
+ "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.",
+ "open_calendar": "Відкрити календар",
+ "done": "Готово"
}
}
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 86de8e353..51222b054 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6603,5 +6603,19 @@
"copy_as_file": "Sao chép dưới dạng tệp",
"copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
"copy_failed": "Không thể sao chép tệp."
+ },
+ "desktopReminders": {
+ "generic_title": "Lời nhắc",
+ "snooze": "Báo lại sau {count} phút",
+ "snooze_short": "{count} phút",
+ "open": "Mở",
+ "join": "Tham gia",
+ "missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}",
+ "missed_more": "và {count} lời nhắc khác",
+ "missed_show": "Hiển thị",
+ "missed_dialog_title": "Lời nhắc đã bỏ lỡ",
+ "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.",
+ "open_calendar": "Mở lịch",
+ "done": "Xong"
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index f3fb5a71a..8004dc647 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "複製為檔案",
"copied": "檔案已複製。您可以將其貼到資料夾中。",
"copy_failed": "無法複製檔案。"
+ },
+ "desktopReminders": {
+ "generic_title": "提醒",
+ "snooze": "{count} 分鐘後再提醒",
+ "snooze_short": "{count} 分鐘",
+ "open": "開啟",
+ "join": "加入",
+ "missed_title": "{count, plural, other {# 則錯過的提醒}}",
+ "missed_more": "還有 {count} 則",
+ "missed_show": "顯示",
+ "missed_dialog_title": "錯過的提醒",
+ "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。",
+ "open_calendar": "開啟日曆",
+ "done": "完成"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 3058a98c8..58a804589 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6591,5 +6591,19 @@
"copy_as_file": "复制为文件",
"copied": "文件已复制。你可以将其粘贴到文件夹中。",
"copy_failed": "无法复制文件。"
+ },
+ "desktopReminders": {
+ "generic_title": "提醒",
+ "snooze": "{count} 分钟后再提醒",
+ "snooze_short": "{count} 分钟",
+ "open": "打开",
+ "join": "加入",
+ "missed_title": "{count, plural, other {# 条错过的提醒}}",
+ "missed_more": "还有 {count} 条",
+ "missed_show": "显示",
+ "missed_dialog_title": "错过的提醒",
+ "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。",
+ "open_calendar": "打开日历",
+ "done": "完成"
}
}
diff --git a/public/sw.js b/public/sw.js
index 22e4174ba..581108f36 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1096,6 +1096,7 @@ function getReusableClientScore(state) {
function buildClickUrl(data, reply) {
if (!data) return `${BASE_PATH}/`;
+ if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`;
const params = new URLSearchParams();
if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts
index da0a266ea..8e7d41983 100644
--- a/stores/calendar-notification-store.ts
+++ b/stores/calendar-notification-store.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
+import { clearClaims } from '@/lib/calendar-reminders/claims';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
@@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create()(
clearAll: () => {
set({ acknowledgedAlerts: {} });
+ clearClaims();
},
}),
{
From 580a7a645d7967eb07febb6444c6eccc3dbd4de6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:50:21 +0100
Subject: [PATCH 10/24] test: check that signing out clears what the desktop
app shows outside its windows
---
lib/desktop/__tests__/os-signout.test.ts | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 lib/desktop/__tests__/os-signout.test.ts
diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts
new file mode 100644
index 000000000..d640b50b7
--- /dev/null
+++ b/lib/desktop/__tests__/os-signout.test.ts
@@ -0,0 +1,24 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/** A sign-out leaves nothing of the account on the OS's surfaces. */
+
+vi.setConfig({ testTimeout: 60_000 });
+
+describe('sign-out', () => {
+ afterEach(() => {
+ window.bulwarkDesktop = undefined;
+ });
+
+ it('clears what the desktop app shows outside its windows', async () => {
+ const clearSurfaces = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
+ const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup');
+ purgeSignedOutData();
+ expect(clearSurfaces).toHaveBeenCalled();
+ });
+
+ it('in a browser there is nothing to clear', async () => {
+ const { clearDesktopSurfaces } = await import('../bridge');
+ expect(() => clearDesktopSurfaces()).not.toThrow();
+ });
+});
From 0872e094a2befe07b929cc27e9fb78c515a495e1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:57:03 +0100
Subject: [PATCH 11/24] test: check that wiping one of several accounts clears
what the desktop app shows
---
lib/desktop/__tests__/os-store.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
index e09aee90d..5b793cd9f 100644
--- a/lib/desktop/__tests__/os-store.test.ts
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -55,6 +55,9 @@ describe('the store and the OS integration', () => {
const clearSurfaces = vi.fn();
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
const { wipeAccount } = await import('@/lib/offline/offline-mode');
+ const { useOfflineStore } = await import('@/lib/offline/state');
+ // Not the last account, so this isn't the wipe of everything, which clears as well.
+ useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] });
await wipeAccount('alice@one').catch(() => {});
expect(clearSurfaces).toHaveBeenCalled();
window.bulwarkDesktop = undefined;
From 8c0a45b865a4628697b660f9fe05ba7d841e393f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:57:28 +0100
Subject: [PATCH 12/24] test: leave no enabled accounts behind in the store and
OS integration test
---
lib/desktop/__tests__/os-store.test.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
index 5b793cd9f..925e4d024 100644
--- a/lib/desktop/__tests__/os-store.test.ts
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -60,6 +60,7 @@ describe('the store and the OS integration', () => {
useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] });
await wipeAccount('alice@one').catch(() => {});
expect(clearSurfaces).toHaveBeenCalled();
+ useOfflineStore.setState({ enabled: [] });
window.bulwarkDesktop = undefined;
});
From 099e3fa8875a90b08dd43eb43d30edc3b55f0755 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 13:09:37 +0100
Subject: [PATCH 13/24] test: check calendar reminders in the browser on both
servers: offline, one tab, system notification, missed summary
---
integration/tests/24-do-reminders.spec.ts | 226 ++++++++++++++++++++
lib/store/__tests__/calendar-no-dom.test.ts | 2 +-
2 files changed, 227 insertions(+), 1 deletion(-)
create mode 100644 integration/tests/24-do-reminders.spec.ts
diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts
new file mode 100644
index 000000000..19699dcce
--- /dev/null
+++ b/integration/tests/24-do-reminders.spec.ts
@@ -0,0 +1,226 @@
+import { test, expect, type BrowserContext, type Page } from '@playwright/test';
+import { ENT, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent';
+
+/**
+ * Calendar reminders from the local store, in a real browser on a production build and
+ * a VM 106 test Stalwart, with real time passing: the store's worker has its own clock.
+ *
+ * The account is ent-dave, whom the other specs leave alone. Snooze and the native
+ * buttons belong to the desktop app: repos/desktop test/reminders-real.spec.ts.
+ */
+
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(900_000);
+// The headless shell denies notifications; full Chromium really shows them.
+test.use({ channel: 'chromium' });
+
+const RUN = Date.now().toString(36);
+const USER = ENT.dave;
+const ZONE = 'Europe/Berlin';
+const CORE = 'urn:ietf:params:jmap:core';
+const CALENDARS = 'urn:ietf:params:jmap:calendars';
+/** ledger.ts AWAY_MS and a margin. */
+const AWAY_MS = 6.5 * 60_000;
+/** How long a change on the server may take to reach the store. */
+const SYNC_MS = 30_000;
+
+type J = Awaited>;
+let client: J;
+let context: BrowserContext;
+let page: Page;
+const created: string[] = [];
+
+async function call(calls: [string, Record, string][]) {
+ const response = await client.request(calls, [CORE, CALENDARS]);
+ for (const [name, result] of response.methodResponses) {
+ if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`);
+ }
+ return response.methodResponses as [string, Record, string][];
+}
+
+/** A wall clock in the event's zone. */
+function wall(ms: number): string {
+ const parts = new Intl.DateTimeFormat('sv-SE', {
+ timeZone: ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
+ }).format(new Date(ms));
+ return parts.replace(' ', 'T');
+}
+
+const OFFSET_MIN = 5;
+
+/** An event whose alert fires at `fireAt`. */
+async function eventFiringAt(title: string, fireAt: number, extra: Record = {}): Promise {
+ const [[, calendars]] = await call([['Calendar/get', { accountId: client.accountId, properties: ['isDefault'] }, '0']]);
+ const list = calendars.list as Array<{ id: string; isDefault: boolean }>;
+ const calendar = list.find((entry) => entry.isDefault) ?? list[0];
+ const [[, result]] = await call([['CalendarEvent/set', {
+ accountId: client.accountId,
+ create: {
+ e: {
+ '@type': 'Event',
+ calendarIds: { [calendar.id]: true },
+ timeZone: ZONE,
+ title,
+ start: wall(fireAt + OFFSET_MIN * 60_000),
+ duration: 'PT30M',
+ useDefaultAlerts: false,
+ alerts: { a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: `-PT${OFFSET_MIN}M`, relativeTo: 'start' } } },
+ ...extra,
+ },
+ },
+ sendSchedulingMessages: false,
+ }, '0']]);
+ const id = (result.created as Record | undefined)?.e?.id;
+ if (!id) throw new Error(`Event not created: ${JSON.stringify(result)}`);
+ created.push(id);
+ return id;
+}
+
+/** Settings > Backup & reset > Offline, switched on, and the first sync done. */
+async function enableOffline(target: Page): Promise {
+ await target.goto('/settings/backup/offline');
+ const toggle = target.getByTestId('offline-switch');
+ await toggle.waitFor({ state: 'visible', timeout: 60_000 });
+ if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
+ await expect(target.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
+}
+
+const toast = (target: Page, text: string) => target.locator('.toast-item').filter({ hasText: text });
+
+test.beforeAll(async ({ browser }) => {
+ requireEntEnv();
+ client = await jmapAs(USER);
+ page = await userPage(browser, USER);
+ context = page.context();
+ await enableOffline(page);
+ await page.goto('/');
+});
+
+test.afterAll(async () => {
+ if (created.length > 0) {
+ await call([['CalendarEvent/set', { accountId: client.accountId, destroy: created, sendSchedulingMessages: false }, '0']]).catch(() => {});
+ }
+ await context?.close();
+});
+
+test('an occurrence of a series fires from the store with the network gone', async () => {
+ const title = `rem-series-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 30_000;
+ // The series began weeks ago, so only an expansion finds today's occurrence.
+ const [day, time] = wall(fireAt + OFFSET_MIN * 60_000).split('T');
+ const weeksAgo = new Date(Date.parse(`${day}T00:00:00Z`) - 28 * 24 * 3_600_000).toISOString().slice(0, 10);
+ await eventFiringAt(title, fireAt, {
+ start: `${weeksAgo}T${time}`,
+ recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
+ });
+ await page.waitForTimeout(SYNC_MS);
+ await context.setOffline(true);
+ try {
+ expect(await page.evaluate(() => navigator.onLine)).toBe(false);
+ await page.waitForTimeout(Math.max(fireAt - Date.now() - 3000, 0));
+ await expect(toast(page, title)).toHaveCount(0);
+ await expect(toast(page, title)).toBeVisible({ timeout: 30_000 });
+ await shot(page, 'desktop-reminders-offline-toast');
+ } finally {
+ await context.setOffline(false);
+ }
+ await toast(page, title).getByRole('button').first().click().catch(() => {});
+});
+
+test('fires in one tab of two', async () => {
+ const second = await context.newPage();
+ await second.goto('/');
+ const title = `rem-tabs-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 20_000;
+ await eventFiringAt(title, fireAt);
+ // A reminder's toast leaves after a while, so note the most each tab ever showed.
+ const most = [0, 0];
+ const deadline = fireAt + 40_000;
+ let lastSeen = 0;
+ while (Date.now() < deadline && (lastSeen === 0 || Date.now() < lastSeen + 8000)) {
+ const counts = await Promise.all([page, second].map((target) => toast(target, title).count()));
+ counts.forEach((count, index) => {
+ most[index] = Math.max(most[index], count);
+ });
+ if (lastSeen === 0 && counts.some((count) => count > 0)) lastSeen = Date.now();
+ await second.waitForTimeout(250);
+ }
+ expect(most[0] + most[1]).toBe(1);
+ await second.close();
+ await page.bringToFront();
+});
+
+test('adds a system notification when permission is granted and the tab is not in front', async () => {
+ await context.grantPermissions(['notifications']);
+ await page.reload();
+ await page.evaluate(() => {
+ document.hasFocus = () => false;
+ });
+ const title = `rem-system-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 15_000;
+ await eventFiringAt(title, fireAt);
+ await expect(toast(page, title)).toBeVisible({ timeout: fireAt - Date.now() + 30_000 });
+ const shown = await page.evaluate(async (wanted) => {
+ const registration = await navigator.serviceWorker.ready;
+ return (await registration.getNotifications()).filter((item) => item.title === wanted).map((item) => ({ tag: item.tag, data: item.data as unknown }));
+ }, title);
+ expect(shown).toHaveLength(1);
+ expect(shown[0].tag).toContain('calendar-reminder:');
+ expect(shown[0].data).toEqual({ kind: 'calendar-reminder' });
+ await context.clearPermissions();
+});
+
+test('what came due with the app closed is summarised once, and not as a burst', async () => {
+ const one = `rem-missed-a-${RUN}`;
+ const two = `rem-missed-b-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 20_000;
+ await eventFiringAt(one, fireAt);
+ await eventFiringAt(two, fireAt + 20_000, { virtualLocations: { v: { '@type': 'VirtualLocation', name: 'Meet', uri: 'https://meet.jit.si/rem-test' } } });
+ await page.waitForTimeout(SYNC_MS);
+
+ // Closed like an app: nothing of the page is left, and the store's data stays.
+ const closedAt = Date.now();
+ await page.close();
+ await new Promise((resolve) => setTimeout(resolve, Math.max(fireAt + 20_000 + AWAY_MS - closedAt, AWAY_MS)));
+
+ page = await context.newPage();
+ await page.goto('/');
+ const summary = page.locator('.toast-item').filter({ hasText: one });
+ await expect(summary).toBeVisible({ timeout: 60_000 });
+ await expect(summary).toContainText(two);
+ await expect(toast(page, one)).toHaveCount(1);
+ await shot(page, 'desktop-reminders-missed-toast');
+
+ await summary.getByRole('button', { name: 'Show' }).click();
+ const list = page.getByTestId('missed-reminders');
+ await expect(list).toContainText(one);
+ await expect(list).toContainText(two);
+ await expect(list.getByRole('button', { name: 'Join' })).toHaveCount(1);
+ await shot(page, 'desktop-reminders-missed');
+ await page.getByRole('button', { name: 'Done' }).click();
+ await expect(list).toHaveCount(0);
+
+ // Once: not after a reload, nor from a new tab.
+ await page.reload();
+ await page.waitForTimeout(30_000);
+ await expect(page.locator('.toast-item').filter({ hasText: 'rem-missed' })).toHaveCount(0);
+ await expect(page.getByTestId('missed-reminders')).toHaveCount(0);
+});
+
+test('with the store off, the app reminds as it always did', async () => {
+ await page.goto('/settings/backup/offline');
+ const toggle = page.getByTestId('offline-switch');
+ await toggle.waitFor({ state: 'visible', timeout: 60_000 });
+ await toggle.click();
+ const confirm = page.getByRole('button', { name: /turn off|switch off|delete/i }).last();
+ if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click();
+ await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 });
+ await page.goto('/');
+ const title = `rem-plain-${RUN}`;
+ const fireAt = Date.now() + 20_000;
+ await eventFiringAt(title, fireAt);
+ // The in-app path reads the server every five minutes, or on a push.
+ await expect(toast(page, title)).toBeVisible({ timeout: 120_000 });
+ await page.waitForTimeout(5000);
+ await expect(toast(page, title)).toHaveCount(1);
+});
diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts
index 8cd3d10a1..1fcd8543f 100644
--- a/lib/store/__tests__/calendar-no-dom.test.ts
+++ b/lib/store/__tests__/calendar-no-dom.test.ts
@@ -22,7 +22,7 @@ async function inputsOf(entry: string): Promise {
}
describe('what the worker pulls in', () => {
- for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) {
+ for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts', 'lib/store/reminders/index.ts']) {
it(`${entry} reaches no UI code`, async () => {
const inputs = await inputsOf(entry);
expect(inputs.length).toBeGreaterThan(3);
From a52a39f9e64e9d1586d3dc90c5623b4caaf59db2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 15:40:10 +0100
Subject: [PATCH 14/24] fix: keep one bad event from stopping reminders, offer
Join for web links only, and keep summaries until they are seen
---
.../calendar/missed-reminders-dialog.tsx | 12 +-
.../use-store-reminders-review.test.tsx | 442 ++++++++++++++++++
hooks/__tests__/use-store-reminders.test.tsx | 5 +-
hooks/use-calendar-alerts.ts | 42 +-
hooks/use-store-reminders.ts | 78 +++-
integration/tests/24-do-reminders.spec.ts | 2 +-
.../__tests__/claims.test.ts | 50 +-
.../__tests__/runner.test.ts | 90 +++-
lib/calendar-reminders/claims.ts | 37 +-
lib/calendar-reminders/runner.ts | 80 +++-
lib/calendar-reminders/system-notification.ts | 9 +-
.../__tests__/reminder-notifications.test.ts | 78 +++-
lib/desktop/reminder-notifications.ts | 73 ++-
lib/event-links.ts | 29 ++
lib/store/__tests__/reminders-review.test.ts | 405 ++++++++++++++++
lib/store/__tests__/reminders.test.ts | 3 +-
lib/store/calendar/reminders.ts | 229 ++++++---
lib/store/reminders/index.ts | 17 +-
lib/store/reminders/ledger.ts | 68 ++-
lib/store/reminders/schema.ts | 4 +
locales/ar/common.json | 4 +-
locales/ca/common.json | 4 +-
locales/cs/common.json | 4 +-
locales/da/common.json | 4 +-
locales/de/common.json | 4 +-
locales/en/common.json | 4 +-
locales/es/common.json | 4 +-
locales/et/common.json | 4 +-
locales/fa/common.json | 4 +-
locales/fr/common.json | 4 +-
locales/he/common.json | 4 +-
locales/hu/common.json | 4 +-
locales/it/common.json | 4 +-
locales/ja/common.json | 4 +-
locales/ko/common.json | 4 +-
locales/lv/common.json | 4 +-
locales/mn/common.json | 4 +-
locales/nb/common.json | 4 +-
locales/nl/common.json | 4 +-
locales/pl/common.json | 4 +-
locales/pt/common.json | 4 +-
locales/ro/common.json | 4 +-
locales/ru/common.json | 4 +-
locales/sk/common.json | 4 +-
locales/th/common.json | 4 +-
locales/tr/common.json | 4 +-
locales/uk/common.json | 4 +-
locales/vi/common.json | 4 +-
locales/zh-TW/common.json | 4 +-
locales/zh/common.json | 4 +-
public/sw.js | 5 +-
51 files changed, 1646 insertions(+), 232 deletions(-)
create mode 100644 hooks/__tests__/use-store-reminders-review.test.tsx
create mode 100644 lib/store/__tests__/reminders-review.test.ts
diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx
index 6309994fd..2b4ed8af3 100644
--- a/components/calendar/missed-reminders-dialog.tsx
+++ b/components/calendar/missed-reminders-dialog.tsx
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { getPathPrefix } from "@/lib/browser-navigation";
import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
-import { dismissReminders, joinMeeting } from "@/lib/desktop/reminder-notifications";
+import { dismissReminders, joinLabel, joinMeeting } from "@/lib/desktop/reminder-notifications";
import { useSettingsStore } from "@/stores/settings-store";
/** What came due while the app was closed, the machine slept or notifications were paused. */
@@ -17,6 +17,7 @@ export function MissedRemindersDialog() {
const account = useMissedReminders((s) => s.account);
const alerts = useMissedReminders((s) => s.alerts);
const close = useMissedReminders((s) => s.close);
+ const joinLabels = { join: t("join"), joinHost: (host: string) => t("join_host", { host }) };
if (!account || alerts.length === 0) return null;
@@ -58,8 +59,13 @@ export function MissedRemindersDialog() {
{alert.joinUrl && (
-