mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 08:15:45 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "حذف",
|
||||
"copy_as_file": "نسخ كملف",
|
||||
"copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
|
||||
"copy_failed": "تعذّر نسخ الملف."
|
||||
"copy_failed": "تعذّر نسخ الملف.",
|
||||
"reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "تذكير",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "حذف",
|
||||
"copy_as_file": "رونوشت بهصورت پرونده",
|
||||
"copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.",
|
||||
"copy_failed": "رونوشت پرونده ممکن نشد."
|
||||
"copy_failed": "رونوشت پرونده ممکن نشد.",
|
||||
"reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "یادآوری",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6652,7 +6652,8 @@
|
||||
"action_delete": "מחיקה",
|
||||
"copy_as_file": "העתקה כקובץ",
|
||||
"copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
|
||||
"copy_failed": "לא ניתן להעתיק את הקובץ."
|
||||
"copy_failed": "לא ניתן להעתיק את הקובץ.",
|
||||
"reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "תזכורת",
|
||||
|
||||
@@ -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ő",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "削除",
|
||||
"copy_as_file": "ファイルとしてコピー",
|
||||
"copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
|
||||
"copy_failed": "ファイルをコピーできませんでした。"
|
||||
"copy_failed": "ファイルをコピーできませんでした。",
|
||||
"reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "リマインダー",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "삭제",
|
||||
"copy_as_file": "파일로 복사",
|
||||
"copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
|
||||
"copy_failed": "파일을 복사할 수 없습니다."
|
||||
"copy_failed": "파일을 복사할 수 없습니다.",
|
||||
"reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "Устгах",
|
||||
"copy_as_file": "Файл болгож хуулах",
|
||||
"copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
|
||||
"copy_failed": "Файлыг хуулж чадсангүй."
|
||||
"copy_failed": "Файлыг хуулж чадсангүй.",
|
||||
"reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Сануулга",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "Удалить",
|
||||
"copy_as_file": "Копировать как файл",
|
||||
"copied": "Файл скопирован. Его можно вставить в папку.",
|
||||
"copy_failed": "Не удалось скопировать файл."
|
||||
"copy_failed": "Не удалось скопировать файл.",
|
||||
"reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Напоминание",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6659,7 +6659,8 @@
|
||||
"action_delete": "ลบ",
|
||||
"copy_as_file": "คัดลอกเป็นไฟล์",
|
||||
"copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
|
||||
"copy_failed": "คัดลอกไฟล์ไม่ได้"
|
||||
"copy_failed": "คัดลอกไฟล์ไม่ได้",
|
||||
"reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "การแจ้งเตือน",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "Видалити",
|
||||
"copy_as_file": "Копіювати як файл",
|
||||
"copied": "Файл скопійовано. Його можна вставити в папку.",
|
||||
"copy_failed": "Не вдалося скопіювати файл."
|
||||
"copy_failed": "Не вдалося скопіювати файл.",
|
||||
"reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Нагадування",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "刪除",
|
||||
"copy_as_file": "複製為檔案",
|
||||
"copied": "檔案已複製。您可以將其貼到資料夾中。",
|
||||
"copy_failed": "無法複製檔案。"
|
||||
"copy_failed": "無法複製檔案。",
|
||||
"reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "提醒",
|
||||
|
||||
@@ -6651,7 +6651,8 @@
|
||||
"action_delete": "删除",
|
||||
"copy_as_file": "复制为文件",
|
||||
"copied": "文件已复制。你可以将其粘贴到文件夹中。",
|
||||
"copy_failed": "无法复制文件。"
|
||||
"copy_failed": "无法复制文件。",
|
||||
"reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "提醒",
|
||||
|
||||
Reference in New Issue
Block a user