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

# Conflicts:
#	components/desktop/desktop-os-host.tsx
#	lib/desktop/os-commands.ts
#	lib/desktop/types.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
This commit is contained in:
Linus Rath
2026-11-03 14:47:37 +01:00
48 changed files with 433 additions and 81 deletions
+6 -2
View File
@@ -4,7 +4,8 @@ import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
@@ -45,7 +46,10 @@ export function DesktopOsHost() {
labelsRef.current = labels;
useEffect(() => {
if (!getDesktopBridge()?.os) return;
const os = getDesktopBridge()?.os;
if (!os) return;
// Main shows no content anywhere until this page has said it may.
os.setLocked(isAppLocked());
const mail = startMailNotifications(labelsRef.current);
mailRef.current = mail;
const summary = startOsSummary(() => localeRef.current);
+6 -3
View File
@@ -2650,9 +2650,6 @@ 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();
@@ -2702,6 +2699,12 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
// The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
useEffect(() => setComposerFileTarget(
(files) => void addFiles(files),
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
+13 -9
View File
@@ -686,15 +686,19 @@ 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) });
},
// A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it
// stays with the browser's drag, which only writes it where the user drops it.
...(attachment.decryptedAttachment ? {} : {
getBlob: async () => {
if (attachment.blobId && client) {
return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
}
const bytes = attachment.tnefData;
if (!bytes || bytes.byteLength === 0) return null;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return new Blob([buffer], { type: inertBlobType(attachment.type) });
},
}),
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
@@ -111,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const router = useRouter();
const pathname = usePathname();
const tLaunch = useTranslations("pwaLaunch");
const tDesktop = useTranslations("desktopOs");
const sentToTarget = useRef(new Set<string>());
const { dialogProps, confirm } = useConfirmDialog();
const [offer, setOffer] = useState<ShareOffer | null>(null);
@@ -213,6 +214,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
if (osEnv.current.pathname !== path) osEnv.current.push(path);
},
notifyFailed: () => toast.error(tLaunch("open_failed")),
notifyReplyLost: () => toast.error(tDesktop("reply_lost")),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+29 -1
View File
@@ -33,7 +33,8 @@ async function fingerprint() {
const leaveFor = vi.fn();
const push = vi.fn();
const notifyFailed = vi.fn();
const env = { leaveFor, push, notifyFailed };
const notifyReplyLost = vi.fn();
const env = { leaveFor, push, notifyFailed, notifyReplyLost };
const focus = vi.fn();
async function run(command: DesktopOsCommand) {
@@ -191,6 +192,33 @@ describe('a toast\'s buttons', () => {
stop();
});
it('behind the app lock an old toast acts on nothing, and the window comes up', async () => {
const { setAppLockCheck } = await import('../app-lock-state');
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
const stop = setMailLinkOpener(opener);
setAppLockCheck(() => true);
for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) {
await run({ type: 'notification', action, context: await context(), reply: 'x' });
}
expect(runNotificationAction).not.toHaveBeenCalled();
expect(opener).not.toHaveBeenCalled();
expect(focus).toHaveBeenCalled();
setAppLockCheck(null);
stop();
});
it('typed text that has nowhere to go says so', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const stop = setMailLinkOpener(vi.fn());
await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' });
expect(notifyReplyLost).toHaveBeenCalled();
notifyReplyLost.mockClear();
await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) });
expect(notifyReplyLost).not.toHaveBeenCalled();
stop();
});
it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
+11
View File
@@ -87,6 +87,8 @@ beforeEach(async () => {
});
const { setAppLockCheck } = await import('../app-lock-state');
setAppLockCheck(null);
const { usePolicyStore } = await import('@/stores/policy-store');
usePolicyStore.setState({ loaded: true });
});
afterEach(() => {
@@ -266,6 +268,15 @@ describe('what the notification says', () => {
expect(JSON.stringify(notification)).not.toContain('Alice Sender');
});
it('before the policy is known: the same', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const { usePolicyStore } = await import('@/stores/policy-store');
usePolicyStore.setState({ loaded: false });
const notification = await buildMailNotification([one()], labels);
expect(JSON.stringify(notification)).not.toContain('Secret');
expect(JSON.stringify(notification)).not.toContain('Alice Sender');
});
it('several at once are told about once, and that one has no message to act on', async () => {
const { buildMailNotification } = await import('../mail-notifications');
const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
+24
View File
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/** A sign-out leaves nothing of the account on the OS's surfaces. */
vi.setConfig({ testTimeout: 60_000 });
describe('sign-out', () => {
afterEach(() => {
window.bulwarkDesktop = undefined;
});
it('clears what the desktop app shows outside its windows', async () => {
const clearSurfaces = vi.fn();
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup');
purgeSignedOutData();
expect(clearSurfaces).toHaveBeenCalled();
});
it('in a browser there is nothing to clear', async () => {
const { clearDesktopSurfaces } = await import('../bridge');
expect(() => clearDesktopSurfaces()).not.toThrow();
});
});
+15
View File
@@ -49,6 +49,21 @@ describe('the store and the OS integration', () => {
expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']);
});
it('a wipe clears what the OS shows, and a browser has nothing to clear', async () => {
const { clearDesktopSurfaces } = await import('../bridge');
expect(() => clearDesktopSurfaces()).not.toThrow();
const clearSurfaces = vi.fn();
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
const { wipeAccount } = await import('@/lib/offline/offline-mode');
const { useOfflineStore } = await import('@/lib/offline/state');
// Not the last account, so this isn't the wipe of everything, which clears as well.
useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] });
await wipeAccount('alice@one').catch(() => {});
expect(clearSurfaces).toHaveBeenCalled();
useOfflineStore.setState({ enabled: [] });
window.bulwarkDesktop = undefined;
});
it('other collections are not mail', async () => {
const { setStoreCreatedListener } = await import('../system-events');
const listener = vi.fn();
+161 -7
View File
@@ -159,7 +159,7 @@ describe('files on the window', () => {
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target);
const clear = setComposerFileTarget(target, () => true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(target).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
@@ -167,6 +167,20 @@ describe('files on the window', () => {
stop();
});
it('a composer out of sight on a hidden tab takes nothing', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const hidden = vi.fn();
const clear = setComposerFileTarget(hidden, () => false);
window.dispatchEvent(dropEvent('drop', [file]));
expect(hidden).not.toHaveBeenCalled();
expect(takeHandoff('compose')?.files).toEqual([file]);
clear();
stop();
});
it('a pasted file is taken; pasted text is not', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
@@ -174,7 +188,7 @@ describe('files on the window', () => {
const stop = startWindowFiles();
const paste = (files: File[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files } });
Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } });
window.dispatchEvent(event);
return event;
};
@@ -185,6 +199,54 @@ describe('files on the window', () => {
stop();
});
describe('a paste that is not for us', () => {
const pasteOn = (target: EventTarget, types: string[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } });
target.dispatchEvent(event);
return event;
};
it('text with a file, as a spreadsheet copies, stays text', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('in a search field or any other input, a file is not an attachment', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const input = document.body.appendChild(document.createElement('input'));
expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
input.remove();
stop();
});
it('in the body of a composer on screen, a bare file attaches', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target, () => true);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
pasteOn(body, ['Files']);
expect(target).toHaveBeenCalledWith([file]);
composer.remove();
clear();
stop();
});
});
it('our own native drag over our own window is not a new file', async () => {
const { setOwnDragActive, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
@@ -247,6 +309,23 @@ describe('dragging an attachment out', () => {
await waitFor(() => expect(startDrag).toHaveBeenCalled());
});
it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const { name, type, getBlobUrl } = source;
const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true));
act(() => hook.result.current.onPointerEnter());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
hook.result.current.onDragStart(event as never);
expect(startDrag).not.toHaveBeenCalled();
expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl));
expect(hook.result.current.onCopyAsFile).toBeUndefined();
});
it('in a browser: still DownloadURL, and no copy button', async () => {
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
@@ -313,15 +392,52 @@ describe('waking up', () => {
stop();
});
it('a network change counts as waking', async () => {
const connection = new EventTarget();
vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection }));
it('a new kind of network counts as waking; a change in its speed estimate does not', async () => {
const connection = Object.assign(new EventTarget(), { type: 'wifi' });
vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
const { power, stop } = await started();
const wake = vi.fn();
const stopWake = power.addWakeHandler(wake);
await new Promise((resolve) => setTimeout(resolve, 0));
connection.dispatchEvent(new Event('change'));
expect(wake).toHaveBeenCalled();
expect(wake).not.toHaveBeenCalled();
connection.type = 'ethernet';
connection.dispatchEvent(new Event('change'));
expect(wake).toHaveBeenCalledTimes(1);
stopWake();
stop();
});
it('where Chromium gives no type, a change is nothing to go on', async () => {
const connection = new EventTarget();
vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
const { power, stop } = await started();
const wake = vi.fn();
const stopWake = power.addWakeHandler(wake);
connection.dispatchEvent(new Event('change'));
expect(wake).not.toHaveBeenCalled();
stopWake();
stop();
});
it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => {
const online = vi.fn();
window.addEventListener('online', online);
vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } }));
const { fire, power, stop } = await started();
const wake = vi.fn();
const stopWake = power.addWakeHandler(wake);
fire('resume');
expect(wake).toHaveBeenCalledTimes(1);
expect(online).not.toHaveBeenCalled();
const now = Date.now();
const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000);
fire('unlock-screen');
expect(wake).toHaveBeenCalledTimes(1);
clock.mockReturnValue(now + 31_000);
fire('unlock-screen');
expect(wake).toHaveBeenCalledTimes(2);
clock.mockRestore();
window.removeEventListener('online', online);
stopWake();
stop();
});
@@ -357,6 +473,8 @@ describe('the summary', () => {
useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' });
useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never });
useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never });
const { usePolicyStore } = await import('@/stores/policy-store');
usePolicyStore.setState({ loaded: true });
});
afterEach(async () => {
@@ -374,6 +492,42 @@ describe('the summary', () => {
expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' });
});
it('before the policy is known it is empty', async () => {
const { usePolicyStore } = await import('@/stores/policy-store');
const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
usePolicyStore.setState({ loaded: false });
await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
expect(getEmails).not.toHaveBeenCalled();
});
it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => {
const setSummary = vi.fn();
setBridge({ setSummary });
const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePolicyStore } = await import('@/stores/policy-store');
let answer: (value: unknown) => void = () => {};
getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
const summary = startOsSummary(() => 'en');
await vi.advanceTimersByTimeAsync(2100);
useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
answer({ emails: [row], hasMore: false, total: 1 });
await vi.advanceTimersByTimeAsync(10);
await new Promise((resolve) => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(2100);
expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch');
setSummary.mockClear();
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } });
await vi.advanceTimersByTimeAsync(2100);
expect(setSummary).toHaveBeenCalled();
summary.stop();
vi.useRealTimers();
});
it('while locked it is empty, without asking anyone anything', async () => {
const { setAppLockCheck } = await import('../app-lock-state');
const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+5
View File
@@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
}
/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */
export function clearDesktopSurfaces(): void {
getDesktopBridge()?.os?.clearSurfaces?.();
}
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
+9 -3
View File
@@ -21,6 +21,7 @@ import { isPopoutWindow } from '@/lib/popout/window-role';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -72,17 +73,22 @@ async function senderInfo(client: IJMAPClient): Promise<Sender> {
};
}
function withoutContent(sender: Sender): boolean {
/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */
export function contentHiddenFor(accountEntryId: string | null | undefined): boolean {
if (isAppLocked()) return true;
// Unknown counts as on, as on the server's route.
if (!sender.entryId) return true;
if (!accountEntryId || !usePolicyStore.getState().loaded) return true;
try {
return devicesForAccount(sender.entryId).pushWithoutContent;
return devicesForAccount(accountEntryId).pushWithoutContent;
} catch {
return true;
}
}
function withoutContent(sender: Sender): boolean {
return contentHiddenFor(sender.entryId);
}
function senderName(mail: NewMail): string {
const first = mail.email.from?.[0];
return first?.name || first?.email || '';
+9
View File
@@ -5,6 +5,7 @@
* user's own OS, so it skips the question a web share gets (decision 17).
*/
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsCommand } from '@/lib/desktop/types';
import { openMailLink } from '@/lib/desktop/mail-links';
@@ -26,6 +27,8 @@ export interface OsCommandEnv {
push: (path: string) => void;
/** Says something couldn't be opened. */
notifyFailed: (names: readonly string[]) => void;
/** A typed reply that has nowhere to go: its message's login is gone from here. */
notifyReplyLost: () => void;
}
/** How long a toast's button waits for a cold start to sign in. */
@@ -78,6 +81,7 @@ async function openFromNotification(context: MailNotificationContext, env: OsCom
openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor);
return;
}
if (reply?.text) env.notifyReplyLost();
openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor);
}
@@ -90,6 +94,11 @@ function runAction(request: NotificationActionRequest): Promise<NotificationActi
}
async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise<void> {
// An old toast's Delete doesn't act behind the lock, and its message doesn't open over it.
if (isAppLocked()) {
showWindow();
return;
}
if (await handleReminderCommand(action, rawContext, env)) return;
const context = parseContext(rawContext);
if (action === 'reply') {
+17 -9
View File
@@ -6,9 +6,8 @@
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types';
import { devicesForAccount } from '@/lib/device-policy';
import type { CalendarEvent, Email } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
@@ -16,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
const ROWS = 5;
const REFRESH_DELAY_MS = 2000;
@@ -46,12 +46,10 @@ export function formatEventTime(start: number, now: number, locale: string | und
}
export async function readSummary(locale: string | undefined, now = Date.now()): Promise<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;
if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY;
const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared);
let unread: DesktopOsTrayMessage[] = [];
@@ -91,12 +89,14 @@ export function startOsSummary(locale: () => string | undefined): { refresh: ()
const os = getDesktopBridge()?.os;
if (!os) return { refresh: () => {}, stop: () => {} };
let timer: ReturnType<typeof setTimeout> | null = null;
let stale = false;
/** A read under way when the account changed answers for the old one, and is dropped. */
let generation = 0;
const run = () => {
timer = null;
const mine = ++generation;
void readSummary(locale()).then((summary) => {
if (!stale) os.setSummary(summary);
if (mine === generation) os.setSummary(summary);
}, (error) => {
console.warn('Could not read the tray summary:', error);
});
@@ -113,16 +113,24 @@ export function startOsSummary(locale: () => string | undefined): { refresh: ()
refresh();
});
const stopAuth = useAuthStore.subscribe((state, previous) => {
if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh();
if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return;
generation += 1;
if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY);
refresh();
});
// An organisation turning sender and subject off goes for what's already showing, too.
const stopPolicy = usePolicyStore.subscribe((state, previous) => {
if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh();
});
const interval = setInterval(refresh, REFRESH_EVERY_MS);
refresh();
return {
refresh,
stop() {
stale = true;
generation += 1;
stopEmail();
stopAuth();
stopPolicy();
clearInterval(interval);
if (timer) clearTimeout(timer);
},
+17 -7
View File
@@ -13,12 +13,13 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
import { usePolicyStore } from '@/stores/policy-store';
/** A resume and an unlock usually come together. */
const WAKE_REPEAT_MS = 2000;
/** A resume, an unlock and a network change come together, and each would mean a round of requests. */
const WAKE_REPEAT_MS = 30_000;
function wake(): void {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('online'));
// Woken up with no network: the browser's own event comes when there is one.
if (navigator.onLine !== false) window.dispatchEvent(new Event('online'));
const policy = usePolicyStore.getState();
if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
runWakeHandlers();
@@ -38,11 +39,20 @@ export function startPowerWatch(): () => void {
if (event === 'resume' || event === 'unlock-screen') wakeOnce();
else if (event === 'suspend') runSleepHandlers();
});
// Wi-Fi to cable is a new network with no offline moment in between.
const connection = (navigator as unknown as { connection?: EventTarget }).connection;
connection?.addEventListener?.('change', wakeOnce);
// Wi-Fi to cable is a new network with no offline moment in between. This event also fires
// for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the
// type, that leaves the stream's own ping check and the online event.
const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection;
let lastType = connection?.type;
const onConnection = () => {
const type = connection?.type;
if (!type || type === lastType) return;
lastType = type;
wakeOnce();
};
connection?.addEventListener?.('change', onConnection);
return () => {
stopPower();
connection?.removeEventListener?.('change', wakeOnce);
connection?.removeEventListener?.('change', onConnection);
};
}
+7 -2
View File
@@ -251,7 +251,7 @@ export interface DesktopOsNotification {
export interface DesktopOsNotifyResult {
shown: boolean;
reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid';
reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed';
/** The page should play the sound the user picked. */
playSound: boolean;
}
@@ -297,8 +297,13 @@ export interface DesktopOsBridge {
setSummary: (summary: DesktopOsSummary) => void;
/** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */
setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void;
/** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */
/**
* App lock's state. Main counts as locked until the main window's page says otherwise,
* and clears the tray, jump list and toasts and strips what comes through.
*/
setLocked: (locked: boolean) => void;
/** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */
clearSurfaces: () => void;
notify: (notification: DesktopOsNotification) => Promise<DesktopOsNotifyResult>;
onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void;
/** Writes the files to a private folder and starts a drag. Only with the mouse held. */
+37 -8
View File
@@ -8,29 +8,58 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { handOffDraft } from '@/lib/launch-handoff';
type ComposerTarget = (files: File[]) => unknown;
interface ComposerTarget {
take: (files: File[]) => unknown;
/** The Pro shell keeps composers mounted on tabs out of sight. */
visible: () => boolean;
}
let composerTarget: ComposerTarget | null = null;
const composerTargets: ComposerTarget[] = [];
/** Our own native drag passing over our own window isn't a new file. */
let ownDrag = false;
/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */
export function setComposerFileTarget(target: ComposerTarget): () => void {
composerTarget = target;
/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */
export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void {
const target = { take, visible };
composerTargets.push(target);
return () => {
if (composerTarget === target) composerTarget = null;
const at = composerTargets.indexOf(target);
if (at !== -1) composerTargets.splice(at, 1);
};
}
function visibleComposer(): ComposerTarget | undefined {
return [...composerTargets].reverse().find((target) => target.visible());
}
export function setOwnDragActive(active: boolean): void {
ownDrag = active;
}
function take(files: File[]): void {
if (composerTarget) composerTarget(files);
const composer = visibleComposer();
if (composer) composer.take(files);
else handOffDraft({ files });
}
function isEditable(target: EventTarget | null): boolean {
const element = target as Partial<HTMLElement> & { closest?: (selector: string) => Element | null } | null;
return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
}
/**
* Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a
* picture of them, and a field that takes text wants the text. The one editable place a
* bare file belongs in is the composer it's being pasted into.
*/
function pasteIsForUs(event: ClipboardEvent): boolean {
const types = [...(event.clipboardData?.types ?? [])];
if (types.includes('text/plain') || types.includes('text/html')) return false;
if (!isEditable(event.target)) return true;
const inComposer = !!(event.target as Partial<Element> | null)?.closest?.('[data-testid="email-composer"]');
return inComposer && !!visibleComposer();
}
function hasFiles(event: DragEvent): boolean {
return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
}
@@ -51,7 +80,7 @@ export function startWindowFiles(): () => void {
const onPaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
if (files.length === 0 || !pasteIsForUs(event)) return;
event.preventDefault();
take(files);
};
+3
View File
@@ -93,6 +93,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
import { clearDesktopSurfaces } from '@/lib/desktop/bridge';
import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
@@ -1054,6 +1055,7 @@ async function nameLost(scope: string, stillSignedIn: boolean): Promise<void> {
}
export async function wipeAccount(account: string): Promise<void> {
clearDesktopSurfaces();
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
@@ -1086,6 +1088,7 @@ export async function wipeAccount(account: string): Promise<void> {
/** Nothing of offline mode stays on this device. */
export async function wipeEverything(): Promise<void> {
clearDesktopSurfaces();
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
writeEnabled([]);
+2
View File
@@ -1,6 +1,7 @@
import { fileStorage } from '@/lib/plugin-storage';
import { clearHandoffs } from '@/lib/launch-handoff';
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
import { clearDesktopSurfaces } from '@/lib/desktop/bridge';
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
import { purgeStoreFiles } from '@/lib/store/client/purge';
@@ -142,6 +143,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void {
}
// Files and links on their way into a draft or an import.
clearHandoffs();
clearDesktopSurfaces();
for (const cleanup of signedOutCleanups) {
try {
cleanup(options);
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "حذف",
"copy_as_file": "نسخ كملف",
"copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
"copy_failed": "تعذّر نسخ الملف."
"copy_failed": "تعذّر نسخ الملف.",
"reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
},
"desktopReminders": {
"generic_title": "تذكير",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "No s'ha pogut copiar el fitxer.",
"reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà."
},
"desktopReminders": {
"generic_title": "Recordatori",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Soubor se nepodařilo zkopírovat.",
"reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní."
},
"desktopReminders": {
"generic_title": "Připomenutí",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der."
},
"desktopReminders": {
"generic_title": "Påmindelse",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Die Datei konnte nicht kopiert werden.",
"reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort."
},
"desktopReminders": {
"generic_title": "Erinnerung",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "The file could not be copied.",
"reply_lost": "Your typed reply could not be kept. Open the message and reply there."
},
"desktopReminders": {
"generic_title": "Reminder",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "No se pudo copiar el archivo.",
"reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí."
},
"desktopReminders": {
"generic_title": "Recordatorio",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Kustuta",
"copy_as_file": "Kopeeri failina",
"copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
"copy_failed": "Faili ei saanud kopeerida."
"copy_failed": "Faili ei saanud kopeerida.",
"reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal."
},
"desktopReminders": {
"generic_title": "Meeldetuletus",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "حذف",
"copy_as_file": "رونوشت به‌صورت پرونده",
"copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.",
"copy_failed": "رونوشت پرونده ممکن نشد."
"copy_failed": "رونوشت پرونده ممکن نشد.",
"reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
},
"desktopReminders": {
"generic_title": "یادآوری",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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é."
"copy_failed": "Le fichier n'a pas pu être copié.",
"reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci."
},
"desktopReminders": {
"generic_title": "Rappel",
+2 -1
View File
@@ -6652,7 +6652,8 @@
"action_delete": "מחיקה",
"copy_as_file": "העתקה כקובץ",
"copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
"copy_failed": "לא ניתן להעתיק את הקובץ."
"copy_failed": "לא ניתן להעתיק את הקובץ.",
"reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
},
"desktopReminders": {
"generic_title": "תזכורת",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "A fájlt nem sikerült másolni.",
"reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott."
},
"desktopReminders": {
"generic_title": "Emlékeztető",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Elimina",
"copy_as_file": "Copia come file",
"copied": "File copiato. Puoi incollarlo in una cartella.",
"copy_failed": "Impossibile copiare il file."
"copy_failed": "Impossibile copiare il file.",
"reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì."
},
"desktopReminders": {
"generic_title": "Promemoria",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "削除",
"copy_as_file": "ファイルとしてコピー",
"copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
"copy_failed": "ファイルをコピーできませんでした。"
"copy_failed": "ファイルをコピーできませんでした。",
"reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
},
"desktopReminders": {
"generic_title": "リマインダー",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "삭제",
"copy_as_file": "파일로 복사",
"copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
"copy_failed": "파일을 복사할 수 없습니다."
"copy_failed": "파일을 복사할 수 없습니다.",
"reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
},
"desktopReminders": {
"generic_title": "알림",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Failu neizdevās nokopēt.",
"reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur."
},
"desktopReminders": {
"generic_title": "Atgādinājums",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Устгах",
"copy_as_file": "Файл болгож хуулах",
"copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
"copy_failed": "Файлыг хуулж чадсангүй."
"copy_failed": "Файлыг хуулж чадсангүй.",
"reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
},
"desktopReminders": {
"generic_title": "Сануулга",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der."
},
"desktopReminders": {
"generic_title": "Påminnelse",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Het bestand kon niet worden gekopieerd.",
"reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar."
},
"desktopReminders": {
"generic_title": "Herinnering",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Nie udało się skopiować pliku.",
"reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej."
},
"desktopReminders": {
"generic_title": "Przypomnienie",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Não foi possível copiar o ficheiro.",
"reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela."
},
"desktopReminders": {
"generic_title": "Lembrete",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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."
"copy_failed": "Fișierul nu a putut fi copiat.",
"reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo."
},
"desktopReminders": {
"generic_title": "Memento",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Удалить",
"copy_as_file": "Копировать как файл",
"copied": "Файл скопирован. Его можно вставить в папку.",
"copy_failed": "Не удалось скопировать файл."
"copy_failed": "Не удалось скопировать файл.",
"reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
},
"desktopReminders": {
"generic_title": "Напоминание",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"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ť."
"copy_failed": "Súbor sa nepodarilo skopírovať.",
"reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej."
},
"desktopReminders": {
"generic_title": "Pripomienka",
+2 -1
View File
@@ -6659,7 +6659,8 @@
"action_delete": "ลบ",
"copy_as_file": "คัดลอกเป็นไฟล์",
"copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
"copy_failed": "คัดลอกไฟล์ไม่ได้"
"copy_failed": "คัดลอกไฟล์ไม่ได้",
"reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
},
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Sil",
"copy_as_file": "Dosya olarak kopyala",
"copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
"copy_failed": "Dosya kopyalanamadı."
"copy_failed": "Dosya kopyalanamadı.",
"reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın."
},
"desktopReminders": {
"generic_title": "Anımsatıcı",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "Видалити",
"copy_as_file": "Копіювати як файл",
"copied": "Файл скопійовано. Його можна вставити в папку.",
"copy_failed": "Не вдалося скопіювати файл."
"copy_failed": "Не вдалося скопіювати файл.",
"reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
},
"desktopReminders": {
"generic_title": "Нагадування",
+2 -1
View File
@@ -6663,7 +6663,8 @@
"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."
"copy_failed": "Không thể sao chép tệp.",
"reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó."
},
"desktopReminders": {
"generic_title": "Lời nhắc",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "刪除",
"copy_as_file": "複製為檔案",
"copied": "檔案已複製。您可以將其貼到資料夾中。",
"copy_failed": "無法複製檔案。"
"copy_failed": "無法複製檔案。",
"reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
},
"desktopReminders": {
"generic_title": "提醒",
+2 -1
View File
@@ -6651,7 +6651,8 @@
"action_delete": "删除",
"copy_as_file": "复制为文件",
"copied": "文件已复制。你可以将其粘贴到文件夹中。",
"copy_failed": "无法复制文件。"
"copy_failed": "无法复制文件。",
"reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
},
"desktopReminders": {
"generic_title": "提醒",