Merge branch 'feat/do-os-integration' into feat/do-reminders

# Conflicts:
#	lib/desktop/types.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
This commit is contained in:
Linus Rath
2026-11-03 11:57:45 +01:00
64 changed files with 3555 additions and 12 deletions
+2
View File
@@ -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({
<MainWindowOnly>
<ImpersonationReconciler />
<PushBindingsHost />
<DesktopOsHost />
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
+101
View File
@@ -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<MailNotifications | null>(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<IJMAPClient>(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;
}
+4
View File
@@ -171,6 +171,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import type { RescuedDraft } from "@/lib/popout/channel";
import { toUnicodeEmail } from "@/lib/idn";
import { setComposerFileTarget } from "@/lib/desktop/window-files";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -2649,6 +2650,9 @@ export function EmailComposer({
resetDragTimeout();
}, [resetDragTimeout]);
// The desktop app: files dropped or pasted anywhere on the window come here.
useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
+10
View File
@@ -677,6 +677,7 @@ interface DraggableAttachmentChipProps {
onPointerEnter: () => void;
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
onCopyAsFile?: () => Promise<boolean>;
}) => React.ReactNode;
}
@@ -684,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo<AttachmentDragSource>(() => ({
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 {
+25 -1
View File
@@ -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<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
/** The desktop app only. */
onCopyAsFile?: () => Promise<boolean>;
}
/** Past this many tiles the bar shows one row less and a "Show N more" tile. */
@@ -159,6 +162,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
{!downloadsOff && (
<IconButton
label={t("download_named", { name })}
@@ -229,6 +233,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
{!downloadsOff && (
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
<Download className="h-4 w-4" />
@@ -332,6 +337,25 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
* 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<boolean> }) {
const t = useTranslations("desktopOs");
return (
<IconButton
label={t("copy_as_file")}
className="text-muted-foreground hover:text-foreground"
onClick={() => {
void copy().then((ok) => {
if (ok) toast.success(t("copied"));
else toast.error(t("copy_failed"));
}, () => toast.error(t("copy_failed")));
}}
>
<Copy className="h-4 w-4" />
</IconButton>
);
}
function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) {
if (url) {
return <img src={url} alt="" loading="lazy" className="h-12 w-12 shrink-0 rounded-md bg-muted object-cover" />;
+12
View File
@@ -144,6 +144,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
@@ -2936,6 +2937,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
// The desktop app's tray, jump list and toasts, like any other permalink. A live
// composer's text goes through its own "Save or discard?" first.
useEffect(() => {
return setMailLinkOpener((link, options) => {
const open = () => void applyMailDeepLinkRef.current(link, options);
const requestClose = composerRequestCloseRef.current;
if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open);
else open();
});
}, []);
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
@@ -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;
+120
View File
@@ -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<DesktopOsStatus | null>(null);
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(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<Settings>) => {
void os.setSettings(patch).then(setStatus);
};
return (
<SettingsSection id="desktop-app" title={t("section_title")} description={t("section_description")}>
<SettingItem label={t("close_to_tray_label")} description={t("close_to_tray_description")}>
<ToggleSwitch checked={settings.closeToTray} onChange={(closeToTray) => change({ closeToTray })} />
</SettingItem>
<SettingItem label={t("start_at_login_label")} description={t("start_at_login_description")}>
<ToggleSwitch checked={settings.startAtLogin} onChange={(startAtLogin) => change({ startAtLogin })} />
</SettingItem>
{settings.startAtLogin && (
<SettingItem nested label={t("start_minimized_label")} description={t("start_minimized_description")}>
<ToggleSwitch checked={settings.startMinimized} onChange={(startMinimized) => change({ startMinimized })} />
</SettingItem>
)}
<SettingItem
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
description={t("attention_description")}
>
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
</SettingItem>
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
</SettingItem>
{settings.quietHoursEnabled && (
<SettingItem nested label={t("quiet_hours_from")}>
<div className="flex items-center gap-2">
<input
type="time"
aria-label={t("quiet_hours_from")}
value={settings.quietHoursStart}
onChange={(event) => change({ quietHoursStart: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
<input
type="time"
aria-label={t("quiet_hours_to")}
value={settings.quietHoursEnd}
onChange={(event) => change({ quietHoursEnd: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
</div>
</SettingItem>
)}
<SettingItem
label={t("show_shortcut_label")}
description={settings.showShortcut && !status.shortcutActive
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
>
<ToggleSwitch
checked={!!settings.showShortcut}
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
/>
</SettingItem>
<SettingItem
label={t("default_app_label")}
description={defaultApp?.mailto ? t("default_app_is_default") : t("default_app_description")}
>
<Button
variant="outline"
size="sm"
onClick={() => void os.openDefaultAppSettings().then(setDefaultApp)}
className="text-xs h-7"
>
{t("default_app_button")}
</Button>
</SettingItem>
</SettingsSection>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { LanguageSettings } from './language-settings';
import { DesktopKeystoreSettings } from './desktop-keystore-settings';
import { ProtocolHandlerSettings } from './protocol-handler-settings';
import { DesktopOsSettings } from './desktop-os-settings';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from '@/components/icons';
@@ -25,6 +26,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
<div className="space-y-8">
<LanguageSettings />
<DesktopOsSettings />
{!IS_LITE && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
<DesktopKeystoreSettings />
@@ -731,4 +731,32 @@ describe('message row drag hover wiring', () => {
expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/);
expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB));
});
it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
const email = mail({ subject: 'Hello world' });
const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
await warm(result);
try {
startDrag.mockClear();
// Plain: still a move to a folder, still DownloadURL for Explorer.
expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true);
expect(startDrag).toHaveBeenCalled();
expect(nativeDrag).not.toHaveBeenCalled();
startDrag.mockClear();
const preventDefault = vi.fn();
const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' };
act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent<HTMLDivElement>));
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;
}
});
});
@@ -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');
+9
View File
@@ -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) {
+37 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag";
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
// drop to materialize a real file. Firefox and Safari ignore it, so we only
@@ -33,6 +34,8 @@ export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise<string | null>;
/** The bytes themselves, for the desktop app's native drag. */
getBlob?: () => Promise<Blob | null>;
}
export interface UseAttachmentDragResult {
@@ -40,6 +43,8 @@ export interface UseAttachmentDragResult {
onPointerEnter: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
/** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */
onCopyAsFile?: () => Promise<boolean>;
}
const NOOP_HANDLERS: UseAttachmentDragResult = {
@@ -56,6 +61,8 @@ export function useAttachmentDrag(
const urlRef = useRef<string | null>(null);
const ownedRef = useRef<boolean>(false);
const inFlightRef = useRef<Promise<string | null> | null>(null);
const blobRef = useRef<Blob | null>(null);
const nativeDrag = canDragNativeFiles() && !!source.getBlob;
useEffect(() => {
return () => {
@@ -70,6 +77,19 @@ export function useAttachmentDrag(
const prefetch = useCallback(() => {
if (!enabled) return;
if (nativeDrag) {
if (blobRef.current || inFlightRef.current) return;
inFlightRef.current = source.getBlob!()
.then((blob) => {
blobRef.current = blob;
return null;
})
.catch(() => null)
.finally(() => {
inFlightRef.current = null;
});
return;
}
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
@@ -87,7 +107,7 @@ export function useAttachmentDrag(
.finally(() => {
inFlightRef.current = null;
});
}, [enabled, source]);
}, [enabled, source, nativeDrag]);
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
@@ -95,6 +115,15 @@ export function useAttachmentDrag(
const name = source.name || "download";
const type = source.type || "application/octet-stream";
// In the desktop app the OS's own drag, with the real file on disk first. The bytes
// aren't here yet: fetch them for the next try, as below.
if (nativeDrag) {
e.preventDefault();
if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]);
else prefetch();
return;
}
if (!url) {
// Blob isn't materialized yet. Kick off the fetch so the next attempt
// works, but cancel this drag so the user doesn't get a silent failure
@@ -111,7 +140,7 @@ export function useAttachmentDrag(
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
[source.name, source.type, prefetch],
[source.name, source.type, prefetch, nativeDrag],
);
const handleDragEnd = useCallback(() => {
@@ -125,6 +154,11 @@ export function useAttachmentDrag(
}
}, []);
const copyAsFile = useCallback(async () => {
const blob = blobRef.current ?? await source.getBlob?.();
return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false;
}, [source]);
if (!enabled) return NOOP_HANDLERS;
return {
@@ -132,5 +166,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}),
};
}
+25 -4
View File
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -103,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions
function revokeLater(url: string): void {
// Chromium may read the URL asynchronously after the drop completes.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
setTimeout(() => {
URL.revokeObjectURL(url);
forgetDragBlob(url);
}, 60_000);
}
async function buildEmailZip(emails: RoutedEmail[]): Promise<string> {
@@ -126,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise<string> {
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
return URL.createObjectURL(zipBlob);
return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob);
}
function prefetchEmailBundle(
@@ -224,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (previous?.url) revokeLater(previous.url);
const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null };
singleRef.current = entry;
entry.promise = route.client
.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId)
// The desktop app's native drag needs the bytes as well as the address.
const fetched = canDragNativeFiles()
? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob))
: route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId);
entry.promise = fetched
.then((url) => {
if (url) {
if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url;
@@ -295,6 +302,14 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const url = singleRef.current?.key === route.key ? singleRef.current.url : null;
if (url) {
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
// to stay the page's, because it also moves the message to a folder.
const blob = e.altKey ? keptDragBlob(url) : undefined;
if (blob) {
e.preventDefault();
void startNativeFileDrag([{ name, blob }]);
return;
}
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
@@ -313,6 +328,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
const blob = e.altKey ? keptDragBlob(ready.url) : undefined;
if (blob) {
e.preventDefault();
void startNativeFileDrag([{ name: ready.name, blob }]);
return;
}
e.dataTransfer.setData(
"DownloadURL",
`application/zip:${ready.name}:${ready.url}`,
+3 -1
View File
@@ -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);
+5 -2
View File
@@ -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<typeof setInterval> | 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.
*/
+245
View File
@@ -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();
});
});
+360
View File
@@ -0,0 +1,360 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox } from '@/lib/jmap/types';
/** New mail to a native notification: which messages count, what the toast may say. */
const playNotificationSound = vi.hoisted(() => vi.fn());
vi.mock('@/lib/notification-sound', () => ({ playNotificationSound }));
let pushWithoutContent = false;
vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) }));
const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }];
let client: IJMAPClient;
vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: {
getState: () => ({
activeAccountId: 'alice@one',
client,
getClientForAccount: () => client,
}),
},
}));
const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox;
const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox;
const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox;
const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox;
const MAILBOXES = [INBOX, SENT, NEWS, JUNK];
function email(id: string, over: Partial<Email> = {}): 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<string, Email>, 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<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
beforeEach(async () => {
vi.clearAllMocks();
pushWithoutContent = false;
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({
emailNotificationsEnabled: true,
emailNotificationSound: true,
doNotDisturb: false,
pushNotifyInboxOnly: true,
notificationMutes: { accounts: [], mailboxes: {} },
notificationSoundChoice: 'involved',
});
const { setAppLockCheck } = await import('../app-lock-state');
setAppLockCheck(null);
});
afterEach(() => {
setBridge(undefined);
vi.useRealTimers();
});
describe('which new messages notify', () => {
it('a new unread message in the Inbox', async () => {
const { notifyingMailbox } = await import('../new-mail');
expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in');
});
it('nothing already read, a draft, or old mail coming back from an import', async () => {
const { notifyingMailbox } = await import('../new-mail');
expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
const old = new Date(Date.now() - 3 * 86400_000).toISOString();
expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull();
});
it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => {
const { notifyingMailbox } = await import('../new-mail');
const { useSettingsStore } = await import('@/stores/settings-store');
const filed = email('a', { mailboxIds: { news: true } });
expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull();
useSettingsStore.setState({ pushNotifyInboxOnly: false });
expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news');
expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
});
it('a muted folder or account stays silent', async () => {
const { notifyingMailbox } = await import('../new-mail');
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } });
expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } });
expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
});
});
describe('the watch', () => {
const flush = async () => {
for (let i = 0; i < 20; i += 1) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
};
it('resolves a push against its baseline, whatever page is open', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
const seen: string[] = [];
const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
watch.seed(fake);
await flush();
emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background');
await flush();
expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200);
expect(seen).toEqual(['a']);
watch.stop();
});
it('tells a message once when the store and the push both report it', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
const seen: string[] = [];
const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
watch.seed(fake);
await flush();
watch.noteCreated(fake, 'acc', ['a']);
await flush();
emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
await flush();
expect(seen).toEqual(['a']);
watch.stop();
});
it('a message the store has not got yet is not written off: its created event still tells it', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
const held: Record<string, Email> = {};
const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]);
const seen: string[] = [];
const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
watch.seed(fake);
await flush();
// The push got here first. The device answers, and it doesn't have the message.
emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
await flush();
expect(seen).toEqual([]);
// The store has synced it now.
held.a = email('a');
watch.noteCreated(fake, 'acc', ['a']);
await flush();
expect(seen).toEqual(['a']);
watch.stop();
});
it('without a baseline the first push only sets one: old mail is not news', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
const fake = fakeClient({}, []);
const onMail = vi.fn();
const watch = startNewMailWatch(onMail);
emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active');
await flush();
expect(fake.getEmailChanges).not.toHaveBeenCalled();
expect(onMail).not.toHaveBeenCalled();
watch.stop();
});
it('ignores a change after it stopped', async () => {
const { startNewMailWatch } = await import('../new-mail');
const { emitPushStateChange } = await import('@/lib/push-events');
const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
const onMail = vi.fn();
const watch = startNewMailWatch(onMail);
watch.seed(fake);
await flush();
watch.stop();
emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
await flush();
expect(onMail).not.toHaveBeenCalled();
});
});
describe('what the notification says', () => {
const one = (id = 'a', over: Partial<Email> = {}) => ({ client, accountId: 'acc', email: email(id, over) });
beforeEach(() => {
client = fakeClient({});
});
it('sender, subject, the buttons, and what a button needs after a restart', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
const notification = await buildMailNotification([one()], labels);
expect(notification.title).toBe('Alice Sender');
expect(notification.body).toBe('Secret a');
expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']);
expect(notification.reply?.label).toBe('reply');
expect(JSON.parse(notification.context)).toEqual({
accountId: 'acc',
slot: 3,
login: await loginFingerprint('https://one.example', 'alice@example.org'),
emailId: 'a',
threadId: 't-a',
});
});
it('no Archive button where the organisation has it off', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const notification = await buildMailNotification([one()], { ...labels, archive: null });
expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']);
});
it('while the app is locked: no sender, no subject, no buttons', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const { setAppLockCheck } = await import('../app-lock-state');
setAppLockCheck(() => true);
const notification = await buildMailNotification([one()], labels);
const text = JSON.stringify(notification);
expect(notification.title).toBe('new_mail');
expect(text).not.toContain('Alice Sender');
expect(text).not.toContain('Secret');
expect(notification.actions).toBeUndefined();
expect(notification.reply).toBeUndefined();
});
it('where the organisation keeps content off notifications: the same', async () => {
const { buildMailNotification } = await import('../mail-notifications');
pushWithoutContent = true;
const notification = await buildMailNotification([one()], labels);
expect(JSON.stringify(notification)).not.toContain('Secret');
expect(JSON.stringify(notification)).not.toContain('Alice Sender');
});
it('several at once are told about once, and that one has no message to act on', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
expect(notification.title).toBe('count:3');
expect(notification.actions).toBeUndefined();
expect(JSON.parse(notification.context).emailId).toBeUndefined();
});
it('several at once while locked say only how many', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const { setAppLockCheck } = await import('../app-lock-state');
setAppLockCheck(() => true);
const notification = await buildMailNotification([one('a'), one('b')], labels);
expect(notification.title).toBe('count:2');
expect(notification.body).toBe('');
});
});
describe('from arrival to toast and sound', () => {
/** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */
const settle = async () => {
const end = performance.now() + 80;
while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve));
};
async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) {
const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
const { emitPushStateChange } = await import('@/lib/push-events');
const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true });
setBridge({ version: 1, platform: 'win32', os: { notify } as never });
client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const mail = startMailNotifications(labels);
mail.seed(client);
await vi.advanceTimersByTimeAsync(10);
emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active');
await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
await settle();
mail.stop();
return notify;
}
it('asks main, and plays the picked sound when main says so', async () => {
const notify = await arrive();
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][0].sound).toBe(true);
expect(notify.mock.calls[0][0].attention).toBe(true);
expect(playNotificationSound).toHaveBeenCalledWith('involved');
});
it('no sound when main says it is quiet', async () => {
const notify = await arrive({ result: { shown: true, playSound: false } });
expect(notify).toHaveBeenCalledTimes(1);
expect(playNotificationSound).not.toHaveBeenCalled();
});
it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({ emailNotificationsEnabled: false });
expect(await arrive()).not.toHaveBeenCalled();
useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true });
expect(await arrive()).not.toHaveBeenCalled();
expect(playNotificationSound).not.toHaveBeenCalled();
});
it("the store's created event reaches a running watch, and nothing else", async () => {
const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events');
const notify = vi.fn(async () => ({ shown: true, playSound: false }));
setBridge({ version: 1, platform: 'win32', os: { notify } as never });
client = fakeClient({ a: email('a') });
noteStoreCreated(client, 'acc', ['a']);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const mail = startMailNotifications(labels);
noteStoreCreated(client, 'acc', ['a']);
await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
await settle();
expect(notify).toHaveBeenCalledTimes(1);
mail.stop();
noteStoreCreated(client, 'acc', ['b']);
await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
await settle();
expect(notify).toHaveBeenCalledTimes(1);
});
it('tells main the sound is off when the user has it off', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({ emailNotificationSound: false });
const notify = await arrive({ result: { shown: true, playSound: false } });
expect(notify.mock.calls[0][0].sound).toBe(false);
});
});
+79
View File
@@ -0,0 +1,79 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StorePageHooks } from '@/lib/store/client/api';
/** The local store and the OS integration: mail it found, and a machine going to sleep. */
// The first import of offline-mode compiles a good part of the app.
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null }));
const setOnline = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/lib/store/client/api', async (original) => ({
...(await original<typeof import('@/lib/store/client/api')>()),
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;
});
});
+442
View File
@@ -0,0 +1,442 @@
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
import type { CalendarEvent } from '@/lib/jmap/types';
/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
useLocale: () => 'en',
}));
function setBridge(bridge: Partial<BulwarkDesktopBridge['os']> | 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<string, unknown>).visibilityState;
});
it('in the desktop app while hidden in the tray', async () => {
const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
setBridge({});
const { unmount } = renderHook(() => usePolicyRefresh());
vi.advanceTimersByTime(POLICY_REFRESH_MS);
expect(fetchPolicy).toHaveBeenCalledTimes(1);
unmount();
});
it('but not in a browser tab out of view', async () => {
const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
const { unmount } = renderHook(() => usePolicyRefresh());
vi.advanceTimersByTime(POLICY_REFRESH_MS);
expect(fetchPolicy).not.toHaveBeenCalled();
unmount();
});
});
function dropEvent(type: string, files: File[]): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
return event;
}
describe('files on the window', () => {
const file = new File(['x'], 'report.pdf');
beforeEach(async () => {
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
clearHandoffs();
takeHandoff('compose');
});
it('nothing happens in a browser', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('a drop on the window starts a new draft with the file', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const over = dropEvent('dragover', [file]);
window.dispatchEvent(over);
expect(over.defaultPrevented).toBe(true);
const drop = dropEvent('drop', [file]);
window.dispatchEvent(drop);
expect(drop.defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
it('a drop something else took is left alone', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
event.preventDefault();
window.dispatchEvent(event);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('text dragged around is not a file', async () => {
const { startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('dragover', []);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
stop();
});
it('an open composer takes the file itself', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target);
window.dispatchEvent(dropEvent('drop', [file]));
expect(target).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
stop();
});
it('a pasted file is taken; pasted text is not', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const paste = (files: File[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files } });
window.dispatchEvent(event);
return event;
};
expect(paste([]).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
expect(paste([file]).defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
it('our own native drag over our own window is not a new file', async () => {
const { setOwnDragActive, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
setOwnDragActive(true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(takeHandoff('compose')).toBeNull();
setOwnDragActive(false);
stop();
});
});
describe('dragging an attachment out', () => {
const blobUrl = 'blob:https://app/1';
const source = {
name: 'report.pdf',
type: 'application/pdf',
getBlobUrl: async () => blobUrl,
getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
};
async function ready() {
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const hook = renderHook(() => useAttachmentDrag(source, true));
act(() => hook.result.current.onPointerEnter());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return hook;
}
it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
hook.result.current.onDragStart(event as never);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.dataTransfer.setData).not.toHaveBeenCalled();
await waitFor(() => expect(startDrag).toHaveBeenCalled());
const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
expect(files[0].name).toBe('report.pdf');
expect([...files[0].data]).toEqual([1, 2, 3]);
});
it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const hook = renderHook(() => useAttachmentDrag(source, true));
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
hook.result.current.onDragStart(event as never);
expect(event.preventDefault).toHaveBeenCalled();
expect(startDrag).not.toHaveBeenCalled();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
hook.result.current.onDragStart(event as never);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
});
it('in a browser: still DownloadURL, and no copy button', async () => {
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
hook.result.current.onDragStart(event as never);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`);
expect(hook.result.current.onCopyAsFile).toBeUndefined();
});
it('Copy as file hands the bytes to main', async () => {
const copyFiles = vi.fn(async () => true);
setBridge({ startDrag: vi.fn() as never, copyFiles });
const hook = await ready();
await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true);
const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
expect(files[0].name).toBe('report.pdf');
});
});
describe('waking up', () => {
async function started(os: Partial<BulwarkDesktopBridge['os']> = {}) {
let fire: (event: DesktopOsPowerEvent) => void = () => {};
setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
const power = await import('../power');
const events = await import('../system-events');
return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() };
}
it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => {
const { fire, power, stop } = await started();
const { usePolicyStore } = await import('@/stores/policy-store');
const fetchPolicy = vi.fn(async () => {});
usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
const visible = vi.fn();
const online = vi.fn();
const wake = vi.fn();
document.addEventListener('visibilitychange', visible);
window.addEventListener('online', online);
const stopWake = power.addWakeHandler(wake);
fire('resume');
fire('unlock-screen');
expect(visible).toHaveBeenCalledTimes(1);
expect(online).toHaveBeenCalledTimes(1);
expect(fetchPolicy).toHaveBeenCalledTimes(1);
expect(wake).toHaveBeenCalledTimes(1);
document.removeEventListener('visibilitychange', visible);
window.removeEventListener('online', online);
stopWake();
stop();
});
it('a suspend tells the sleep handlers and wakes nothing', async () => {
const { fire, power, stop } = await started();
const sleep = vi.fn();
const visible = vi.fn();
const stopSleep = power.addSleepHandler(sleep);
document.addEventListener('visibilitychange', visible);
fire('suspend');
fire('lock-screen');
expect(sleep).toHaveBeenCalledTimes(1);
expect(visible).not.toHaveBeenCalled();
document.removeEventListener('visibilitychange', visible);
stopSleep();
stop();
});
it('a network change counts as waking', async () => {
const connection = new EventTarget();
vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection }));
const { power, stop } = await started();
const wake = vi.fn();
const stopWake = power.addWakeHandler(wake);
await new Promise((resolve) => setTimeout(resolve, 0));
connection.dispatchEvent(new Event('change'));
expect(wake).toHaveBeenCalled();
stopWake();
stop();
});
});
describe('the summary', () => {
const event = (id: string, start: string, over: Partial<CalendarEvent> = {}) => ({ 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<DesktopOsStatus['settings']>) => ({ ...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(<DesktopOsSettings />);
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(<DesktopOsSettings />);
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(<DesktopOsSettings />);
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(<DesktopOsSettings />);
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(<DesktopOsSettings />);
expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
});
});
+19
View File
@@ -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;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Mail as real files from the page's side: a native drag or a copy to the clipboard.
* Later agents' calls for the taskbar's progress bar live here too.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { setOwnDragActive } from '@/lib/desktop/window-files';
/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */
export interface DragFile {
name: string;
blob: Blob;
}
const keptBlobs = new Map<string, Blob>();
/** In the app, remembers what's behind an object URL made for dragging. */
export function keepDragBlob(url: string, blob: Blob): string {
if (canDragNativeFiles()) keptBlobs.set(url, blob);
return url;
}
export function keptDragBlob(url: string): Blob | undefined {
return keptBlobs.get(url);
}
export function forgetDragBlob(url: string): void {
keptBlobs.delete(url);
}
export function canDragNativeFiles(): boolean {
return !!getDesktopBridge()?.os?.startDrag;
}
async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) };
}
/**
* Hands the file to main, which writes it to its private folder and starts the OS's
* drag. The mouse is still down, so this has to come straight from `dragstart`.
*/
export async function startNativeFileDrag(files: readonly DragFile[]): Promise<boolean> {
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<boolean> {
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);
}
+44
View File
@@ -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>): void {
if (opener) {
opener(link, options);
return;
}
waiting = { link, options };
void leaveFor('/');
}
/** For tests. */
export function resetMailLinks(): void {
opener = null;
waiting = null;
}
+174
View File
@@ -0,0 +1,174 @@
/**
* New mail as native notifications, with the same rules as the app's other ways of
* saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that
* arrives together is told about once. Sender and subject stay off the notification
* when the app is locked, or when the organisation says so for that login
* (pushWithoutContent, the rule the push preview route follows).
*
* Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers
* whether the page should play the sound the user picked, because this is the one
* place that sound is played from in the desktop app.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsNotification } from '@/lib/desktop/types';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail';
import { setStoreCreatedListener } from '@/lib/desktop/system-events';
import { devicesForAccount } from '@/lib/device-policy';
import { playNotificationSound } from '@/lib/notification-sound';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export const MAIL_BATCH_MS = 3000;
export interface MailNotificationLabels {
newMail: string;
newMailCount: (count: number) => string;
noSubject: string;
archive: string | null;
markRead: string;
delete: string;
reply: string;
replyPlaceholder: string;
}
/** What a toast's button hands back, possibly after the app restarted. */
export interface MailNotificationContext {
emailId?: string;
threadId?: string;
accountId?: string;
slot?: number;
login?: string;
}
/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */
export function desktopPlaysMailSound(): boolean {
return !!getDesktopBridge()?.os && !isPopoutWindow();
}
interface Sender {
entryId: string | null;
slot?: number;
login?: string;
label: string | null;
}
async function senderInfo(client: IJMAPClient): Promise<Sender> {
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<DesktopOsNotification> {
const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a));
const sender = await senderInfo(newest.client);
const hidden = withoutContent(sender);
const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login };
const base = {
id: `mail:${newest.accountId}/${newest.email.id}`,
group: `mail:${newest.accountId}`,
sound: true,
attention: true,
};
const suffix = sender.label && !hidden ? ` (${sender.label})` : '';
if (batch.length > 1) {
return {
...base,
title: `${labels.newMailCount(batch.length)}${suffix}`,
body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`,
// Nothing for one message to act on: a click opens the Inbox.
context: JSON.stringify(context),
};
}
const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId };
if (hidden) {
return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) };
}
return {
...base,
title: `${senderName(newest) || labels.newMail}${suffix}`,
body: newest.email.subject || newest.email.preview || labels.noSubject,
context: JSON.stringify(full),
actions: [
...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []),
{ id: 'mark-read', label: labels.markRead },
{ id: 'delete', label: labels.delete },
],
reply: { label: labels.reply, placeholder: labels.replyPlaceholder },
};
}
export interface MailNotifications extends NewMailWatch {
setLabels(labels: MailNotificationLabels): void;
}
export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
let labels = initial;
let batch: NewMail[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const current = batch;
batch = [];
if (current.length === 0) return;
const settings = useSettingsStore.getState();
if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return;
void (async () => {
const notification = await buildMailNotification(current, labels);
const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound });
if (result?.playSound) playNotificationSound(settings.notificationSoundChoice);
})().catch((error) => {
console.error('Failed to show a notification:', error);
});
};
const watch = startNewMailWatch((mail) => {
batch.push(...mail);
timer ??= setTimeout(flush, MAIL_BATCH_MS);
});
const handle: MailNotifications = {
...watch,
setLabels(next) {
labels = next;
},
stop() {
watch.stop();
if (timer) clearTimeout(timer);
timer = null;
setStoreCreatedListener(null);
},
};
setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids));
return handle;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Which messages count as new mail for the desktop app's notifications, for every
* connected login, whichever page or folder is open.
*
* With the local store the source is its `created` event (noteCreatedEmails): the
* engine has already worked out the delta. Without one, each login's push change is
* resolved here with Email/changes on a baseline of its own, the way
* reportNewInboxMail does for plugins. Both can see the same message; it's only told
* once.
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { isMailboxMuted } from '@/lib/notification-mutes';
import { subscribePushStateChanges } from '@/lib/push-events';
import { useSettingsStore } from '@/stores/settings-store';
export interface NewMail {
client: IJMAPClient;
/** The JMAP account the message is in. */
accountId: string;
email: Email;
}
const MAX_PAGES = 5;
const MAX_CHANGES = 200;
const SEEN_LIMIT = 500;
/** An import or a restore creates old mail: nobody is waiting to hear about that. */
const NEWEST_AGE_MS = 24 * 60 * 60 * 1000;
/** Folders mail lands in by itself, when not only the Inbox notifies. */
const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']);
function mailboxIdsOf(email: Email): string[] {
return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
}
/** The folder a new message counts for, or null if it stays silent. */
export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null {
if (email.keywords?.$seen || email.keywords?.$draft) return null;
const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now;
if (now - received > NEWEST_AGE_MS) return null;
const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState();
const here = new Set(mailboxIdsOf(email));
const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id));
const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? '')));
// Mail filed in a muted folder and an unmuted one still notifies.
return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null;
}
export interface NewMailWatch {
/** The local store's `created` event for this login's account. */
noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void;
/** Takes a state to work from, so the first push after the start can tell what's new. */
seed(client: IJMAPClient): void;
stop(): void;
}
export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch {
const baselines = new WeakMap<IJMAPClient, Map<string, string>>();
const seen: string[] = [];
let queue: Promise<void> = 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<void>) => {
const next = queue.then(run, run).catch((error) => {
console.error('Failed to check for new mail:', error);
});
queue = next;
return next;
};
const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => {
const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`));
if (fresh.length === 0 || stopped) return;
const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]);
// Only what came back counts as told. With the store on, a push can overtake it: the
// device doesn't have the message yet, and its created event has to be able to tell it.
for (const email of emails) seen.push(`${accountId}/${email.id}`);
seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
const own = client.getAccountId();
const mail = emails
.filter((email) => notifyingMailbox(email, accountId, own, mailboxes))
.map((email) => ({ client, accountId, email }));
if (mail.length > 0 && !stopped) onMail(mail);
};
const check = async (client: IJMAPClient, accountId: string, pushed: string) => {
const map = baselinesOf(client);
const since = map.get(accountId);
if (!since || !client.getEmailChanges) {
map.set(accountId, pushed);
return;
}
if (since === pushed) return;
const created: string[] = [];
let state = since;
for (let page = 0; page < MAX_PAGES; page += 1) {
const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES);
if (!delta) {
// Too old for the server: start again from here.
map.set(accountId, pushed);
return;
}
created.push(...delta.created);
state = delta.newState;
if (!delta.hasMoreChanges) break;
}
map.set(accountId, state);
await report(client, accountId, created);
};
const stopPush = subscribePushStateChanges((change, client) => {
for (const [accountId, types] of Object.entries(change.changed ?? {})) {
const pushed = types?.Email;
if (pushed) void enqueue(() => check(client, accountId, pushed));
}
});
return {
noteCreated(client, accountId, ids) {
void enqueue(() => report(client, accountId, ids));
},
seed(client) {
void enqueue(async () => {
const map = baselinesOf(client);
const own = client.getAccountId();
const mailboxes = await client.getAllMailboxes();
const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox');
await Promise.all(inboxes.map(async (inbox) => {
const accountId = inbox.accountId ?? own;
if (map.has(accountId)) return;
const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1);
if (state && !map.has(accountId)) map.set(accountId, state);
}));
});
},
stop() {
stopped = true;
stopPush();
},
};
}
+181
View File
@@ -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<void>;
/** 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<boolean> {
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<boolean> {
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<void> {
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<NotificationActionStatus> {
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<void> {
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<DesktopOsCommand, { type: 'files' }>, 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<void> {
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);
});
});
}
+130
View File
@@ -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<DesktopOsSummary> {
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<typeof setTimeout> | 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);
},
};
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Waking up without stale state. After sleep, an unlock or a network change, the app
* does what it does when a tab comes back into view: the JMAP client checks for state
* changes and replaces a dead stream, the identities and policy refresh. Those are the
* handlers already listening, so this only gives them the word. The hidden main window
* of the desktop app never goes out of view, so without this they wouldn't hear it.
* The local store hangs on the same two: `online` is setOnline(true) and a flush,
* `visibilitychange` is a sync per account. It takes itself offline for a sleep through
* addSleepHandler, in lib/desktop/system-events.ts.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
import { usePolicyStore } from '@/stores/policy-store';
/** A resume and an unlock usually come together. */
const WAKE_REPEAT_MS = 2000;
function wake(): void {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('online'));
const policy = usePolicyStore.getState();
if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
runWakeHandlers();
}
export function startPowerWatch(): () => void {
const bridge = getDesktopBridge()?.os;
if (!bridge) return () => {};
let lastWake = 0;
const wakeOnce = () => {
const now = Date.now();
if (now - lastWake < WAKE_REPEAT_MS) return;
lastWake = now;
wake();
};
const stopPower = bridge.onPower((event) => {
if (event === 'resume' || event === 'unlock-screen') wakeOnce();
else if (event === 'suspend') runSleepHandlers();
});
// Wi-Fi to cable is a new network with no offline moment in between.
const connection = (navigator as unknown as { connection?: EventTarget }).connection;
connection?.addEventListener?.('change', wakeOnce);
return () => {
stopPower();
connection?.removeEventListener?.('change', wakeOnce);
};
}
+56
View File
@@ -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<Handler>();
const sleepHandlers = new Set<Handler>();
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<Handler>): 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);
}
+117
View File
@@ -182,6 +182,122 @@ export interface DesktopKeystoreBridge {
revokeAccess: () => Promise<void>;
}
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<DesktopOsStatus>;
setSettings: (patch: Partial<DesktopOsSettings>) => Promise<DesktopOsStatus>;
/** 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<DesktopOsNotifyResult>;
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<boolean>;
/** The same, onto the clipboard as files. */
copyFiles: (files: DesktopOsFile[]) => Promise<boolean>;
defaultAppStatus: () => Promise<DesktopOsDefaultAppStatus>;
/** Offers the app to the OS and opens where the user chooses. */
openDefaultAppSettings: () => Promise<DesktopOsDefaultAppStatus>;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -191,6 +307,7 @@ export interface BulwarkDesktopBridge {
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
os?: DesktopOsBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
+66
View File
@@ -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);
};
}
+10
View File
@@ -62,6 +62,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
| { kind: 'queued'; subject: string }
@@ -196,6 +197,10 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
if (event.type === 'created' && event.collection === 'Email') {
const client = onlineClientFor(event.account);
if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
}
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
forgetMailSnapshot(event.account);
@@ -957,6 +962,11 @@ export function startOfflineMode(): () => void {
window.removeEventListener('offline', onOffline);
});
// The desktop app going to sleep. Waking comes with the two events above and below.
cleanups.push(addSleepHandler(() => {
void getStore().setOnline(false).catch(() => {});
}));
// Back in front: catch up now rather than at the next interval.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
+37 -1
View File
@@ -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<string, ReturnType<typeof makeClient>>;
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<NotificationActionRequest> = {}) {
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);
});
});
+21 -1
View File
@@ -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 });
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.",
"revoke": "سحب الإذن",
"revoked": "تم سحب الإذن"
},
"desktopOs": {
"section_title": "تطبيق سطح المكتب",
"section_description": "طريقة عمل التطبيق مع حاسوبك.",
"close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق",
"close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.",
"start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب",
"start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.",
"start_minimized_label": "البدء في شريط النظام",
"start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.",
"attention_label": "وميض شريط المهام عند وصول بريد جديد",
"attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد",
"attention_description": "فقط عندما لا يكون التطبيق في المقدمة.",
"quiet_hours_label": "ساعات الهدوء",
"quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.",
"quiet_hours_from": "من",
"quiet_hours_to": "إلى",
"show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح",
"show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.",
"show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.",
"default_app_label": "تطبيق البريد الافتراضي",
"default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.",
"default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.",
"default_app_button": "اختيار التطبيق الافتراضي…",
"new_mail": "بريد جديد",
"no_subject": "(بدون موضوع)",
"action_delete": "حذف",
"copy_as_file": "نسخ كملف",
"copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
"copy_failed": "تعذّر نسخ الملف."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Retira el permís. El servidor ho haurà de tornar a demanar.",
"revoke": "Retira",
"revoked": "Permís retirat"
},
"desktopOs": {
"section_title": "Aplicació d'escriptori",
"section_description": "Com funciona l'aplicació amb l'ordinador.",
"close_to_tray_label": "Continua a la safata en tancar",
"close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.",
"start_at_login_label": "Inicia en entrar a l'ordinador",
"start_at_login_description": "L'aplicació s'inicia sola quan entreu.",
"start_minimized_label": "Inicia a la safata",
"start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.",
"attention_label": "Fes parpellejar la barra de tasques amb correu nou",
"attention_mac_label": "Fes botar la icona del Dock amb correu nou",
"attention_description": "Només quan l'aplicació no és al davant.",
"quiet_hours_label": "Hores de silenci",
"quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.",
"quiet_hours_from": "De",
"quiet_hours_to": "a",
"show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat",
"show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.",
"show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.",
"default_app_label": "Aplicació de correu predeterminada",
"default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.",
"default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.",
"default_app_button": "Tria l'aplicació predeterminada…",
"new_mail": "Correu nou",
"no_subject": "(sense assumpte)",
"action_delete": "Suprimeix",
"copy_as_file": "Copia com a fitxer",
"copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
"copy_failed": "No s'ha pogut copiar el fitxer."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Odvolejte své povolení. Server se bude muset zeptat znovu.",
"revoke": "Odvolat",
"revoked": "Povolení bylo odvoláno"
},
"desktopOs": {
"section_title": "Desktopová aplikace",
"section_description": "Jak aplikace spolupracuje s vaším počítačem.",
"close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti",
"close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.",
"start_at_login_label": "Spustit po přihlášení k počítači",
"start_at_login_description": "Aplikace se po přihlášení spustí sama.",
"start_minimized_label": "Spustit v oznamovací oblasti",
"start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.",
"attention_label": "Při nové poště blikat na hlavním panelu",
"attention_mac_label": "Při nové poště poskakovat ikonou v Docku",
"attention_description": "Jen když aplikace není v popředí.",
"quiet_hours_label": "Doba klidu",
"quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.",
"quiet_hours_from": "Od",
"quiet_hours_to": "do",
"show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou",
"show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.",
"show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.",
"default_app_label": "Výchozí poštovní aplikace",
"default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.",
"default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.",
"default_app_button": "Vybrat výchozí aplikaci…",
"new_mail": "Nová pošta",
"no_subject": "(bez předmětu)",
"action_delete": "Smazat",
"copy_as_file": "Kopírovat jako soubor",
"copied": "Soubor zkopírován. Můžete ho vložit do složky.",
"copy_failed": "Soubor se nepodařilo zkopírovat."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Træk din tilladelse tilbage. Serveren skal så spørge igen.",
"revoke": "Træk tilbage",
"revoked": "Tilladelsen er trukket tilbage"
},
"desktopOs": {
"section_title": "Skrivebordsapp",
"section_description": "Sådan arbejder appen sammen med din computer.",
"close_to_tray_label": "Fortsæt i systembakken ved lukning",
"close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.",
"start_at_login_label": "Start, når du logger på computeren",
"start_at_login_description": "Appen starter selv, når du logger på.",
"start_minimized_label": "Start i systembakken",
"start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.",
"attention_label": "Blink i proceslinjen ved ny post",
"attention_mac_label": "Lad Dock-ikonet hoppe ved ny post",
"attention_description": "Kun når appen ikke er forrest.",
"quiet_hours_label": "Stille timer",
"quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.",
"quiet_hours_from": "Fra",
"quiet_hours_to": "til",
"show_shortcut_label": "Vis appen med en tastaturgenvej",
"show_shortcut_description": "{keys} henter appen frem fra enhver anden app.",
"show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.",
"default_app_label": "Standardmailapp",
"default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.",
"default_app_is_default": "Denne app åbner dine maillinks.",
"default_app_button": "Vælg standardapp…",
"new_mail": "Ny post",
"no_subject": "(intet emne)",
"action_delete": "Slet",
"copy_as_file": "Kopiér som fil",
"copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Nehmen Sie Ihre Erlaubnis zurück. Der Server muss dann erneut fragen.",
"revoke": "Zurücknehmen",
"revoked": "Erlaubnis zurückgenommen"
},
"desktopOs": {
"section_title": "Desktop-App",
"section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.",
"close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen",
"close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.",
"start_at_login_label": "Beim Anmelden am Computer starten",
"start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.",
"start_minimized_label": "Im Infobereich starten",
"start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.",
"attention_label": "Taskleiste bei neuen E-Mails blinken lassen",
"attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen",
"attention_description": "Nur wenn die App nicht im Vordergrund ist.",
"quiet_hours_label": "Ruhezeiten",
"quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.",
"quiet_hours_from": "Von",
"quiet_hours_to": "bis",
"show_shortcut_label": "App mit einer Tastenkombination anzeigen",
"show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.",
"show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.",
"default_app_label": "Standard-E-Mail-App",
"default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.",
"default_app_is_default": "Diese App öffnet Ihre Mail-Links.",
"default_app_button": "Standard-App auswählen…",
"new_mail": "Neue E-Mail",
"no_subject": "(kein Betreff)",
"action_delete": "Löschen",
"copy_as_file": "Als Datei kopieren",
"copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
"copy_failed": "Die Datei konnte nicht kopiert werden."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Take back your permission. The server has to ask again.",
"revoke": "Take back",
"revoked": "Permission taken back"
},
"desktopOs": {
"section_title": "Desktop app",
"section_description": "How the app works with your computer.",
"close_to_tray_label": "Keep running in the tray when closed",
"close_to_tray_description": "New mail and reminders keep coming after you close the window.",
"start_at_login_label": "Start when you sign in to your computer",
"start_at_login_description": "The app starts by itself when you sign in.",
"start_minimized_label": "Start in the tray",
"start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.",
"attention_label": "Flash the taskbar on new mail",
"attention_mac_label": "Bounce the Dock icon on new mail",
"attention_description": "Only when the app is not in front.",
"quiet_hours_label": "Quiet hours",
"quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.",
"quiet_hours_from": "From",
"quiet_hours_to": "to",
"show_shortcut_label": "Show the app with a keyboard shortcut",
"show_shortcut_description": "{keys} brings the app to the front, from any other app.",
"show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.",
"default_app_label": "Default mail app",
"default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.",
"default_app_is_default": "This app opens your mail links.",
"default_app_button": "Choose default app…",
"new_mail": "New mail",
"no_subject": "(no subject)",
"action_delete": "Delete",
"copy_as_file": "Copy as file",
"copied": "File copied. You can paste it into a folder.",
"copy_failed": "The file could not be copied."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Retire su permiso. El servidor tendrá que volver a pedirlo.",
"revoke": "Retirar",
"revoked": "Permiso retirado"
},
"desktopOs": {
"section_title": "Aplicación de escritorio",
"section_description": "Cómo funciona la aplicación con tu ordenador.",
"close_to_tray_label": "Seguir en la bandeja al cerrar",
"close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.",
"start_at_login_label": "Iniciar al entrar en el ordenador",
"start_at_login_description": "La aplicación se inicia sola cuando entras.",
"start_minimized_label": "Iniciar en la bandeja",
"start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.",
"attention_label": "Hacer parpadear la barra de tareas con correo nuevo",
"attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo",
"attention_description": "Solo cuando la aplicación no está delante.",
"quiet_hours_label": "Horas de silencio",
"quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.",
"quiet_hours_from": "De",
"quiet_hours_to": "a",
"show_shortcut_label": "Mostrar la aplicación con un atajo de teclado",
"show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.",
"show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.",
"default_app_label": "Aplicación de correo predeterminada",
"default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.",
"default_app_is_default": "Esta aplicación abre tus enlaces de correo.",
"default_app_button": "Elegir aplicación predeterminada…",
"new_mail": "Correo nuevo",
"no_subject": "(sin asunto)",
"action_delete": "Eliminar",
"copy_as_file": "Copiar como archivo",
"copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
"copy_failed": "No se pudo copiar el archivo."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Võtke oma luba tagasi. Server peab siis uuesti küsima.",
"revoke": "Võta tagasi",
"revoked": "Luba võeti tagasi"
},
"desktopOs": {
"section_title": "Töölauarakendus",
"section_description": "Kuidas rakendus sinu arvutiga koos töötab.",
"close_to_tray_label": "Sulgemisel jätka salves",
"close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.",
"start_at_login_label": "Käivita arvutisse sisselogimisel",
"start_at_login_description": "Rakendus käivitub sisselogimisel ise.",
"start_minimized_label": "Käivita salves",
"start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.",
"attention_label": "Vilguta tegumiriba uue kirja korral",
"attention_mac_label": "Põrgata Docki ikooni uue kirja korral",
"attention_description": "Ainult siis, kui rakendus pole esiplaanil.",
"quiet_hours_label": "Vaikne aeg",
"quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.",
"quiet_hours_from": "Alates",
"quiet_hours_to": "kuni",
"show_shortcut_label": "Näita rakendust kiirklahviga",
"show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.",
"show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.",
"default_app_label": "Vaikimisi e-posti rakendus",
"default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.",
"default_app_is_default": "See rakendus avab sinu e-posti lingid.",
"default_app_button": "Vali vaikerakendus…",
"new_mail": "Uus kiri",
"no_subject": "(teema puudub)",
"action_delete": "Kustuta",
"copy_as_file": "Kopeeri failina",
"copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
"copy_failed": "Faili ei saanud kopeerida."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.",
"revoke": "پس گرفتن",
"revoked": "اجازه پس گرفته شد"
},
"desktopOs": {
"section_title": "برنامه دسکتاپ",
"section_description": "نحوه کار برنامه با رایانه شما.",
"close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند",
"close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.",
"start_at_login_label": "هنگام ورود به رایانه شروع شود",
"start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.",
"start_minimized_label": "شروع در سینی سیستم",
"start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.",
"attention_label": "چشمک نوار وظیفه هنگام نامه جدید",
"attention_mac_label": "جهش نماد Dock هنگام نامه جدید",
"attention_description": "فقط وقتی برنامه در جلو نیست.",
"quiet_hours_label": "ساعات سکوت",
"quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.",
"quiet_hours_from": "از",
"quiet_hours_to": "تا",
"show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید",
"show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.",
"show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.",
"default_app_label": "برنامه پیش‌فرض ایمیل",
"default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.",
"default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.",
"default_app_button": "انتخاب برنامه پیش‌فرض…",
"new_mail": "نامه جدید",
"no_subject": "(بدون موضوع)",
"action_delete": "حذف",
"copy_as_file": "رونوشت به‌صورت پرونده",
"copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.",
"copy_failed": "رونوشت پرونده ممکن نشد."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Retirez votre autorisation. Le serveur devra la redemander.",
"revoke": "Retirer",
"revoked": "Autorisation retirée"
},
"desktopOs": {
"section_title": "Application de bureau",
"section_description": "Comment l'application fonctionne avec votre ordinateur.",
"close_to_tray_label": "Rester dans la zone de notification à la fermeture",
"close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.",
"start_at_login_label": "Démarrer à l'ouverture de session",
"start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.",
"start_minimized_label": "Démarrer dans la zone de notification",
"start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.",
"attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier",
"attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier",
"attention_description": "Seulement quand l'application n'est pas au premier plan.",
"quiet_hours_label": "Heures calmes",
"quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.",
"quiet_hours_from": "De",
"quiet_hours_to": "à",
"show_shortcut_label": "Afficher l'application avec un raccourci clavier",
"show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.",
"show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.",
"default_app_label": "Application de messagerie par défaut",
"default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.",
"default_app_is_default": "Cette application ouvre vos liens de messagerie.",
"default_app_button": "Choisir l'application par défaut…",
"new_mail": "Nouveau courrier",
"no_subject": "(sans objet)",
"action_delete": "Supprimer",
"copy_as_file": "Copier en tant que fichier",
"copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
"copy_failed": "Le fichier n'a pas pu être copié."
}
}
+30
View File
@@ -6562,5 +6562,35 @@
"label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.",
"revoke": "ביטול",
"revoked": "ההרשאה בוטלה"
},
"desktopOs": {
"section_title": "יישום שולחן עבודה",
"section_description": "איך היישום עובד עם המחשב שלך.",
"close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה",
"close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.",
"start_at_login_label": "להפעיל בכניסה למחשב",
"start_at_login_description": "היישום מופעל מעצמו כשנכנסים.",
"start_minimized_label": "להפעיל במגש המערכת",
"start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.",
"attention_label": "הבהוב בשורת המשימות בדואר חדש",
"attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש",
"attention_description": "רק כשהיישום לא בחזית.",
"quiet_hours_label": "שעות שקט",
"quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.",
"quiet_hours_from": "מ-",
"quiet_hours_to": "עד",
"show_shortcut_label": "הצגת היישום בקיצור מקשים",
"show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.",
"show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.",
"default_app_label": "יישום הדואר המוגדר כברירת מחדל",
"default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.",
"default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.",
"default_app_button": "בחירת יישום ברירת מחדל…",
"new_mail": "דואר חדש",
"no_subject": "(ללא נושא)",
"action_delete": "מחיקה",
"copy_as_file": "העתקה כקובץ",
"copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
"copy_failed": "לא ניתן להעתיק את הקובץ."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Vonja vissza az engedélyét. A kiszolgálónak újra kérnie kell.",
"revoke": "Visszavonás",
"revoked": "Engedély visszavonva"
},
"desktopOs": {
"section_title": "Asztali alkalmazás",
"section_description": "Hogyan működik az alkalmazás a számítógépével.",
"close_to_tray_label": "Bezáráskor fusson tovább a tálcán",
"close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.",
"start_at_login_label": "Indítás a számítógépre való bejelentkezéskor",
"start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.",
"start_minimized_label": "Indítás a tálcán",
"start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.",
"attention_label": "Tálca villogtatása új levélnél",
"attention_mac_label": "Dock-ikon ugráltatása új levélnél",
"attention_description": "Csak ha az alkalmazás nincs előtérben.",
"quiet_hours_label": "Csendes órák",
"quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.",
"quiet_hours_from": "Ettől",
"quiet_hours_to": "eddig",
"show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal",
"show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.",
"show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.",
"default_app_label": "Alapértelmezett levelezőalkalmazás",
"default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.",
"default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.",
"default_app_button": "Alapértelmezett alkalmazás kiválasztása…",
"new_mail": "Új levél",
"no_subject": "(nincs tárgy)",
"action_delete": "Törlés",
"copy_as_file": "Másolás fájlként",
"copied": "Fájl másolva. Beillesztheti egy mappába.",
"copy_failed": "A fájlt nem sikerült másolni."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Ritira il tuo permesso. Il server dovrà chiederlo di nuovo.",
"revoke": "Ritira",
"revoked": "Permesso ritirato"
},
"desktopOs": {
"section_title": "App desktop",
"section_description": "Come l'app funziona con il tuo computer.",
"close_to_tray_label": "Resta nell'area di notifica alla chiusura",
"close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.",
"start_at_login_label": "Avvia all'accesso al computer",
"start_at_login_description": "L'app si avvia da sola quando accedi.",
"start_minimized_label": "Avvia nell'area di notifica",
"start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.",
"attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta",
"attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta",
"attention_description": "Solo quando l'app non è in primo piano.",
"quiet_hours_label": "Ore di silenzio",
"quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.",
"quiet_hours_from": "Da",
"quiet_hours_to": "a",
"show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera",
"show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.",
"show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.",
"default_app_label": "App di posta predefinita",
"default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.",
"default_app_is_default": "Questa app apre i tuoi link di posta.",
"default_app_button": "Scegli app predefinita…",
"new_mail": "Nuova posta",
"no_subject": "(nessun oggetto)",
"action_delete": "Elimina",
"copy_as_file": "Copia come file",
"copied": "File copiato. Puoi incollarlo in una cartella.",
"copy_failed": "Impossibile copiare il file."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "許可を取り消します。サーバーは再度確認する必要があります。",
"revoke": "取り消す",
"revoked": "許可を取り消しました"
},
"desktopOs": {
"section_title": "デスクトップアプリ",
"section_description": "アプリとパソコンの連携方法です。",
"close_to_tray_label": "閉じてもトレイで実行を続ける",
"close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。",
"start_at_login_label": "パソコンへのサインイン時に起動",
"start_at_login_description": "サインインするとアプリが自動で起動します。",
"start_minimized_label": "トレイで起動",
"start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。",
"attention_label": "新着メールでタスクバーを点滅",
"attention_mac_label": "新着メールで Dock アイコンを跳ねさせる",
"attention_description": "アプリが前面にないときのみ。",
"quiet_hours_label": "おやすみ時間",
"quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。",
"quiet_hours_from": "開始",
"quiet_hours_to": "終了",
"show_shortcut_label": "キーボードショートカットでアプリを表示",
"show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。",
"show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。",
"default_app_label": "既定のメールアプリ",
"default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。",
"default_app_is_default": "メールのリンクはこのアプリで開きます。",
"default_app_button": "既定のアプリを選択…",
"new_mail": "新着メール",
"no_subject": "(件名なし)",
"action_delete": "削除",
"copy_as_file": "ファイルとしてコピー",
"copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
"copy_failed": "ファイルをコピーできませんでした。"
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.",
"revoke": "취소",
"revoked": "허용을 취소했습니다"
},
"desktopOs": {
"section_title": "데스크톱 앱",
"section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.",
"close_to_tray_label": "닫을 때 트레이에서 계속 실행",
"close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.",
"start_at_login_label": "컴퓨터에 로그인할 때 시작",
"start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.",
"start_minimized_label": "트레이에서 시작",
"start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.",
"attention_label": "새 메일이 오면 작업 표시줄 깜박이기",
"attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기",
"attention_description": "앱이 앞에 있지 않을 때만 작동합니다.",
"quiet_hours_label": "방해 금지 시간",
"quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.",
"quiet_hours_from": "시작",
"quiet_hours_to": "종료",
"show_shortcut_label": "키보드 단축키로 앱 표시",
"show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.",
"show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.",
"default_app_label": "기본 메일 앱",
"default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.",
"default_app_is_default": "이 앱이 메일 링크를 엽니다.",
"default_app_button": "기본 앱 선택…",
"new_mail": "새 메일",
"no_subject": "(제목 없음)",
"action_delete": "삭제",
"copy_as_file": "파일로 복사",
"copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
"copy_failed": "파일을 복사할 수 없습니다."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Atsauciet savu atļauju. Serverim būs jālūdz vēlreiz.",
"revoke": "Atsaukt",
"revoked": "Atļauja atsaukta"
},
"desktopOs": {
"section_title": "Darbvirsmas lietotne",
"section_description": "Kā lietotne darbojas ar jūsu datoru.",
"close_to_tray_label": "Aizverot turpināt darbu teknē",
"close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.",
"start_at_login_label": "Sākt, piesakoties datorā",
"start_at_login_description": "Lietotne sākas pati, kad piesakāties.",
"start_minimized_label": "Sākt teknē",
"start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.",
"attention_label": "Mirgot uzdevumjoslā, pienākot pastam",
"attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam",
"attention_description": "Tikai tad, ja lietotne nav priekšplānā.",
"quiet_hours_label": "Klusuma stundas",
"quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.",
"quiet_hours_from": "No",
"quiet_hours_to": "līdz",
"show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu",
"show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.",
"show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.",
"default_app_label": "Noklusējuma pasta lietotne",
"default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.",
"default_app_is_default": "Šī lietotne atver jūsu pasta saites.",
"default_app_button": "Izvēlēties noklusējuma lietotni…",
"new_mail": "Jauns pasts",
"no_subject": "(nav tēmas)",
"action_delete": "Dzēst",
"copy_as_file": "Kopēt kā failu",
"copied": "Fails nokopēts. Varat to ielīmēt mapē.",
"copy_failed": "Failu neizdevās nokopēt."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.",
"revoke": "Буцааж авах",
"revoked": "Зөвшөөрлийг буцааж авлаа"
},
"desktopOs": {
"section_title": "Ширээний апп",
"section_description": "Апп таны компьютертэй хэрхэн ажиллах.",
"close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах",
"close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.",
"start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх",
"start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.",
"start_minimized_label": "Тавиур дээр эхлүүлэх",
"start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.",
"attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах",
"attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх",
"attention_description": "Зөвхөн апп урд талд байхгүй үед.",
"quiet_hours_label": "Чимээгүй цаг",
"quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.",
"quiet_hours_from": "Эхлэх",
"quiet_hours_to": "хүртэл",
"show_shortcut_label": "Гарын товчлолоор аппыг харуулах",
"show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.",
"show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.",
"default_app_label": "Үндсэн шуудангийн апп",
"default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.",
"default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.",
"default_app_button": "Үндсэн апп сонгох…",
"new_mail": "Шинэ захиа",
"no_subject": "(гарчиггүй)",
"action_delete": "Устгах",
"copy_as_file": "Файл болгож хуулах",
"copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
"copy_failed": "Файлыг хуулж чадсангүй."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Trekk tilbake tillatelsen din. Serveren må da spørre på nytt.",
"revoke": "Trekk tilbake",
"revoked": "Tillatelsen er trukket tilbake"
},
"desktopOs": {
"section_title": "Skrivebordsapp",
"section_description": "Slik samarbeider appen med datamaskinen din.",
"close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking",
"close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.",
"start_at_login_label": "Start når du logger på datamaskinen",
"start_at_login_description": "Appen starter av seg selv når du logger på.",
"start_minimized_label": "Start i systemstatusfeltet",
"start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.",
"attention_label": "Blink i oppgavelinjen ved ny e-post",
"attention_mac_label": "La Dock-ikonet sprette ved ny e-post",
"attention_description": "Bare når appen ikke er foran.",
"quiet_hours_label": "Stille timer",
"quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.",
"quiet_hours_from": "Fra",
"quiet_hours_to": "til",
"show_shortcut_label": "Vis appen med en hurtigtast",
"show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.",
"show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.",
"default_app_label": "Standard e-postapp",
"default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.",
"default_app_is_default": "Denne appen åpner e-postlenkene dine.",
"default_app_button": "Velg standardapp…",
"new_mail": "Ny e-post",
"no_subject": "(uten emne)",
"action_delete": "Slett",
"copy_as_file": "Kopier som fil",
"copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Trek uw toestemming in. De server moet dan opnieuw vragen.",
"revoke": "Intrekken",
"revoked": "Toestemming ingetrokken"
},
"desktopOs": {
"section_title": "Desktop-app",
"section_description": "Hoe de app samenwerkt met je computer.",
"close_to_tray_label": "Bij sluiten doorgaan in het systeemvak",
"close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.",
"start_at_login_label": "Starten als je je aanmeldt op de computer",
"start_at_login_description": "De app start vanzelf als je je aanmeldt.",
"start_minimized_label": "Starten in het systeemvak",
"start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.",
"attention_label": "Taakbalk laten knipperen bij nieuwe mail",
"attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail",
"attention_description": "Alleen als de app niet vooraan staat.",
"quiet_hours_label": "Stille uren",
"quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.",
"quiet_hours_from": "Van",
"quiet_hours_to": "tot",
"show_shortcut_label": "App tonen met een sneltoets",
"show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.",
"show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.",
"default_app_label": "Standaard-mailapp",
"default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.",
"default_app_is_default": "Deze app opent je maillinks.",
"default_app_button": "Standaard-app kiezen…",
"new_mail": "Nieuwe mail",
"no_subject": "(geen onderwerp)",
"action_delete": "Verwijderen",
"copy_as_file": "Kopiëren als bestand",
"copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
"copy_failed": "Het bestand kon niet worden gekopieerd."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Cofnij swoją zgodę. Serwer będzie musiał zapytać ponownie.",
"revoke": "Cofnij",
"revoked": "Zgoda została cofnięta"
},
"desktopOs": {
"section_title": "Aplikacja na komputer",
"section_description": "Jak aplikacja współpracuje z Twoim komputerem.",
"close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku",
"close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.",
"start_at_login_label": "Uruchamiaj po zalogowaniu do komputera",
"start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.",
"start_minimized_label": "Uruchamiaj w zasobniku",
"start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.",
"attention_label": "Migaj na pasku zadań przy nowej poczcie",
"attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie",
"attention_description": "Tylko gdy aplikacja nie jest na wierzchu.",
"quiet_hours_label": "Godziny ciszy",
"quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.",
"quiet_hours_from": "Od",
"quiet_hours_to": "do",
"show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym",
"show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.",
"show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.",
"default_app_label": "Domyślna aplikacja poczty",
"default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.",
"default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.",
"default_app_button": "Wybierz domyślną aplikację…",
"new_mail": "Nowa poczta",
"no_subject": "(bez tematu)",
"action_delete": "Usuń",
"copy_as_file": "Kopiuj jako plik",
"copied": "Plik skopiowany. Możesz wkleić go do folderu.",
"copy_failed": "Nie udało się skopiować pliku."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Retire a sua permissão. O servidor terá de pedir novamente.",
"revoke": "Retirar",
"revoked": "Permissão retirada"
},
"desktopOs": {
"section_title": "Aplicação de ambiente de trabalho",
"section_description": "Como a aplicação funciona com o seu computador.",
"close_to_tray_label": "Continuar na bandeja ao fechar",
"close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.",
"start_at_login_label": "Iniciar ao entrar no computador",
"start_at_login_description": "A aplicação inicia sozinha quando entra.",
"start_minimized_label": "Iniciar na bandeja",
"start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.",
"attention_label": "Piscar a barra de tarefas com correio novo",
"attention_mac_label": "Saltar o ícone da Dock com correio novo",
"attention_description": "Só quando a aplicação não está à frente.",
"quiet_hours_label": "Horas de silêncio",
"quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.",
"quiet_hours_from": "De",
"quiet_hours_to": "até",
"show_shortcut_label": "Mostrar a aplicação com um atalho de teclado",
"show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.",
"show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.",
"default_app_label": "Aplicação de correio predefinida",
"default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.",
"default_app_is_default": "Esta aplicação abre as suas ligações de correio.",
"default_app_button": "Escolher aplicação predefinida…",
"new_mail": "Correio novo",
"no_subject": "(sem assunto)",
"action_delete": "Eliminar",
"copy_as_file": "Copiar como ficheiro",
"copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
"copy_failed": "Não foi possível copiar o ficheiro."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Retrageți permisiunea. Serverul va trebui să ceară din nou.",
"revoke": "Retrage",
"revoked": "Permisiune retrasă"
},
"desktopOs": {
"section_title": "Aplicație desktop",
"section_description": "Cum funcționează aplicația cu computerul tău.",
"close_to_tray_label": "Rămâne în zona de notificări la închidere",
"close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.",
"start_at_login_label": "Pornește la conectarea în computer",
"start_at_login_description": "Aplicația pornește singură când te conectezi.",
"start_minimized_label": "Pornește în zona de notificări",
"start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.",
"attention_label": "Clipește în bara de activități la e-mail nou",
"attention_mac_label": "Sare pictograma din Dock la e-mail nou",
"attention_description": "Doar când aplicația nu este în față.",
"quiet_hours_label": "Ore de liniște",
"quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.",
"quiet_hours_from": "De la",
"quiet_hours_to": "până la",
"show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură",
"show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.",
"show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.",
"default_app_label": "Aplicația de e-mail implicită",
"default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.",
"default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.",
"default_app_button": "Alege aplicația implicită…",
"new_mail": "E-mail nou",
"no_subject": "(fără subiect)",
"action_delete": "Șterge",
"copy_as_file": "Copiază ca fișier",
"copied": "Fișier copiat. Îl poți lipi într-un dosar.",
"copy_failed": "Fișierul nu a putut fi copiat."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.",
"revoke": "Отозвать",
"revoked": "Разрешение отозвано"
},
"desktopOs": {
"section_title": "Настольное приложение",
"section_description": "Как приложение работает с вашим компьютером.",
"close_to_tray_label": "При закрытии продолжать работу в трее",
"close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.",
"start_at_login_label": "Запускать при входе в систему",
"start_at_login_description": "Приложение запускается само, когда вы входите.",
"start_minimized_label": "Запускать в трее",
"start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.",
"attention_label": "Мигать на панели задач при новой почте",
"attention_mac_label": "Подпрыгивать значком в Dock при новой почте",
"attention_description": "Только когда приложение не на переднем плане.",
"quiet_hours_label": "Тихие часы",
"quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.",
"quiet_hours_from": "С",
"quiet_hours_to": "до",
"show_shortcut_label": "Показывать приложение сочетанием клавиш",
"show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.",
"show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.",
"default_app_label": "Почтовое приложение по умолчанию",
"default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.",
"default_app_is_default": "Это приложение открывает ваши почтовые ссылки.",
"default_app_button": "Выбрать приложение по умолчанию…",
"new_mail": "Новая почта",
"no_subject": "(без темы)",
"action_delete": "Удалить",
"copy_as_file": "Копировать как файл",
"copied": "Файл скопирован. Его можно вставить в папку.",
"copy_failed": "Не удалось скопировать файл."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Odvolajte svoje povolenie. Server sa bude musieť opýtať znova.",
"revoke": "Odvolať",
"revoked": "Povolenie bolo odvolané"
},
"desktopOs": {
"section_title": "Počítačová aplikácia",
"section_description": "Ako aplikácia spolupracuje s vaším počítačom.",
"close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení",
"close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.",
"start_at_login_label": "Spustiť po prihlásení do počítača",
"start_at_login_description": "Aplikácia sa po prihlásení spustí sama.",
"start_minimized_label": "Spustiť v oblasti oznámení",
"start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.",
"attention_label": "Pri novej pošte blikať na paneli úloh",
"attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku",
"attention_description": "Len keď aplikácia nie je v popredí.",
"quiet_hours_label": "Čas pokoja",
"quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.",
"quiet_hours_from": "Od",
"quiet_hours_to": "do",
"show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou",
"show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.",
"show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.",
"default_app_label": "Predvolená poštová aplikácia",
"default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.",
"default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.",
"default_app_button": "Vybrať predvolenú aplikáciu…",
"new_mail": "Nová pošta",
"no_subject": "(bez predmetu)",
"action_delete": "Odstrániť",
"copy_as_file": "Kopírovať ako súbor",
"copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
"copy_failed": "Súbor sa nepodarilo skopírovať."
}
}
+30
View File
@@ -6569,5 +6569,35 @@
"label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง",
"revoke": "ถอน",
"revoked": "ถอนการอนุญาตแล้ว"
},
"desktopOs": {
"section_title": "แอปเดสก์ท็อป",
"section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ",
"close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด",
"close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง",
"start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์",
"start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้",
"start_minimized_label": "เริ่มในถาดระบบ",
"start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ",
"attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่",
"attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่",
"attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า",
"quiet_hours_label": "ช่วงเวลาเงียบ",
"quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ",
"quiet_hours_from": "จาก",
"quiet_hours_to": "ถึง",
"show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด",
"show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้",
"show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง",
"default_app_label": "แอปเมลเริ่มต้น",
"default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้",
"default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ",
"default_app_button": "เลือกแอปเริ่มต้น…",
"new_mail": "เมลใหม่",
"no_subject": "(ไม่มีหัวเรื่อง)",
"action_delete": "ลบ",
"copy_as_file": "คัดลอกเป็นไฟล์",
"copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
"copy_failed": "คัดลอกไฟล์ไม่ได้"
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "İzninizi geri alın. Sunucunun yeniden sorması gerekir.",
"revoke": "Geri al",
"revoked": "İzin geri alındı"
},
"desktopOs": {
"section_title": "Masaüstü uygulaması",
"section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.",
"close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et",
"close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.",
"start_at_login_label": "Bilgisayarda oturum açınca başlat",
"start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.",
"start_minimized_label": "Sistem tepsisinde başlat",
"start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.",
"attention_label": "Yeni postada görev çubuğunu yanıp söndür",
"attention_mac_label": "Yeni postada Dock simgesini zıplat",
"attention_description": "Yalnızca uygulama önde değilken.",
"quiet_hours_label": "Sessiz saatler",
"quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.",
"quiet_hours_from": "Başlangıç",
"quiet_hours_to": "bitiş",
"show_shortcut_label": "Uygulamayı klavye kısayoluyla göster",
"show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.",
"show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.",
"default_app_label": "Varsayılan posta uygulaması",
"default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.",
"default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.",
"default_app_button": "Varsayılan uygulamayı seç…",
"new_mail": "Yeni posta",
"no_subject": "(konu yok)",
"action_delete": "Sil",
"copy_as_file": "Dosya olarak kopyala",
"copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
"copy_failed": "Dosya kopyalanamadı."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.",
"revoke": "Відкликати",
"revoked": "Дозвіл відкликано"
},
"desktopOs": {
"section_title": "Настільний застосунок",
"section_description": "Як застосунок працює з вашим комп’ютером.",
"close_to_tray_label": "Під час закриття працювати далі в треї",
"close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.",
"start_at_login_label": "Запускати під час входу в систему",
"start_at_login_description": "Застосунок запускається сам, коли ви входите.",
"start_minimized_label": "Запускати в треї",
"start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.",
"attention_label": "Блимати на панелі завдань за нової пошти",
"attention_mac_label": "Підстрибувати значком у Dock за нової пошти",
"attention_description": "Лише коли застосунок не на передньому плані.",
"quiet_hours_label": "Тихі години",
"quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.",
"quiet_hours_from": "З",
"quiet_hours_to": "до",
"show_shortcut_label": "Показувати застосунок сполученням клавіш",
"show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.",
"show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.",
"default_app_label": "Поштовий застосунок за замовчуванням",
"default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.",
"default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.",
"default_app_button": "Вибрати застосунок за замовчуванням…",
"new_mail": "Нова пошта",
"no_subject": "(без теми)",
"action_delete": "Видалити",
"copy_as_file": "Копіювати як файл",
"copied": "Файл скопійовано. Його можна вставити в папку.",
"copy_failed": "Не вдалося скопіювати файл."
}
}
+30
View File
@@ -6573,5 +6573,35 @@
"label_description": "Thu hồi quyền của bạn. Máy chủ sẽ phải hỏi lại.",
"revoke": "Thu hồi",
"revoked": "Đã thu hồi quyền"
},
"desktopOs": {
"section_title": "Ứng dụng máy tính",
"section_description": "Cách ứng dụng hoạt động với máy tính của bạn.",
"close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng",
"close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.",
"start_at_login_label": "Khởi động khi đăng nhập vào máy tính",
"start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.",
"start_minimized_label": "Khởi động ở khay hệ thống",
"start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.",
"attention_label": "Nhấp nháy thanh tác vụ khi có thư mới",
"attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới",
"attention_description": "Chỉ khi ứng dụng không ở phía trước.",
"quiet_hours_label": "Giờ yên tĩnh",
"quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.",
"quiet_hours_from": "Từ",
"quiet_hours_to": "đến",
"show_shortcut_label": "Hiện ứng dụng bằng phím tắt",
"show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.",
"show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.",
"default_app_label": "Ứng dụng thư mặc định",
"default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.",
"default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.",
"default_app_button": "Chọn ứng dụng mặc định…",
"new_mail": "Thư mới",
"no_subject": "(không có chủ đề)",
"action_delete": "Xóa",
"copy_as_file": "Sao chép dưới dạng tệp",
"copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
"copy_failed": "Không thể sao chép tệp."
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "收回你的許可。伺服器需要重新詢問。",
"revoke": "收回",
"revoked": "已收回許可"
},
"desktopOs": {
"section_title": "桌面應用程式",
"section_description": "應用程式如何與您的電腦配合。",
"close_to_tray_label": "關閉時繼續在系統匣中執行",
"close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。",
"start_at_login_label": "登入電腦時啟動",
"start_at_login_description": "您登入時應用程式會自動啟動。",
"start_minimized_label": "在系統匣中啟動",
"start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。",
"attention_label": "有新郵件時閃爍工作列",
"attention_mac_label": "有新郵件時讓 Dock 圖示跳動",
"attention_description": "僅在應用程式不在前景時。",
"quiet_hours_label": "勿擾時段",
"quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。",
"quiet_hours_from": "從",
"quiet_hours_to": "到",
"show_shortcut_label": "使用鍵盤快速鍵顯示應用程式",
"show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。",
"show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。",
"default_app_label": "預設郵件應用程式",
"default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。",
"default_app_is_default": "此應用程式會開啟您的郵件連結。",
"default_app_button": "選擇預設應用程式…",
"new_mail": "新郵件",
"no_subject": "(無主旨)",
"action_delete": "刪除",
"copy_as_file": "複製為檔案",
"copied": "檔案已複製。您可以將其貼到資料夾中。",
"copy_failed": "無法複製檔案。"
}
}
+30
View File
@@ -6561,5 +6561,35 @@
"label_description": "收回你的许可。服务器需要重新询问。",
"revoke": "收回",
"revoked": "已收回许可"
},
"desktopOs": {
"section_title": "桌面应用",
"section_description": "应用如何与你的电脑配合。",
"close_to_tray_label": "关闭时继续在托盘中运行",
"close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。",
"start_at_login_label": "登录电脑时启动",
"start_at_login_description": "你登录时应用会自动启动。",
"start_minimized_label": "在托盘中启动",
"start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。",
"attention_label": "有新邮件时闪烁任务栏",
"attention_mac_label": "有新邮件时让 Dock 图标跳动",
"attention_description": "仅在应用不在前台时。",
"quiet_hours_label": "免打扰时段",
"quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。",
"quiet_hours_from": "从",
"quiet_hours_to": "到",
"show_shortcut_label": "使用键盘快捷键显示应用",
"show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。",
"show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。",
"default_app_label": "默认邮件应用",
"default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。",
"default_app_is_default": "此应用会打开你的邮件链接。",
"default_app_button": "选择默认应用…",
"new_mail": "新邮件",
"no_subject": "(无主题)",
"action_delete": "删除",
"copy_as_file": "复制为文件",
"copied": "文件已复制。你可以将其粘贴到文件夹中。",
"copy_failed": "无法复制文件。"
}
}