diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index df31098c0..a6e522136 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -4,7 +4,8 @@ import { useEffect, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { getDesktopBridge } from "@/lib/desktop/bridge"; -import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; +import { isAppLocked } from "@/lib/desktop/app-lock-state"; +import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; import { startWindowFiles } from "@/lib/desktop/window-files"; @@ -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); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4c02f4eac..be8263737 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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(null); + + // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + useEffect(() => setComposerFileTarget( + (files) => void addFiles(files), + () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + ), [addFiles, composerRootEl]); // The id plugins know this composer by (registered below, with the handle). const [pluginComposerId, setPluginComposerId] = useState(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ee3e909c3..7fbb74ffa 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -686,15 +686,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', - getBlob: async () => { - if (attachment.blobId && client) { - return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); - } - const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); - if (!bytes || bytes.byteLength === 0) return null; - const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; - return new Blob([buffer], { type: inertBlobType(attachment.type) }); - }, + // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it + // stays with the browser's drag, which only writes it where the user drops it. + ...(attachment.decryptedAttachment ? {} : { + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData; + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, + }), getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 0078cd8bf..f3940efaa 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -111,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const router = useRouter(); const pathname = usePathname(); const tLaunch = useTranslations("pwaLaunch"); + const tDesktop = useTranslations("desktopOs"); const sentToTarget = useRef(new Set()); const { dialogProps, confirm } = useConfirmDialog(); const [offer, setOffer] = useState(null); @@ -213,6 +214,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle if (osEnv.current.pathname !== path) osEnv.current.push(path); }, notifyFailed: () => toast.error(tLaunch("open_failed")), + notifyReplyLost: () => toast.error(tDesktop("reply_lost")), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 465180c64..d3f717f64 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -33,7 +33,8 @@ async function fingerprint() { const leaveFor = vi.fn(); const push = vi.fn(); const notifyFailed = vi.fn(); -const env = { leaveFor, push, notifyFailed }; +const notifyReplyLost = vi.fn(); +const env = { leaveFor, push, notifyFailed, notifyReplyLost }; const focus = vi.fn(); async function run(command: DesktopOsCommand) { @@ -191,6 +192,33 @@ describe('a toast\'s buttons', () => { stop(); }); + it('behind the app lock an old toast acts on nothing, and the window comes up', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + setAppLockCheck(() => true); + for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) { + await run({ type: 'notification', action, context: await context(), reply: 'x' }); + } + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + setAppLockCheck(null); + stop(); + }); + + it('typed text that has nowhere to go says so', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const stop = setMailLinkOpener(vi.fn()); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' }); + expect(notifyReplyLost).toHaveBeenCalled(); + notifyReplyLost.mockClear(); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) }); + expect(notifyReplyLost).not.toHaveBeenCalled(); + stop(); + }); + it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => { const { setMailLinkOpener } = await import('../mail-links'); const opener = vi.fn(); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index ca3f25bf1..da28971f6 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -87,6 +87,8 @@ beforeEach(async () => { }); const { setAppLockCheck } = await import('../app-lock-state'); setAppLockCheck(null); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); }); afterEach(() => { @@ -266,6 +268,15 @@ describe('what the notification says', () => { expect(JSON.stringify(notification)).not.toContain('Alice Sender'); }); + it('before the policy is known: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: false }); + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + it('several at once are told about once, and that one has no message to act on', async () => { const { buildMailNotification } = await import('../mail-notifications'); const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels); diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts new file mode 100644 index 000000000..d640b50b7 --- /dev/null +++ b/lib/desktop/__tests__/os-signout.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** A sign-out leaves nothing of the account on the OS's surfaces. */ + +vi.setConfig({ testTimeout: 60_000 }); + +describe('sign-out', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + it('clears what the desktop app shows outside its windows', async () => { + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup'); + purgeSignedOutData(); + expect(clearSurfaces).toHaveBeenCalled(); + }); + + it('in a browser there is nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + }); +}); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index cd876cfd2..925e4d024 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -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(); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 2e005476f..b6df496bb 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -159,7 +159,7 @@ describe('files on the window', () => { setBridge({}); const stop = startWindowFiles(); const target = vi.fn(); - const clear = setComposerFileTarget(target); + const clear = setComposerFileTarget(target, () => true); window.dispatchEvent(dropEvent('drop', [file])); expect(target).toHaveBeenCalledWith([file]); expect(takeHandoff('compose')).toBeNull(); @@ -167,6 +167,20 @@ describe('files on the window', () => { stop(); }); + it('a composer out of sight on a hidden tab takes nothing', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const hidden = vi.fn(); + const clear = setComposerFileTarget(hidden, () => false); + window.dispatchEvent(dropEvent('drop', [file])); + expect(hidden).not.toHaveBeenCalled(); + expect(takeHandoff('compose')?.files).toEqual([file]); + clear(); + stop(); + }); + it('a pasted file is taken; pasted text is not', async () => { const { startWindowFiles } = await import('../window-files'); const { takeHandoff } = await import('@/lib/launch-handoff'); @@ -174,7 +188,7 @@ describe('files on the window', () => { const stop = startWindowFiles(); const paste = (files: File[]) => { const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files } }); + Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); window.dispatchEvent(event); return event; }; @@ -185,6 +199,54 @@ describe('files on the window', () => { stop(); }); + describe('a paste that is not for us', () => { + const pasteOn = (target: EventTarget, types: string[]) => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } }); + target.dispatchEvent(event); + return event; + }; + + it('text with a file, as a spreadsheet copies, stays text', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('in a search field or any other input, a file is not an attachment', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const input = document.body.appendChild(document.createElement('input')); + expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + input.remove(); + stop(); + }); + + it('in the body of a composer on screen, a bare file attaches', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target, () => true); + const composer = document.body.appendChild(document.createElement('div')); + composer.setAttribute('data-testid', 'email-composer'); + const body = composer.appendChild(document.createElement('div')); + body.setAttribute('contenteditable', 'true'); + pasteOn(body, ['Files']); + expect(target).toHaveBeenCalledWith([file]); + composer.remove(); + clear(); + stop(); + }); + }); + it('our own native drag over our own window is not a new file', async () => { const { setOwnDragActive, startWindowFiles } = await import('../window-files'); const { takeHandoff } = await import('@/lib/launch-handoff'); @@ -247,6 +309,23 @@ describe('dragging an attachment out', () => { await waitFor(() => expect(startDrag).toHaveBeenCalled()); }); + it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const { name, type, getBlobUrl } = source; + const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true)); + act(() => hook.result.current.onPointerEnter()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(startDrag).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl)); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + it('in a browser: still DownloadURL, and no copy button', async () => { const hook = await ready(); const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; @@ -313,15 +392,52 @@ describe('waking up', () => { stop(); }); - it('a network change counts as waking', async () => { - const connection = new EventTarget(); - vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection })); + it('a new kind of network counts as waking; a change in its speed estimate does not', async () => { + const connection = Object.assign(new EventTarget(), { type: 'wifi' }); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); const { power, stop } = await started(); const wake = vi.fn(); const stopWake = power.addWakeHandler(wake); - await new Promise((resolve) => setTimeout(resolve, 0)); connection.dispatchEvent(new Event('change')); - expect(wake).toHaveBeenCalled(); + expect(wake).not.toHaveBeenCalled(); + connection.type = 'ethernet'; + connection.dispatchEvent(new Event('change')); + expect(wake).toHaveBeenCalledTimes(1); + stopWake(); + stop(); + }); + + it('where Chromium gives no type, a change is nothing to go on', async () => { + const connection = new EventTarget(); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + connection.dispatchEvent(new Event('change')); + expect(wake).not.toHaveBeenCalled(); + stopWake(); + stop(); + }); + + it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => { + const online = vi.fn(); + window.addEventListener('online', online); + vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } })); + const { fire, power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + fire('resume'); + expect(wake).toHaveBeenCalledTimes(1); + expect(online).not.toHaveBeenCalled(); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(1); + clock.mockReturnValue(now + 31_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(2); + clock.mockRestore(); + window.removeEventListener('online', online); stopWake(); stop(); }); @@ -357,6 +473,8 @@ describe('the summary', () => { useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' }); useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never }); useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never }); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); }); afterEach(async () => { @@ -374,6 +492,42 @@ describe('the summary', () => { expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' }); }); + it('before the policy is known it is empty', async () => { + const { usePolicyStore } = await import('@/stores/policy-store'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + usePolicyStore.setState({ loaded: false }); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + + it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => { + const setSummary = vi.fn(); + setBridge({ setSummary }); + const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary'); + const { useAuthStore } = await import('@/stores/auth-store'); + const { usePolicyStore } = await import('@/stores/policy-store'); + let answer: (value: unknown) => void = () => {}; + getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + const summary = startOsSummary(() => 'en'); + await vi.advanceTimersByTimeAsync(2100); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + answer({ emails: [row], hasMore: false, total: 1 }); + await vi.advanceTimersByTimeAsync(10); + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch'); + + setSummary.mockClear(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } }); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenCalled(); + summary.stop(); + vi.useRealTimers(); + }); + it('while locked it is empty, without asking anyone anything', async () => { const { setAppLockCheck } = await import('../app-lock-state'); const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts index 922a5c1b5..33e36bd81 100644 --- a/lib/desktop/bridge.ts +++ b/lib/desktop/bridge.ts @@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe getDesktopBridge()?.servers?.forget?.(serverUrl, reason); } +/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */ +export function clearDesktopSurfaces(): void { + getDesktopBridge()?.os?.clearSurfaces?.(); +} + /** True in any window of the desktop app, standalone or on an instance. */ export function isDesktopApp(): boolean { return getDesktopBridge() !== null; diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 05b3303ba..12cd12e5d 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -21,6 +21,7 @@ import { isPopoutWindow } from '@/lib/popout/window-role'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; import { useSettingsStore } from '@/stores/settings-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -72,17 +73,22 @@ async function senderInfo(client: IJMAPClient): Promise { }; } -function withoutContent(sender: Sender): boolean { +/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */ +export function contentHiddenFor(accountEntryId: string | null | undefined): boolean { if (isAppLocked()) return true; // Unknown counts as on, as on the server's route. - if (!sender.entryId) return true; + if (!accountEntryId || !usePolicyStore.getState().loaded) return true; try { - return devicesForAccount(sender.entryId).pushWithoutContent; + return devicesForAccount(accountEntryId).pushWithoutContent; } catch { return true; } } +function withoutContent(sender: Sender): boolean { + return contentHiddenFor(sender.entryId); +} + function senderName(mail: NewMail): string { const first = mail.email.from?.[0]; return first?.name || first?.email || ''; diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 3a6fa8703..ede5e140b 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -5,6 +5,7 @@ * user's own OS, so it skips the question a web share gets (decision 17). */ +import { isAppLocked } from '@/lib/desktop/app-lock-state'; import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsCommand } from '@/lib/desktop/types'; import { openMailLink } from '@/lib/desktop/mail-links'; @@ -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 { + // 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') { diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts index 12d014051..599dd942a 100644 --- a/lib/desktop/os-summary.ts +++ b/lib/desktop/os-summary.ts @@ -6,9 +6,8 @@ */ import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; -import { devicesForAccount } from '@/lib/device-policy'; import type { CalendarEvent, Email } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; @@ -16,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; const ROWS = 5; const REFRESH_DELAY_MS = 2000; @@ -46,12 +46,10 @@ export function formatEventTime(start: number, now: number, locale: string | und } export async function readSummary(locale: string | undefined, now = Date.now()): Promise { - if (isAppLocked()) return EMPTY_SUMMARY; const auth = useAuthStore.getState(); const client: IJMAPClient | null = auth.client; const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId); - if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY; - if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY; + if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY; const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared); let unread: DesktopOsTrayMessage[] = []; @@ -91,12 +89,14 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () const os = getDesktopBridge()?.os; if (!os) return { refresh: () => {}, stop: () => {} }; let timer: ReturnType | null = null; - let stale = false; + /** A read under way when the account changed answers for the old one, and is dropped. */ + let generation = 0; const run = () => { timer = null; + const mine = ++generation; void readSummary(locale()).then((summary) => { - if (!stale) os.setSummary(summary); + if (mine === generation) os.setSummary(summary); }, (error) => { console.warn('Could not read the tray summary:', error); }); @@ -113,16 +113,24 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () refresh(); }); const stopAuth = useAuthStore.subscribe((state, previous) => { - if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh(); + if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return; + generation += 1; + if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY); + refresh(); + }); + // An organisation turning sender and subject off goes for what's already showing, too. + const stopPolicy = usePolicyStore.subscribe((state, previous) => { + if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh(); }); const interval = setInterval(refresh, REFRESH_EVERY_MS); refresh(); return { refresh, stop() { - stale = true; + generation += 1; stopEmail(); stopAuth(); + stopPolicy(); clearInterval(interval); if (timer) clearTimeout(timer); }, diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 44d429dfc..1e36e52ae 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -13,12 +13,13 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; import { usePolicyStore } from '@/stores/policy-store'; -/** A resume and an unlock usually come together. */ -const WAKE_REPEAT_MS = 2000; +/** A resume, an unlock and a network change come together, and each would mean a round of requests. */ +const WAKE_REPEAT_MS = 30_000; function wake(): void { document.dispatchEvent(new Event('visibilitychange')); - window.dispatchEvent(new Event('online')); + // Woken up with no network: the browser's own event comes when there is one. + if (navigator.onLine !== false) window.dispatchEvent(new Event('online')); const policy = usePolicyStore.getState(); if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); runWakeHandlers(); @@ -38,11 +39,20 @@ export function startPowerWatch(): () => void { if (event === 'resume' || event === 'unlock-screen') wakeOnce(); else if (event === 'suspend') runSleepHandlers(); }); - // Wi-Fi to cable is a new network with no offline moment in between. - const connection = (navigator as unknown as { connection?: EventTarget }).connection; - connection?.addEventListener?.('change', wakeOnce); + // Wi-Fi to cable is a new network with no offline moment in between. This event also fires + // for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the + // type, that leaves the stream's own ping check and the online event. + const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection; + let lastType = connection?.type; + const onConnection = () => { + const type = connection?.type; + if (!type || type === lastType) return; + lastType = type; + wakeOnce(); + }; + connection?.addEventListener?.('change', onConnection); return () => { stopPower(); - connection?.removeEventListener?.('change', wakeOnce); + connection?.removeEventListener?.('change', onConnection); }; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 56db40527..cfdae6576 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -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; onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts index c9b95221a..63a67630b 100644 --- a/lib/desktop/window-files.ts +++ b/lib/desktop/window-files.ts @@ -8,29 +8,58 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { handOffDraft } from '@/lib/launch-handoff'; -type ComposerTarget = (files: File[]) => unknown; +interface ComposerTarget { + take: (files: File[]) => unknown; + /** The Pro shell keeps composers mounted on tabs out of sight. */ + visible: () => boolean; +} -let composerTarget: ComposerTarget | null = null; +const composerTargets: ComposerTarget[] = []; /** Our own native drag passing over our own window isn't a new file. */ let ownDrag = false; -/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */ -export function setComposerFileTarget(target: ComposerTarget): () => void { - composerTarget = target; +/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */ +export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void { + const target = { take, visible }; + composerTargets.push(target); return () => { - if (composerTarget === target) composerTarget = null; + const at = composerTargets.indexOf(target); + if (at !== -1) composerTargets.splice(at, 1); }; } +function visibleComposer(): ComposerTarget | undefined { + return [...composerTargets].reverse().find((target) => target.visible()); +} + export function setOwnDragActive(active: boolean): void { ownDrag = active; } function take(files: File[]): void { - if (composerTarget) composerTarget(files); + const composer = visibleComposer(); + if (composer) composer.take(files); else handOffDraft({ files }); } +function isEditable(target: EventTarget | null): boolean { + const element = target as Partial & { closest?: (selector: string) => Element | null } | null; + return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]'); +} + +/** + * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a + * picture of them, and a field that takes text wants the text. The one editable place a + * bare file belongs in is the composer it's being pasted into. + */ +function pasteIsForUs(event: ClipboardEvent): boolean { + const types = [...(event.clipboardData?.types ?? [])]; + if (types.includes('text/plain') || types.includes('text/html')) return false; + if (!isEditable(event.target)) return true; + const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); + return inComposer && !!visibleComposer(); +} + function hasFiles(event: DragEvent): boolean { return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); } @@ -51,7 +80,7 @@ export function startWindowFiles(): () => void { const onPaste = (event: ClipboardEvent) => { if (event.defaultPrevented) return; const files = Array.from(event.clipboardData?.files ?? []); - if (files.length === 0) return; + if (files.length === 0 || !pasteIsForUs(event)) return; event.preventDefault(); take(files); }; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index f272d443f..80edee15b 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -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 { } export async function wipeAccount(account: string): Promise { + clearDesktopSurfaces(); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); @@ -1086,6 +1088,7 @@ export async function wipeAccount(account: string): Promise { /** Nothing of offline mode stays on this device. */ export async function wipeEverything(): Promise { + clearDesktopSurfaces(); for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 6c4bfffb7..75e80b1a6 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,6 +1,7 @@ import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -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); diff --git a/locales/ar/common.json b/locales/ar/common.json index 8f95fcdc1..5b8dc9018 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6651,7 +6651,8 @@ "action_delete": "حذف", "copy_as_file": "نسخ كملف", "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", - "copy_failed": "تعذّر نسخ الملف." + "copy_failed": "تعذّر نسخ الملف.", + "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك." }, "desktopReminders": { "generic_title": "تذكير", diff --git a/locales/ca/common.json b/locales/ca/common.json index 314e54fee..bb869aa7a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 9b35a5f69..bd1e23b46 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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í", diff --git a/locales/da/common.json b/locales/da/common.json index a34c4f278..f87fa10f6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 6fd1f441d..28fd528df 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 3fac5f0ca..6ecf5f81d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 58aa222ce..6b659b2e0 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 703ce7e93..5a4ff2f25 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index c28fba87a..d667dcbba 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6651,7 +6651,8 @@ "action_delete": "حذف", "copy_as_file": "رونوشت به‌صورت پرونده", "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", - "copy_failed": "رونوشت پرونده ممکن نشد." + "copy_failed": "رونوشت پرونده ممکن نشد.", + "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید." }, "desktopReminders": { "generic_title": "یادآوری", diff --git a/locales/fr/common.json b/locales/fr/common.json index b9ddbdb33..93f7b00d1 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 45cb62559..0b542235c 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6652,7 +6652,8 @@ "action_delete": "מחיקה", "copy_as_file": "העתקה כקובץ", "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", - "copy_failed": "לא ניתן להעתיק את הקובץ." + "copy_failed": "לא ניתן להעתיק את הקובץ.", + "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם." }, "desktopReminders": { "generic_title": "תזכורת", diff --git a/locales/hu/common.json b/locales/hu/common.json index 3b8be79f3..6044ed4c2 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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ő", diff --git a/locales/it/common.json b/locales/it/common.json index 57d091827..a5813f6d6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 14c25a295..aade84482 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6651,7 +6651,8 @@ "action_delete": "削除", "copy_as_file": "ファイルとしてコピー", "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", - "copy_failed": "ファイルをコピーできませんでした。" + "copy_failed": "ファイルをコピーできませんでした。", + "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。" }, "desktopReminders": { "generic_title": "リマインダー", diff --git a/locales/ko/common.json b/locales/ko/common.json index 67a7c3a5f..e6001ba63 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6651,7 +6651,8 @@ "action_delete": "삭제", "copy_as_file": "파일로 복사", "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", - "copy_failed": "파일을 복사할 수 없습니다." + "copy_failed": "파일을 복사할 수 없습니다.", + "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요." }, "desktopReminders": { "generic_title": "알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index 7bdc0fd39..f8556eb48 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 07e77c284..3b7d53f53 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6651,7 +6651,8 @@ "action_delete": "Устгах", "copy_as_file": "Файл болгож хуулах", "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", - "copy_failed": "Файлыг хуулж чадсангүй." + "copy_failed": "Файлыг хуулж чадсангүй.", + "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу." }, "desktopReminders": { "generic_title": "Сануулга", diff --git a/locales/nb/common.json b/locales/nb/common.json index 7a179743a..56e3864df 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index baef02882..d1fc0da7a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index d3155adff..8b8e15de8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2f9742a8c..aa285479b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index fe32a546e..a358376b9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index c07945a37..01e71652a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6651,7 +6651,8 @@ "action_delete": "Удалить", "copy_as_file": "Копировать как файл", "copied": "Файл скопирован. Его можно вставить в папку.", - "copy_failed": "Не удалось скопировать файл." + "copy_failed": "Не удалось скопировать файл.", + "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него." }, "desktopReminders": { "generic_title": "Напоминание", diff --git a/locales/sk/common.json b/locales/sk/common.json index 07a730ab5..252f45e39 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index 19a58526d..11bb888b8 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6659,7 +6659,8 @@ "action_delete": "ลบ", "copy_as_file": "คัดลอกเป็นไฟล์", "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", - "copy_failed": "คัดลอกไฟล์ไม่ได้" + "copy_failed": "คัดลอกไฟล์ไม่ได้", + "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น" }, "desktopReminders": { "generic_title": "การแจ้งเตือน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 55c8647a0..0bacdb6e7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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ı", diff --git a/locales/uk/common.json b/locales/uk/common.json index b54563fe0..be241ff05 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6651,7 +6651,8 @@ "action_delete": "Видалити", "copy_as_file": "Копіювати як файл", "copied": "Файл скопійовано. Його можна вставити в папку.", - "copy_failed": "Не вдалося скопіювати файл." + "copy_failed": "Не вдалося скопіювати файл.", + "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому." }, "desktopReminders": { "generic_title": "Нагадування", diff --git a/locales/vi/common.json b/locales/vi/common.json index 95b051574..a52ffea04 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 986ee29e0..fdb148db0 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6651,7 +6651,8 @@ "action_delete": "刪除", "copy_as_file": "複製為檔案", "copied": "檔案已複製。您可以將其貼到資料夾中。", - "copy_failed": "無法複製檔案。" + "copy_failed": "無法複製檔案。", + "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。" }, "desktopReminders": { "generic_title": "提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index 35528a472..0e53fb826 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6651,7 +6651,8 @@ "action_delete": "删除", "copy_as_file": "复制为文件", "copied": "文件已复制。你可以将其粘贴到文件夹中。", - "copy_failed": "无法复制文件。" + "copy_failed": "无法复制文件。", + "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。" }, "desktopReminders": { "generic_title": "提醒",