mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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": "تعذّر نسخ الملف."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "رونوشت پرونده ممکن نشد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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é."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "לא ניתן להעתיק את הקובץ."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ファイルをコピーできませんでした。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "파일을 복사할 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Файлыг хуулж чадсангүй."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Не удалось скопировать файл."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ť."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "คัดลอกไฟล์ไม่ได้"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Не вдалося скопіювати файл."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "無法複製檔案。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "无法复制文件。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user