diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 5c05dda77..359648627 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -25,7 +25,7 @@ const CAP_FIELDS: CapField[] = [ { key: 'offlineMaxAttachmentMb', label: 'Largest attachment or file kept', - description: 'Anything bigger leaves the device and opens from the server when there is a connection. Files someone chose to keep offline count only towards the total below.', + description: 'Anything bigger leaves the device and opens from the server when there is a connection. This also holds for files someone chose to keep offline.', unit: 'MB', max: 1024 * 1024, }, @@ -107,8 +107,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {

What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything), - with calendar, contacts and files, encrypted in the browser. That protects against someone copying the browser's - files, not against someone using the unlocked browser. The limits above can only lower what people pick: where several + with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not + against someone using the unlocked browser or app. The limits above can only lower what people pick: where several scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings. diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx index 7fcde2e2a..2d214ad4e 100644 --- a/components/offline/__tests__/account-storage.test.tsx +++ b/components/offline/__tests__/account-storage.test.tsx @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const offline = vi.hoisted(() => ({ clearAccount: vi.fn(async () => {}), + disableOffline: vi.fn(async () => {}), + unsentCount: vi.fn(async () => ({ messages: 0, changes: 0 })), chooseScope: vi.fn(), requestPersistence: vi.fn(async () => 'refused'), dismissAreaConflict: vi.fn(async () => {}), @@ -11,6 +13,8 @@ const offline = vi.hoisted(() => ({ vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ ...(await importOriginal()), clearAccount: offline.clearAccount, + disableOffline: offline.disableOffline, + unsentCount: offline.unsentCount, chooseScope: offline.chooseScope, requestPersistence: offline.requestPersistence, dismissAreaConflict: offline.dismissAreaConflict, @@ -21,6 +25,7 @@ vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ import { OfflineSettings } from '../offline-settings'; import { OfflineBanner } from '../offline-banner'; import { useOfflineStore } from '@/lib/offline/state'; +import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; @@ -37,7 +42,7 @@ const area = vi.fn(async (_account: string, name: string) => (name === 'files.ke function setDevices(devices: Partial) { usePolicyStore.setState({ - loaded: true, source: 'server', subject: null, + loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, } as never); } @@ -140,6 +145,15 @@ describe('what it takes', () => { expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated'); }); + it('announces the phase, not every number', () => { + const now = Date.now(); + useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now, startedDone: 0, at: now } } }); + render(); + expect(screen.getByTestId('offline-progress-phase').textContent).toBe('progress_bodies'); + expect(screen.getByTestId('offline-progress-phase').getAttribute('role')).toBe('status'); + expect(screen.getByTestId('offline-progress').querySelectorAll('[aria-live]')).toHaveLength(1); + }); + it('shows a large sync going on, with the time left once it can tell', () => { const now = Date.now(); useOfflineStore.setState({ @@ -163,15 +177,56 @@ describe('what it takes', () => { }); describe('clearing an account', () => { - it('asks first, naming unsent mail', () => { - useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'Hi', to: [], status: 'waiting' }] }); + it('asks first, reading then what would be lost: messages and changes', async () => { + offline.unsentCount.mockResolvedValue({ messages: 1, changes: 3 }); render(); fireEvent.click(screen.getByTestId('offline-clear')); expect(offline.clearAccount).not.toHaveBeenCalled(); + expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument(); expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument(); + expect(offline.unsentCount).toHaveBeenCalledWith('acc'); fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' })); expect(offline.clearAccount).toHaveBeenCalledWith('acc'); }); + + it('says nothing is lost when nothing is', async () => { + offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 }); + render(); + fireEvent.click(screen.getByTestId('offline-clear')); + expect(await screen.findByText('clear_confirm_message')).toBeInTheDocument(); + await waitFor(() => expect(offline.unsentCount).toHaveBeenCalled()); + expect(screen.queryByText(/unsent_changes/)).toBeNull(); + }); + + it('can\'t be asked for without a connection, since it downloads everything again', () => { + useOfflineStore.setState({ browserOffline: true }); + render(); + expect(screen.getByTestId('offline-clear')).toBeDisabled(); + expect(screen.getByText('clear_needs_connection')).toBeInTheDocument(); + }); + + it('nor after a start from the local copy', () => { + useOfflineStore.setState({ startedOffline: true }); + render(); + expect(screen.getByTestId('offline-clear')).toBeDisabled(); + }); +}); + +describe('the off switch', () => { + it('asks when only changes wait, naming them', async () => { + offline.unsentCount.mockResolvedValue({ messages: 0, changes: 2 }); + render(); + fireEvent.click(screen.getByTestId('offline-switch')); + expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument(); + expect(offline.disableOffline).not.toHaveBeenCalled(); + }); + + it('asks nothing when nothing waits', async () => { + offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 }); + render(); + fireEvent.click(screen.getByTestId('offline-switch')); + await waitFor(() => expect(offline.disableOffline).toHaveBeenCalledWith('acc')); + }); }); describe('what the browser may do', () => { @@ -239,6 +294,30 @@ describe('everything that did not go through', () => { }); }); +describe('for screen readers', () => { + it('names each account\'s group when there are several, and the usage list', () => { + useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['b', {}]])) as never }); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'A', email: 'a@example.org' }, { id: 'b', label: 'B', email: 'b@example.org' }] as never }); + render(); + expect(screen.getByRole('group', { name: 'a@example.org' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'b@example.org' })).toBeInTheDocument(); + expect(screen.getByTestId('offline-usage').getAttribute('aria-label')).toBe('usage_label'); + useAccountStore.setState({ accounts: [] }); + }); +}); + +describe('in the desktop app', () => { + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + + it('says what the risk is there, and only there', () => { + render(); + expect(screen.queryByTestId('offline-desktop-warning')).toBeNull(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; + render(); + expect(screen.getByTestId('offline-desktop-warning').textContent).toBe('warning_desktop'); + }); +}); + describe('files kept offline', () => { it('lists the pinned ones with their state, and can let go of one', async () => { render(); @@ -251,6 +330,15 @@ describe('files kept offline', () => { await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.objectContaining({ jmap: 'j', id: 'f1' }))); }); + it('an unpin the store refuses leaves the list as it is, with no error thrown about', async () => { + render(); + const rows = await screen.findAllByTestId('offline-kept-file'); + area.mockImplementationOnce(async () => { throw new Error('gone'); }); + fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' })); + await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.anything())); + expect(await screen.findAllByTestId('offline-kept-file')).toHaveLength(2); + }); + it('a node of another account goes by that account', () => { expect(fileRefFor('shared1:n7', 'own')).toEqual({ jmap: 'shared1', id: 'n7' }); expect(fileRefFor('n7', 'own')).toEqual({ jmap: 'own', id: 'n7' }); diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 98a6b24f1..3113b413e 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -19,7 +19,7 @@ import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; function setNoOffline(noOffline: boolean) { usePolicyStore.setState({ loaded: true, - subject: null, + subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline } } }, }); } @@ -54,12 +54,12 @@ describe('Settings > Offline', () => { expect(offline.enableOffline).toHaveBeenCalledWith('acc'); }); - it('asks before turning off with unsent messages', () => { + it('asks before turning off with unsent messages', async () => { useOfflineStore.setState({ enabled: ['acc'], outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Hi', to: ['b@example.org'], status: 'waiting' }] }); render(); fireEvent.click(screen.getByTestId('offline-switch')); + expect(await screen.findByText('off_confirm_title')).toBeInTheDocument(); expect(offline.disableOffline).not.toHaveBeenCalled(); - expect(screen.getByText('off_confirm_title')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'off_confirm' })); expect(offline.disableOffline).toHaveBeenCalledWith('acc'); }); @@ -78,7 +78,7 @@ describe('Settings > Offline', () => { expect(screen.queryByTestId('offline-switch')).toBeNull(); }); - it('asks before turning off when a draft is only on this device, and counts both lists', () => { + it('asks before turning off when a draft is only on this device, and counts both lists', async () => { useOfflineStore.setState({ enabled: ['acc'], localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }], @@ -86,8 +86,8 @@ describe('Settings > Offline', () => { }); render(); fireEvent.click(screen.getByTestId('offline-switch')); + expect(await screen.findByText('off_confirm_title')).toBeInTheDocument(); expect(offline.disableOffline).not.toHaveBeenCalled(); - expect(screen.getByText('off_confirm_title')).toBeInTheDocument(); useOfflineStore.setState({ localDrafts: [], outbox: [] }); }); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index 773ca78e6..a75a09197 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -1,18 +1,20 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useFormatter, useTranslations } from 'next-intl'; import { Lock } from '@/components/icons'; import { SettingItem, Select, ToggleSwitch } from '@/components/settings/settings-section'; import { OrgLockedNote } from '@/components/settings/org-locked-note'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import { formatFileSize } from '@/lib/utils'; -import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode'; +import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount, unsentCount } from '@/lib/offline/offline-mode'; import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope'; -import { useOfflineStore } from '@/lib/offline/state'; +import { isOfflineNow, useOfflineStore } from '@/lib/offline/state'; import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; import { devicesForAccount } from '@/lib/device-policy'; +import { useAccountStore } from '@/stores/account-store'; import { KeptFiles } from './kept-files'; const MB = 1024 * 1024; @@ -32,7 +34,9 @@ function ProgressLine({ progress }: { progress: SyncProgress }) { : t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) }); return (

-

+ {/* Only the phase is announced: the numbers change every moment. */} +

{phase}

+

{phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}}

{progress.total !== null && progress.total > 0 && ( @@ -42,6 +46,22 @@ function ProgressLine({ progress }: { progress: SyncProgress }) { ); } +/** What a clear or switching off would lose, read when the question is asked. */ +function useUnsent(account: string) { + const [unsent, setUnsent] = useState({ messages: 0, changes: 0 }); + const live = useRef(true); + useEffect(() => { + live.current = true; + return () => { live.current = false; }; + }, []); + const read = async () => { + const counted = await unsentCount(account); + if (live.current) setUnsent(counted); + return counted; + }; + return { unsent, read }; +} + /** One account in Settings > Backup & reset > Offline. */ export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) { const t = useTranslations('offline.settings'); @@ -54,9 +74,16 @@ export function AccountStorage({ account, primary, heading }: { account: string; const storeReset = useOfflineStore((s) => s.health[account]?.reset); const storageFull = useOfflineStore((s) => !!s.storageFull[account]); const progress = useOfflineStore((s) => s.progress[account]); - const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); + const server = useAccountStore((s) => s.accounts.find((entry) => entry.id === account)?.serverUrl); + const address = useAccountStore((s) => { + const entry = s.accounts.find((candidate) => candidate.id === account); + return entry?.email || entry?.username || account; + }); + // Clearing downloads everything again, which can't happen without the server. + const cantClear = useOfflineStore((s) => s.startedOffline || s.browserOffline || (!!server && isOfflineNow(server))); // Re-renders on a choice; the value itself is worked out below, with the policy. useOfflineStore((s) => s.choices[account]); + const { unsent, read: readUnsent } = useUnsent(account); const [confirmOff, setConfirmOff] = useState(false); const [confirmClear, setConfirmClear] = useState(false); const devices = devicesForAccount(account); @@ -85,9 +112,27 @@ export function AccountStorage({ account, primary, heading }: { account: string; }; const caps = attachmentCapOptions(devices.offlineMaxAttachmentMb); const currentCap = maxAttachmentMb(scope); + const lostText = [ + unsent.messages > 0 ? tStorage('clear_confirm_unsent', { count: unsent.messages }) : null, + unsent.changes > 0 ? tStorage('unsent_changes', { count: unsent.changes }) : null, + ].filter(Boolean).join(' '); + const askClear = () => { + setConfirmClear(true); + void readUnsent(); + }; + const turnOff = async () => { + const counted = await readUnsent(); + if (counted.messages + counted.changes > 0) setConfirmOff(true); + else void disableOffline(account); + }; return ( -
+
{heading &&

{heading}

} { if (on) void enableOffline(account); - else if (unsent > 0) setConfirmOff(true); - else void disableOffline(account); + else void turnOff(); }} /> + {isDesktopApp() &&

{tStorage('warning_desktop')}

} {storeReset && (

@@ -114,7 +159,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; {storageFull && (

{tStorage('storage_full_title')} {tStorage('storage_full_text')}

- +
)}
@@ -127,7 +172,7 @@ export function AccountStorage({ account, primary, heading }: { account: string;

{syncing && progressWorthShowing(progress) && } {usage && usage.parts.length > 0 && ( -
+
{usage.parts.filter((part) => part.items > 0).map((part) => (
{tStorage(`usage_${part.part}`)}
@@ -177,8 +222,8 @@ export function AccountStorage({ account, primary, heading }: { account: string; )} - - + + )} @@ -187,7 +232,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; onClose={() => setConfirmOff(false)} onConfirm={() => { setConfirmOff(false); void disableOffline(account); }} title={t('off_confirm_title')} - message={t('off_confirm_message', { count: unsent })} + message={`${tStorage('off_confirm_text', { account: address })} ${lostText}`.trim()} confirmText={t('off_confirm')} variant="destructive" /> @@ -196,7 +241,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; onClose={() => setConfirmClear(false)} onConfirm={() => { setConfirmClear(false); void clearAccount(account); }} title={tStorage('clear_confirm_title')} - message={unsent > 0 ? `${tStorage('clear_confirm_message')} ${tStorage('clear_confirm_unsent', { count: unsent })}` : tStorage('clear_confirm_message')} + message={`${tStorage('clear_confirm_message', { account: address })} ${lostText}`.trim()} confirmText={tStorage('clear_confirm')} variant="destructive" /> diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 2dfea8a3a..5271e670f 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -6,6 +6,7 @@ import { AlertTriangle } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; import { Button } from '@/components/ui/button'; import { IS_LITE } from '@/lib/lite'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import { signedInAccountIds } from '@/lib/device-policy'; import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode'; import { useOfflineStore } from '@/lib/offline/state'; @@ -61,7 +62,7 @@ export function OfflineSettings() { <>
{enabledCount > 0 && persist === 'refused' && (
diff --git a/components/pro/pro-tab-bar.tsx b/components/pro/pro-tab-bar.tsx index 741e3a5a3..755f7b556 100644 --- a/components/pro/pro-tab-bar.tsx +++ b/components/pro/pro-tab-bar.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type DragEvent, type KeyboardEvent } from "react"; import { useTranslations } from "next-intl"; import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, Folder, Search, X, type AppIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; +import { useRestoredTabTitles } from "@/hooks/use-restored-tab-titles"; import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; import { PRO_TAB_DRAG_MIME, @@ -63,6 +64,7 @@ export function ProTabBar({ }: ProTabBarProps) { const tSidebar = useTranslations("sidebar"); const reorderTab = useProTabStore((s) => s.reorderTab); + useRestoredTabTitles(tabs); const [dropIndicator, setDropIndicator] = useState(null); // True while an email or folder payload hovers the strip (both drop "anywhere"). diff --git a/hooks/__tests__/use-keep-file-offline.test.tsx b/hooks/__tests__/use-keep-file-offline.test.tsx new file mode 100644 index 000000000..76883e7a0 --- /dev/null +++ b/hooks/__tests__/use-keep-file-offline.test.tsx @@ -0,0 +1,63 @@ +import 'fake-indexeddb/auto'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { useOfflineStore } from '@/lib/offline/state'; +import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useKeepFileOffline } from '../use-keep-file-offline'; + +const FILE = { id: 'f1' }; +const client = { getAccountId: () => 'j' } as never; +const area = vi.fn(async (_account: string, _name: string, _params?: unknown): Promise => ({ files: [] })); + +function setDevices(devices: Partial) { + usePolicyStore.setState({ + loaded: true, source: 'server', subject: { email: 'a@example.org', groups: [] }, + policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, + } as never); +} + +beforeEach(() => { + area.mockClear(); + area.mockImplementation(async () => ({ files: [] })); + setStoreBackend({ supported: () => true, area } as unknown as StoreBackend); + useAuthStore.setState({ activeAccountId: 'acc' }); + useOfflineStore.setState({ enabled: ['acc'], choices: { acc: { on: true } } }); + useToastStore.setState({ toasts: [] } as never); + setDevices({}); +}); + +afterEach(() => { setStoreBackend(null); }); + +describe('Keep offline in the Files app', () => { + it('is offered for a plain file, not for a folder', () => { + const { result } = renderHook(() => useKeepFileOffline(client)); + expect(result.current.canKeepOffline(FILE)).toBe(true); + expect(result.current.canKeepOffline({ ...FILE, isDirectory: true })).toBe(false); + }); + + it('isn\'t offered when the organisation forbids keeping files', () => { + setDevices({ noOfflineAttachments: true }); + const { result } = renderHook(() => useKeepFileOffline(client)); + expect(result.current.canKeepOffline(FILE)).toBe(false); + }); + + it('isn\'t offered when the user keeps no attachments', () => { + useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } }); + const { result } = renderHook(() => useKeepFileOffline(client)); + expect(result.current.canKeepOffline(FILE)).toBe(false); + }); + + it('says so when the store has no row for the file yet', async () => { + area.mockImplementation(async (_account, name) => { + if (name === 'files.pin') throw new Error('No file f1'); + return { files: [] }; + }); + const { result } = renderHook(() => useKeepFileOffline(client)); + await act(async () => { await result.current.toggleKeptOffline(FILE); }); + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('pin_not_ready')); + }); +}); diff --git a/hooks/__tests__/use-restored-tab-titles.test.tsx b/hooks/__tests__/use-restored-tab-titles.test.tsx new file mode 100644 index 000000000..bf046b30a --- /dev/null +++ b/hooks/__tests__/use-restored-tab-titles.test.tsx @@ -0,0 +1,46 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; +import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store'; +import { useRestoredTabTitles } from '../use-restored-tab-titles'; + +const stored = (id: string, title = TITLE_UNTIL_LOADED): ProTab => ({ + id, kind: 'email', labelKey: '', title, closeable: true, paneId: 'main', + emailData: { accountId: 'j', emailId: `m-${id}`, mailboxId: null, title }, +}); + +const getEmail = vi.fn(async (id: string) => ({ subject: id === 'm-empty' ? '' : `Subject of ${id}` })); + +function current(id: string) { + return useProTabStore.getState().tabs.find((tab) => tab.id === id); +} + +beforeEach(() => { + getEmail.mockClear(); + useAuthStore.setState({ client: { getEmail } as never }); +}); + +describe('tabs restored without their subjects', () => { + it('get them back without being opened, also an empty one', async () => { + const tabs = [stored('a'), stored('empty'), stored('known', 'Known')]; + useProTabStore.setState({ tabs }); + renderHook(() => useRestoredTabTitles(tabs)); + await waitFor(() => expect(current('a')?.title).toBe('Subject of m-a')); + expect(current('a')?.emailData?.title).toBe('Subject of m-a'); + await waitFor(() => expect(current('empty')?.title).toBe('no_subject')); + expect(getEmail).toHaveBeenCalledTimes(2); + }); + + it('wait for a client, and keep their mark if the message can\'t be read', async () => { + const tabs = [stored('a')]; + useProTabStore.setState({ tabs }); + useAuthStore.setState({ client: null }); + const { rerender } = renderHook(() => useRestoredTabTitles(tabs)); + expect(getEmail).not.toHaveBeenCalled(); + getEmail.mockRejectedValueOnce(new Error('offline')); + useAuthStore.setState({ client: { getEmail } as never }); + rerender(); + await waitFor(() => expect(getEmail).toHaveBeenCalled()); + expect(current('a')?.title).toBe(TITLE_UNTIL_LOADED); + }); +}); diff --git a/hooks/use-keep-file-offline.ts b/hooks/use-keep-file-offline.ts index aff9cd1cb..31521bec3 100644 --- a/hooks/use-keep-file-offline.ts +++ b/hooks/use-keep-file-offline.ts @@ -1,7 +1,12 @@ 'use client'; import { useCallback, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { useDevicePolicy } from '@/hooks/use-device-policy'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { scopeForAccount } from '@/lib/offline/offline-mode'; +import { useOfflineStore } from '@/lib/offline/state'; +import { toast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useKeptFiles } from './use-kept-files'; @@ -22,18 +27,32 @@ export function fileRefFor(id: string, ownAccount: string): { jmap: string; id: /** "Keep offline" in the Files app. Nothing to offer when the account doesn't keep mail here. */ export function useKeepFileOffline(client: IJMAPClient | null | undefined) { + const t = useTranslations('offlineStorage'); const account = useAuthStore((s) => s.activeAccountId); const { active, pinned, pin, unpin } = useKeptFiles(account); const own = client?.getAccountId() ?? ''; const keptOffline = useMemo(() => new Set(pinned.map((file) => (file.jmap === own ? file.id : `${file.jmap}:${file.id}`))), [pinned, own]); - const canKeepOffline = useCallback((resource: FileRef) => active && !!own && !resource.isDirectory && !resource.isSymlink, [active, own]); + // Nothing to offer where the organisation or the user keeps no files. + const devices = useDevicePolicy(); + useOfflineStore((s) => (account ? s.choices[account] : undefined)); + const attachmentsAllowed = !devices.noOfflineAttachments && !!account && scopeForAccount(account).scope.attachments; + + const canKeepOffline = useCallback( + (resource: FileRef) => active && attachmentsAllowed && !!own && !resource.isDirectory && !resource.isSymlink, + [active, attachmentsAllowed, own], + ); const toggleKeptOffline = useCallback(async (resource: FileRef) => { const ref = fileRefFor(resource.id, own); - await (keptOffline.has(resource.id) ? unpin(ref) : pin(ref)).catch(() => {}); - }, [own, keptOffline, pin, unpin]); + if (keptOffline.has(resource.id)) { + await unpin(ref); + return; + } + // A file so new the store hasn't listed it yet has nothing to pin. + await pin(ref).catch(() => toast.info(t('pin_not_ready'))); + }, [own, keptOffline, pin, unpin, t]); return { keptOffline, canKeepOffline, toggleKeptOffline }; } diff --git a/hooks/use-kept-files.ts b/hooks/use-kept-files.ts index 3a706968f..b897687e1 100644 --- a/hooks/use-kept-files.ts +++ b/hooks/use-kept-files.ts @@ -50,7 +50,8 @@ export function useKeptFiles(account: string | null | undefined) { const unpin = useCallback(async (ref: { jmap: string; id: string }) => { if (!account) return; - await getStore().area(account, 'files.unpin', ref); + // The store being in trouble: the list says what's so. + await getStore().area(account, 'files.unpin', ref).catch(() => {}); await read(); }, [account, read]); diff --git a/hooks/use-restored-tab-titles.ts b/hooks/use-restored-tab-titles.ts new file mode 100644 index 000000000..f26281fc2 --- /dev/null +++ b/hooks/use-restored-tab-titles.ts @@ -0,0 +1,35 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/stores/auth-store'; +import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store'; + +/** + * With the local store on, stored tabs have no subjects. A tab's body puts its + * title back, but only an open tab has a body, so the others are asked for here. + */ +export function useRestoredTabTitles(tabs: readonly ProTab[]): void { + const t = useTranslations('email_composer'); + const updateTabTitle = useProTabStore((s) => s.updateTabTitle); + const client = useAuthStore((s) => s.client); + const waiting = tabs.filter((tab) => tab.kind === 'email' && tab.title === TITLE_UNTIL_LOADED && tab.emailData); + const key = waiting.map((tab) => tab.id).join(' '); + + useEffect(() => { + if (!key || !client) return; + let stale = false; + for (const tab of waiting) { + const data = tab.emailData!; + const owner = data.clientAccountId ? useAuthStore.getState().getClientForAccount(data.clientAccountId) : client; + // Its login is gone or not back yet. The next render asks again. + if (!owner) continue; + void owner.getEmail(data.emailId, data.accountId).then((email) => { + if (!stale && email) updateTabTitle(tab.id, email.subject || t('no_subject')); + }).catch(() => {}); + } + return () => { stale = true; }; + // `waiting` follows `key`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, client, updateTabTitle]); +} diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index bc24e6fe0..e34302733 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -967,7 +967,9 @@ async function statusSettled(page: Page): Promise { test('a cap from the organisation prunes kept mail at the next policy fetch, and lifting it fetches the mail back', async ({ browser }) => { const old = `Old message ${RUN}`; + const recent = `Recent message ${RUN}`; await importOld(ENT.alice, old, 200); + await deliverAndWait(recent, `Recent body ${RUN}`); const page = await userPage(browser, ENT.alice); await enableOffline(page); const windowRow = page.locator('[data-search-label="How far back to keep"]'); @@ -998,8 +1000,18 @@ test('a cap from the organisation prunes kept mail at the next policy fetch, and await toMail(page); await blockMailServer(page.context()); await waitUntilOffline(page); - await page.goto('/mail?q=' + encodeURIComponent(`"Old message"`)).catch(() => {}); - await expect(page.locator('[data-testid="email-list-item"]', { hasText: old })).toHaveCount(0); + const search = async (term: string) => { + const box = page.getByPlaceholder('Search mail').first(); + await box.fill(term); + await box.press('Enter'); + }; + const rows = page.locator('[data-testid="email-list-item"]'); + // A word of a message that stayed finds it, so the search does work here. + await search(recent); + await expect(rows.filter({ hasText: recent }).first()).toBeVisible({ timeout: 30_000 }); + await search(old); + await expect(rows.filter({ hasText: recent })).toHaveCount(0, { timeout: 30_000 }); + await expect(rows.filter({ hasText: old })).toHaveCount(0); await page.context().unroute(`${new URL(JMAP_URL).origin}/**`); await setPolicy(browser, {}); @@ -1056,8 +1068,20 @@ test('the storage page: usage, choices, clearing an account', async ({ browser } test('the storage page fits at 390 px', async ({ browser }) => { const phone = await userPage(browser, ENT.alice, true); await enableOffline(phone, true); - const overflow = await phone.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - expect(overflow).toBeLessThanOrEqual(0); + // Scrolling happens in the pane, so the page's own width says nothing: nothing may reach past the screen. + const section = phone.locator('[data-settings-section="offline"]'); + await expect(section).toBeVisible(); + const past = await section.evaluate((root) => { + const width = document.documentElement.clientWidth; + const out: string[] = []; + for (const el of [root, ...root.querySelectorAll('*')]) { + const box = el.getBoundingClientRect(); + if (box.width > 0 && (box.right > width + 1 || box.left < -1)) out.push(`${el.tagName} ${el.getAttribute('data-testid') ?? ''} ${Math.round(box.right)}`); + if (el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX !== 'visible' && getComputedStyle(el).overflowX !== 'hidden') out.push(`scrolls: ${el.tagName} ${el.className}`); + } + return out; + }); + expect(past).toEqual([]); await shot(phone, 'do-storage-page-390'); await phone.context().close(); }); @@ -1119,10 +1143,40 @@ test('a browser that refuses to keep the data gets a warning and can be asked ag await page.context().close(); }); +/** A message with a text attachment of `size` bytes, repeating `term`, imported so the bytes stay as they are. */ +async function importWithAttachment(account: string, subject: string, term: string, size: number): Promise { + const client = await jmapAs(account); + const content = `${term} `.repeat(Math.ceil(size / (term.length + 1))); + const boundary = `b${RUN}`; + const raw = [ + `From: ${ENT.bob}`, `To: ${account}`, `Subject: ${subject}`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${RUN}att@example.org>`, 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain', '', `See the file ${RUN}`, `--${boundary}`, + 'Content-Type: text/plain; name="kept.txt"', 'Content-Disposition: attachment; filename="kept.txt"', 'Content-Transfer-Encoding: 8bit', '', content, `--${boundary}--`, '', + ].join('\r\n'); + const auth = 'Basic ' + Buffer.from(`${account}:${process.env.IT_ENT_PASSWORD}`).toString('base64'); + const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: raw }); + if (!upload.ok) throw new Error(`upload: ${upload.status}`); + const { blobId } = await upload.json() as { blobId: string }; + const boxes = await client.request([['Mailbox/query', { accountId: client.accountId, filter: { role: 'inbox' } }, '0']]); + const inbox = boxes.methodResponses[0][1].ids[0] as string; + const done = await client.request([['Email/import', { accountId: client.accountId, emails: { a: { blobId, mailboxIds: { [inbox]: true }, keywords: {}, receivedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z') } } }, '0']]); + if (!done.methodResponses[0][1].created?.a) throw new Error(`import: ${JSON.stringify(done.methodResponses[0][1])}`); +} + +/** How many bytes of attachments the settings page says are kept, roughly: it's what the store counts. */ +async function keptAttachmentBytes(page: Page): Promise { + const row = page.getByTestId('offline-usage').locator('[data-part="attachments"] dd'); + if ((await row.count()) === 0) return 0; + const match = (await row.innerText()).replace(',', '.').match(/([\d.]+)\s*(B|KB|MB|GB)/); + return match ? Number(match[1]) * { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }[match[2] as 'B' | 'KB' | 'MB' | 'GB'] : 0; +} + test('nothing of a kept message is readable on the device', async ({ browser }) => { const subject = `Rawcheck ${RUN} ${RUN}quokka`; const text = `Rawbody ${RUN} ${RUN}wombat`; + const inFile = `${RUN}platypus`; await deliverAndWait(subject, text); + await importWithAttachment(ENT.alice, `Rawfile ${RUN}`, inFile, 300_000); const page = await userPage(browser, ENT.alice); await toMail(page); await openMessage(page, subject); @@ -1201,13 +1255,16 @@ test('nothing of a kept message is readable on the device', async ({ browser }) await expect(body(page)).toContainText(text, { timeout: 20_000 }); await page.waitForTimeout(1500); - for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`]) { + // The file has to be on the device for this to say anything: 300 KB of it. + await page.goto('/settings/backup/offline'); + await expect.poll(() => keptAttachmentBytes(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(300_000); + for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`, inFile]) { const { hits, files, bytesRead } = await findIn(term); expect(files, 'the store has its files').toBeGreaterThan(0); expect(bytesRead, 'and they hold something').toBeGreaterThan(100_000); expect(hits, `plaintext "${term}"`).toEqual([]); } - await page.reload(); + await page.goto('/mail'); await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(1500); expect((await findIn(subject)).hits).toEqual([]); diff --git a/lib/device-policy.ts b/lib/device-policy.ts index 3489e28df..22db6991a 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -84,6 +84,11 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy { return devicesOf(policy); } +/** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */ +export function devicesForNoOne(): DevicePolicy { + return devicesOf(usePolicyStore.getState().resolveFor(null)); +} + /** Local ids of every account signed in here, the active one included. */ export function signedInAccountIds(): string[] { const auth = useAuthStore.getState(); diff --git a/lib/offline/__tests__/client-fallback.test.ts b/lib/offline/__tests__/client-fallback.test.ts index a4aa5ffa5..fa8106f2b 100644 --- a/lib/offline/__tests__/client-fallback.test.ts +++ b/lib/offline/__tests__/client-fallback.test.ts @@ -43,7 +43,7 @@ beforeEach(async () => { forgetLegacyMove(); localStorage.clear(); useOfflineStore.setState({ enabled: [], downServers: [], browserOffline: false, outbox: [], pendingChanges: 0, conflicts: [] }); - usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] }); vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { if (init?.mode === 'no-cors') { diff --git a/lib/offline/__tests__/drafts-flush.test.ts b/lib/offline/__tests__/drafts-flush.test.ts index f9139cf7d..b609b5632 100644 --- a/lib/offline/__tests__/drafts-flush.test.ts +++ b/lib/offline/__tests__/drafts-flush.test.ts @@ -67,7 +67,7 @@ beforeEach(async () => { forgetOrphanCheck(); localStorage.clear(); useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false }); - usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); const client = fakeClient(); useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index fa421b495..4bf221c12 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -14,14 +14,17 @@ import { disableOffline, enableOffline, enforcePolicy, + refreshQueueState, + refreshUsage, registerOfflineHooks, scopeForAccount, setOfflineNotifier, startOfflineMode, + unsentCount, wipeEverything, type OfflineNotice, } from '../offline-mode'; -import { OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state'; +import { clearEvicted, markEvicted, OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state'; const NOW = Date.now(); const iso = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString(); @@ -31,7 +34,7 @@ let server: FakeJmap; function setDevices(devices: Partial): void { usePolicyStore.setState({ - loaded: true, source: 'server', subject: null, subjectPending: false, + loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, } as never); } @@ -59,6 +62,98 @@ afterEach(async () => { setStoreBackend(null); }); +describe('at page load, before the subject is known', () => { + const forbiddenExceptGroup = { + ...usePolicyStore.getState().rawPolicy, + organisation: { devices: { noOffline: true } }, + scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }], + }; + + function loading(): void { + useOfflineStore.setState({ enabled: ['acc'] }); + usePolicyStore.setState({ rawPolicy: forbiddenExceptGroup, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never); + usePolicyStore.setState({ policy: usePolicyStore.getState().resolveFor(null) } as never); + useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null }); + } + + it('Everyone forbidding it wipes nothing while a group scope may allow it', async () => { + loading(); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(store.removed).toEqual([]); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + expect(notices.filter((n) => n.kind === 'forbidden')).toEqual([]); + }); + + it('with the subject in, the group decides', async () => { + loading(); + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] }); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + + usePolicyStore.getState().setPolicySubject({ email: 'bob@example.org', groups: [] }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + }); + + it('with no client coming, Everyone decides', async () => { + loading(); + useAuthStore.setState({ isLoading: false, client: null }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + }); + + it('pushes no scope either: a wider one would cost a re-list on every reload', async () => { + usePolicyStore.setState({ rawPolicy: { ...forbiddenExceptGroup, organisation: {}, scopes: [] }, loaded: true, source: 'server', partial: false, subject: null } as never); + useOfflineStore.setState({ enabled: ['acc'] }); + useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null }); + const setScope = vi.spyOn(store, 'setScope'); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(setScope).not.toHaveBeenCalled(); + }); +}); + +describe('the public part of the policy', () => { + it('is not the organisation\'s word: no scope goes out, and no desktop default', async () => { + useOfflineStore.setState({ enabled: ['acc'] }); + usePolicyStore.setState({ partial: true } as never); + const setScope = vi.spyOn(store, 'setScope'); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(setScope).not.toHaveBeenCalled(); + + useOfflineStore.setState({ enabled: [] }); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(useOfflineStore.getState().enabled).toEqual([]); + }); +}); + +describe('an account dropped by the app', () => { + it('is judged by what applies to anyone, not by the groups of whoever is signed in', async () => { + const policy = { + ...usePolicyStore.getState().rawPolicy, + organisation: { devices: { noOffline: true } }, + scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }], + }; + usePolicyStore.setState({ rawPolicy: policy, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never); + useAuthStore.setState({ activeAccountId: 'other', isLoading: false, client: null }); + useAccountStore.setState({ accounts: [{ id: 'other', label: 'other', serverUrl: 'https://mail.example.org', username: 'other@example.org' } as never] }); + usePolicyStore.getState().setPolicySubject({ email: 'other@example.org', groups: ['team@example.org'] }); + useOfflineStore.setState({ enabled: ['gone', 'other'] }); + markEvicted('gone'); + enforcePolicy(); + await vi.waitFor(() => expect(store.removed).toContain('gone')); + // Whoever is here keeps theirs: they are in the group. + expect(store.removed).not.toContain('other'); + expect(useOfflineStore.getState().enabled).toEqual(['other']); + clearEvicted('gone'); + }); +}); + describe('a policy fetch', () => { async function kept(): Promise { server.addEmail({ id: 'old', receivedAt: iso(100) }); @@ -112,6 +207,34 @@ describe('the user picking what to keep', () => { expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).acc.days).toBe(30); }); + it('waits for a choice to settle: arrowing past a short window prunes nothing', async () => { + writeEnabled(['acc']); + useOfflineStore.setState({ choices: { acc: { on: true, days: null } } }); + const setScope = vi.spyOn(store, 'setScope'); + vi.useFakeTimers(); + try { + chooseScope('acc', { days: 30 }); + await vi.advanceTimersByTimeAsync(300); + chooseScope('acc', { days: 90 }); + await vi.advanceTimersByTimeAsync(300); + chooseScope('acc', { days: 365 }); + await vi.advanceTimersByTimeAsync(300); + expect(setScope).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1000); + } finally { + vi.useRealTimers(); + } + expect(setScope).toHaveBeenCalledTimes(1); + expect(setScope.mock.calls[0][1].days).toBe(365); + }); + + it('starts from what is in force: someone from before the local store keeps the rest of what they had', () => { + writeEnabled(['acc']); + useOfflineStore.setState({ choices: {} }); + chooseScope('acc', { attachments: false }); + expect(useOfflineStore.getState().choices.acc).toMatchObject({ on: true, days: 30, attachments: false }); + }); + it('can\'t get past the cap', () => { setDevices({ offlineMaxDays: 30 }); writeEnabled(['acc']); @@ -137,6 +260,63 @@ describe('opened messages', () => { }); }); +describe('the usage of several accounts', () => { + it('one store in trouble leaves what the others said', async () => { + writeEnabled(['acc', 'bad']); + const area = vi.spyOn(store, 'area'); + const real = area.getMockImplementation(); + area.mockImplementation(async (account: string, name: string) => { + if (name !== 'store.usage') return real ? real(account, name) : undefined; + if (account === 'bad') throw new Error('gone'); + return { bytes: 5, parts: [] }; + }); + await refreshUsage(); + expect(useOfflineStore.getState().usage.acc?.bytes).toBe(5); + area.mockRestore(); + }); +}); + +describe('what a browser clearing the store would lose', () => { + it('counts local drafts too', async () => { + writeEnabled(['acc']); + const real = store.area.bind(store); + vi.spyOn(store, 'area').mockImplementation(async (account: string, name: string, params?: unknown) => ( + name.startsWith('drafts.') && !name.includes('Elsewhere') + ? [{ localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }] + : real(account, name, params) + ) as never); + await refreshQueueState(); + expect(useOfflineStore.getState().health.acc?.pending).toBe(1); + vi.restoreAllMocks(); + }); +}); + +describe('what would be lost', () => { + it('counts changes per account when a store can\'t answer, and local drafts', async () => { + useOfflineStore.setState({ + enabled: ['acc', 'b'], + changesBy: { acc: 2, b: 7 }, + outbox: [], + localDrafts: [{ account: 'acc', localId: 'd', status: 'waiting' } as never], + }); + vi.spyOn(store, 'area').mockRejectedValue(new Error('gone')); + expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 2 }); + vi.restoreAllMocks(); + }); +}); + +describe('turning the last account off', () => { + it('keeps the off of another account still signed in', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; + useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['other', {}]])) as never }); + useOfflineStore.setState({ choices: { other: { on: false } } }); + writeEnabled(['acc']); + await disableOffline('acc'); + expect(useOfflineStore.getState().choices).toEqual({ acc: { on: false }, other: { on: false } }); + expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).other.on).toBe(false); + }); +}); + describe('clearing an account', () => { it('removes its store and starts over, and it stays on', async () => { server.addEmail({ id: 'new', receivedAt: iso(5) }); @@ -162,10 +342,16 @@ describe('clearing in another tab', () => { const stop = startOfflineMode(); const ensureOpen = vi.spyOn(store, 'ensureOpen'); ensureOpen.mockClear(); + const area = vi.spyOn(store, 'area'); + // What the start itself asks for is over. + await new Promise((resolve) => setTimeout(resolve, 20)); + area.mockClear(); localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { clearedAt: 2, pending: 0 } })); window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_HEALTH_KEY })); expect(ensureOpen).toHaveBeenCalledWith('acc'); expect(useOfflineStore.getState().health.acc.clearedAt).toBe(2); + // Its lists and sizes are of a store that's gone. + await vi.waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'store.usage')); stop(); }); }); @@ -210,13 +396,6 @@ describe('the desktop app', () => { expect(useOfflineStore.getState().choices.acc?.on).toBe(false); }); - it('holds to a cap from the first open', async () => { - asDesktop(); - setDevices({ offlineMaxDays: 14 }); - enforcePolicy(); - await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc'])); - expect(scopeForAccount('acc').scope.days).toBe(14); - }); }); describe('a browser', () => { diff --git a/lib/offline/__tests__/handoff-wipes.test.ts b/lib/offline/__tests__/handoff-wipes.test.ts index 534a060c8..26699c78d 100644 --- a/lib/offline/__tests__/handoff-wipes.test.ts +++ b/lib/offline/__tests__/handoff-wipes.test.ts @@ -20,7 +20,7 @@ beforeEach(async () => { setStoreBackend(new InProcessStore()); localStorage.clear(); useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); - usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); }); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index f01524e44..1ce7a5c4c 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -56,7 +56,7 @@ beforeEach(async () => { forgetLegacyMove(); localStorage.clear(); useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], browserOffline: false, downServers: [], startedOffline: false }); - usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); }); diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts index da3099e06..d2ecd5bad 100644 --- a/lib/offline/__tests__/queue-sync.test.ts +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -22,7 +22,7 @@ beforeEach(() => { setStoreBackend(store); seen.calls = 0; seen.onCall = () => {}; - usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); }); diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index a5bbc6a0d..c7e4579e5 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -56,7 +56,7 @@ function fakeClient(server = 'https://mail.example.org', overrides: Record) { - usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } }); + usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } }); } function setOnline(online: boolean) { diff --git a/lib/offline/__tests__/scope.test.ts b/lib/offline/__tests__/scope.test.ts index 0bc04950c..0073e6b82 100644 --- a/lib/offline/__tests__/scope.test.ts +++ b/lib/offline/__tests__/scope.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { DEFAULT_SCOPE } from '@/lib/store/area'; import { @@ -27,7 +27,7 @@ describe('effectiveScope', () => { it('the user over the default', () => { const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER); expect(effective.enabled).toBe(true); - expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }); + expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB, orgMaxFileBytes: null }); expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false); expect(effectiveScope('a', { days: null }, NO_LIMITS, BROWSER).scope.days).toBeNull(); }); @@ -42,8 +42,11 @@ describe('effectiveScope', () => { it('a cap lowers the choice and the default, also "everything", and never raises', () => { const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 }; const capped = effectiveScope('a', { days: null, maxAttachmentBytes: 500 * MB }, policy, DESKTOP); - expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB }); + expect(capped.scope).toMatchObject({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB }); expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false }); + // Sent on its own: a pin obeys this one, not the user's size. + expect(capped.scope.orgMaxFileBytes).toBe(10 * MB); + expect(effectiveScope('a', { maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER).scope.orgMaxFileBytes).toBeNull(); expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30); const under = effectiveScope('a', { days: 30, maxAttachmentBytes: 5 * MB }, policy, BROWSER); expect(under.scope.days).toBe(30); @@ -151,10 +154,27 @@ describe('what the page offers', () => { }); describe('Lite', () => { - it('reads the caps from the same policy.json, and has no store to apply them to', async () => { + it('reads the caps from the same policy.json', async () => { const { applyLitePolicy } = await import('@/lib/lite-config'); const policy = applyLitePolicy({ organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 'lots' } } } as never); expect(policy.organisation?.devices).toMatchObject({ offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 0 }); + // A cap is lower than anything, also where nothing else is known. + expect(effectiveScope('a', { days: null }, policy.organisation!.devices, BROWSER).scope).toMatchObject({ days: 30, attachments: false }); + }); + + it('has no store to apply them to, whatever a caller asks', async () => { + vi.resetModules(); + vi.doMock('@/lib/lite', async (original) => ({ ...(await original()), IS_LITE: true })); + try { + const { enableOffline, offlineSupported } = await import('../offline-mode'); + const { useOfflineStore: lite } = await import('../state'); + expect(offlineSupported()).toBe(false); + await enableOffline('a'); + expect(lite.getState().enabled).toEqual([]); + } finally { + vi.doUnmock('@/lib/lite'); + vi.resetModules(); + } }); }); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts index 2a4b99dc2..c2013e11e 100644 --- a/lib/offline/__tests__/storage-health.test.ts +++ b/lib/offline/__tests__/storage-health.test.ts @@ -102,7 +102,7 @@ beforeEach(() => { enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {}, storeLost: [], storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false, }); - usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); setStoreBackend(null); setOfflineNotifier((notice) => notices.push(notice)); registerOfflineHooks(); @@ -198,12 +198,73 @@ describe('a store the browser cleared', () => { expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 }); }); + it('off and on again is no eviction', async () => { + await openAccountKey('acc'); + hadStore('acc'); + writeEnabled(['acc']); + await wipeAccount('acc'); + expect(localStorage.getItem('bulwark-store-opened')).toBeNull(); + FakeWorker.created = true; + await openAsOwner('acc'); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]); + }); + it('keeps only numbers about it outside the store', () => { writeHealth('acc', { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 }); expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 } }); }); }); +describe('the scope an account opens with', () => { + const setDays = (offlineMaxDays: number) => usePolicyStore.setState({ + policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, offlineMaxDays } } }, + } as never); + const openParams = (worker: FakeWorker) => worker.posted.find((m) => m.kind === 'open')?.params as { scope?: { days: number | null } } | undefined; + + function holdPolicy(): () => void { + let release = () => {}; + const fetchPolicy = vi.fn(() => new Promise((resolve) => { release = resolve; })); + usePolicyStore.setState({ fetchPolicy } as never); + return () => release(); + } + + afterEach(() => { usePolicyStore.setState({ fetchPolicy: realFetchPolicy } as never); }); + const realFetchPolicy = usePolicyStore.getState().fetchPolicy; + + it('none from a tab that just took over, and the cap once it has heard the policy', async () => { + const heard = holdPolicy(); + setDays(14); + const worker = await openAsOwner('acc'); + await vi.waitFor(() => expect(openParams(worker)).toBeTruthy()); + // This tab's policy may be old: the store keeps what it has. + expect(openParams(worker)?.scope).toBeUndefined(); + expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]); + + heard(); + await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1)); + expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14); + }); + + it('a stale cap the fetch corrects isn\'t what goes out', async () => { + const heard = holdPolicy(); + setDays(365); + await openAsOwner('acc'); + setDays(14); + heard(); + await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1)); + expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14); + }); + + it('none before the policy is known, at the real inputs', async () => { + usePolicyStore.setState({ source: 'none' } as never); + const worker = await openAsOwner('acc'); + await vi.waitFor(() => expect(openParams(worker)).toBeTruthy()); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]); + }); +}); + describe('a failed write', () => { it('says the device is full, at most once in a while, until a sync goes through', async () => { const worker = await openAsOwner('acc'); diff --git a/lib/offline/__tests__/sync-progress.test.ts b/lib/offline/__tests__/sync-progress.test.ts index 6bba589c9..419214736 100644 --- a/lib/offline/__tests__/sync-progress.test.ts +++ b/lib/offline/__tests__/sync-progress.test.ts @@ -32,6 +32,15 @@ describe('the time left', () => { expect(bodies.startedDone).toBe(10); }); + it('starts the sample over after a gap: time asleep isn\'t the rate', () => { + const start = nextProgress(undefined, event('bodies', 0, 10_000), 0); + const before = nextProgress(start, event('bodies', 100, 10_000), 10_000); + const after = nextProgress(before, event('bodies', 200, 10_000), 10_000 + 3_600_000); + expect(after.startedAt).toBe(10_000 + 3_600_000); + expect(after.startedDone).toBe(200); + expect(minutesLeft(after)).toBeNull(); + }); + it('has no time without a total, or when done', () => { const open = nextProgress(undefined, event('list', 0, null), 0); expect(minutesLeft(nextProgress(open, event('list', 500, null), 10_000))).toBeNull(); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 08237e835..6a8c77ac7 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -24,7 +24,7 @@ import { IS_LITE } from '@/lib/lite'; import { desktopJmapBase } from '@/lib/desktop/jmap-route'; -import { devicesForAccount, signedInAccountIds } from '@/lib/device-policy'; +import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; @@ -160,9 +160,14 @@ export function offlineAllowed(account: string | null | undefined): boolean { return offlineSupported() && !devicesForAccount(account ?? null).noOffline; } +/** A dropped account's data isn't judged by the groups of whoever is signed in now. */ +function policyOfKept(account: string) { + return isEvicted(account) ? devicesForNoOne() : devicesForAccount(account); +} + /** What this account keeps here: the organisation's policy over the user's choice over the platform's default. */ export function scopeForAccount(account: string): EffectiveScope { - return effectiveScope(account, userChoiceFor(account), devicesForAccount(account), platformDefault()); + return effectiveScope(account, userChoiceFor(account), policyOfKept(account), platformDefault()); } /** "Keep mail on this device" is on for this account here, and allowed. */ @@ -193,6 +198,9 @@ function onlineClientFor(account: string) { // ---- The store's way to this tab ---- +/** How long a tab that took over the store waits for the policy before going on as if it hadn't heard. */ +const OWNER_POLICY_WAIT_MS = 5_000; + /** A full disk stays full: once in a while is enough to say so. */ const STORAGE_NOTICE_MS = 30 * 60_000; let lastStorageNotice = 0; @@ -226,6 +234,13 @@ const storeHooks: StorePageHooks = { await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, + async ownerTaken() { + // This tab may have sat in the background: its policy can be old. + await Promise.race([ + usePolicyStore.getState().fetchPolicy(), + new Promise((resolve) => setTimeout(resolve, OWNER_POLICY_WAIT_MS)), + ]); + }, onStoreLost(account) { // Whoever is here wasn't signed in to a dropped account: nothing to tell them. if (isEvicted(account)) return; @@ -617,14 +632,23 @@ export async function refreshQueueState(): Promise { const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account)); const accounts = visibleAccounts(); const areas = await Promise.all(accounts.map(readAreaState)); - useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts], areaConflicts: areas.flatMap((area) => area.conflicts) }); + const changesBy = Object.fromEntries(accounts.map((account, index) => [account, (patchesBy[account] ?? 0) + areas[index].pending])); + useOfflineStore.setState((s) => ({ + outbox: [...unread, ...rows], + pendingChanges: patches, + conflicts, + localDrafts: [...unreadDrafts, ...drafts], + areaConflicts: areas.flatMap((area) => area.conflicts), + // An account that couldn't answer counts with what it last said. + changesBy: { ...changesBy, ...Object.fromEntries(failed.map((account) => [account, s.changesBy[account] ?? 0])) }, + })); if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); // What a browser clearing the store would lose. Counted here, and the // count is all that's kept outside the store. A store that couldn't be // read keeps its last count. accounts.forEach((account, index) => { if (failed.includes(account)) return; - const sends = rows.filter((row) => row.account === account).length; + const sends = rows.filter((row) => row.account === account).length + drafts.filter((row) => row.account === account).length; writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending }); }); } catch { @@ -680,10 +704,15 @@ export async function refreshUsage(): Promise { const accounts = visibleAccounts(); if (!offlineSupported() || accounts.length === 0) return; try { - const usage = await Promise.all(accounts.map((account) => getStore().area(account, 'store.usage'))); - useOfflineStore.setState({ - usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0), - usage: Object.fromEntries(accounts.map((account, index) => [account, usage[index]])), + const settled = await Promise.allSettled(accounts.map((account) => getStore().area(account, 'store.usage'))); + // One account's store being in trouble says nothing about the others'. + useOfflineStore.setState((s) => { + const usage = { ...s.usage }; + accounts.forEach((account, index) => { + const result = settled[index]; + if (result.status === 'fulfilled' && result.value) usage[account] = result.value; + }); + return { usage, usageBytes: accounts.reduce((sum, account) => sum + (usage[account]?.bytes ?? 0), 0) }; }); } catch { /* deleted meanwhile */ @@ -889,10 +918,21 @@ export async function clearAccount(account: string): Promise { /** The user changed what an account keeps. Written first, so a reload holds to it. */ export function chooseScope(account: string, patch: UserChoice): void { - writeChoice(account, patch); - pushScope(account); + // Over what's in force: someone from before the local store has a choice nobody wrote down. + writeChoice(account, { ...userChoiceFor(account), ...patch }); + // Arrowing through a list passes shorter windows on the way: those prune. + const waiting = scopeTimers.get(account); + if (waiting) clearTimeout(waiting); + scopeTimers.set(account, setTimeout(() => { + scopeTimers.delete(account); + pushScope(account); + }, SCOPE_SETTLE_MS)); } +/** How long a choice has to stay before the store acts on it. */ +const SCOPE_SETTLE_MS = 800; +const scopeTimers = new Map>(); + /** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */ function pushScope(account: string): void { if (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return; @@ -968,7 +1008,7 @@ const UNSENT_READ_MS = 2_000; */ export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> { const shown = useOfflineStore.getState(); - const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges }; + const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: scope === 'all' ? Object.values(shown.changesBy).reduce((sum, count) => sum + count, 0) : shown.changesBy[scope] ?? 0 }; const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account)); if (accounts.length === 0) return known; const pendingIn = (account: string, area: string) => getStore().area(account, `${area}.pending`).catch(() => 0); @@ -1038,7 +1078,8 @@ export async function wipeEverything(): Promise { for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); - forgetAllAccountState(); + // Turned off by hand and still signed in: that stays their word. + forgetAllAccountState(signedInAccountIds()); getStore().stop(); await purgeStoreFiles(); try { @@ -1056,8 +1097,9 @@ export async function wipeEverything(): Promise { /** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */ function scopeDecided(account: string): boolean { - const { loaded, source } = usePolicyStore.getState(); - return loaded && (source === 'server' || source === 'cache') && policyDecided(account); + const { loaded, source, partial } = usePolicyStore.getState(); + // The public part a page gets before it's signed in has no caps and no scopes in it. + return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account); } /** Whether the policy of an account is known well enough to decide on it. */ @@ -1065,7 +1107,13 @@ function policyDecided(account: string): boolean { // Dropped, with no groups to wait for: what applies to everyone reaches it. if (isEvicted(account)) return true; const auth = useAuthStore.getState(); - if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending; + if (account === auth.activeAccountId || !auth.activeAccountId) { + const { subject, subjectPending } = usePolicyStore.getState(); + if (subjectPending) return false; + // The active account's id is kept across a reload and its subject isn't: until + // it's there only Everyone's values show, and a group scope may say otherwise. + return !auth.activeAccountId || subject !== null || !(auth.isLoading || auth.client); + } // Another signed-in account: its groups have to be known first (a group // scope may forbid it); a signed-out one is wiped with its sign-out. if (!auth.getClientForAccount?.(account)) return false; @@ -1080,7 +1128,7 @@ export function enforcePolicy(): void { if (!policyDecided(account)) continue; // What the organisation says, not whether this window can open the store: // a blocked store says nothing against the data in it. - if (devicesForAccount(account).noOffline) { + if (policyOfKept(account).noOffline) { // Whoever is here now wasn't signed in to a dropped account. const someoneElses = isEvicted(account); clearHandoffs(); @@ -1189,7 +1237,11 @@ export function startOfflineMode(): () => void { useOfflineStore.setState({ health }); // Another tab cleared an account that stays on: if this tab owns the store, it opens the new one. for (const id of useOfflineStore.getState().enabled) { - if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) void getStore().ensureOpen(id).catch(() => {}); + if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) { + void getStore().ensureOpen(id).catch(() => {}); + void refreshQueueState(); + void refreshUsage(); + } } } if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; diff --git a/lib/offline/scope.ts b/lib/offline/scope.ts index 270030c32..dcdfd0581 100644 --- a/lib/offline/scope.ts +++ b/lib/offline/scope.ts @@ -104,6 +104,7 @@ export function effectiveScope( attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments), maxAttachmentBytes: cappedBytes(maxAttachment, policy.offlineMaxAttachmentMb), maxTotalBytes: cappedBytes(defaults.scope.maxTotalBytes, policy.offlineMaxTotalMb), + orgMaxFileBytes: policy.offlineMaxAttachmentMb > 0 ? policy.offlineMaxAttachmentMb * MB : null, }, locked: { offline: policy.noOffline, diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 18449790b..f85726a57 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -80,6 +80,8 @@ interface OfflineState { conflicts: ConflictEntry[]; /** The same for calendar events and contacts. */ areaConflicts: AreaConflict[]; + /** Changes waiting, per account: mail's, the calendar's and the contacts'. */ + changesBy: Record; /** Drafts that are only on this device: on their way up, or refused by the server. */ localDrafts: LocalDraftRow[]; /** A local draft the user asked to open, for the mail page to pick up. */ @@ -139,6 +141,7 @@ export const useOfflineStore = create()(() => ({ pendingChanges: 0, conflicts: [], areaConflicts: [], + changesBy: {}, localDrafts: [], draftToOpen: null, usageBytes: 0, @@ -185,10 +188,14 @@ export function readStoredHealth(): Record { return readRecord(OFFLINE_HEALTH_KEY); } -/** Nothing is kept for anyone any more. */ -export function forgetAllAccountState(): void { - useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] }); - writeRecord(OFFLINE_SCOPE_KEY, {}); +/** Nothing is kept for anyone any more. Those still signed in stay off if they turned it off. */ +export function forgetAllAccountState(signedIn: readonly string[] = []): void { + const off: Record = {}; + for (const [account, choice] of Object.entries(useOfflineStore.getState().choices)) { + if (choice.on === false && signedIn.includes(account)) off[account] = { on: false }; + } + useOfflineStore.setState({ choices: off, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [], changesBy: {} }); + writeRecord(OFFLINE_SCOPE_KEY, off); writeRecord(OFFLINE_HEALTH_KEY, {}); } diff --git a/lib/offline/sync-progress.ts b/lib/offline/sync-progress.ts index 01c1db2e5..945a0375d 100644 --- a/lib/offline/sync-progress.ts +++ b/lib/offline/sync-progress.ts @@ -22,6 +22,8 @@ export interface SyncProgress { const BODY_COST_PER_LIST_ITEM = 10; /** An estimate from less than this jumps about too much to show. */ const MIN_SAMPLE_MS = 5_000; +/** A pause this long (asleep, offline) says nothing about the rate: the sample starts again. */ +const SAMPLE_GAP_MS = 60_000; /** Too small to be worth a progress line: new mail arriving in a normal pass. */ const MIN_ITEMS_SHOWN = 50; @@ -30,7 +32,7 @@ export function nextProgress( event: { area: string; phase: string; done: number; total: number | null }, now: number, ): SyncProgress { - const samePhase = previous?.area === event.area && previous.phase === event.phase && event.done >= previous.done; + const samePhase = previous?.area === event.area && previous.phase === event.phase && event.done >= previous.done && now - previous.at < SAMPLE_GAP_MS; return { area: event.area, phase: event.phase, diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts index 41d158e12..e842a36b6 100644 --- a/lib/store/__tests__/files.test.ts +++ b/lib/store/__tests__/files.test.ts @@ -344,6 +344,32 @@ describe('pinned files', () => { expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull(); }); + it('holds a pin to the organisation\'s cap on one file, not to the user\'s own', async () => { + const server = new FakeFiles(); + const pinned = server.addFile('pinned', null, 60); + const rig = await synced(server); + await rig.engine.areaCall('files.pin', ref(pinned.id)); + await rig.engine.requestSync(); + await keptOffline(rig); + rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: null }); + await rig.engine.requestSync(); + expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull(); + + rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: 10 }); + await rig.engine.requestSync(); + expect(await rig.engine.areaCall('files.read', ref(pinned.id))).toBeNull(); + expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'tooLarge' }); + + // Not fetched back while the cap holds; fetched back once it's lifted. + server.downloads.length = 0; + rig.engine.setOnline(true); + await rig.engine.requestSync(); + expect(server.downloads).toEqual([]); + rig.engine.setScope({ ...DEFAULT_SCOPE, orgMaxFileBytes: null }); + await rig.engine.requestSync(); + expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull(); + }); + it('holds even pins to a lower total, newest out first, and doesn\'t fetch them back', async () => { const server = new FakeFiles(); const older = server.addFile('older', null, 60); diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index 11e47f920..14651cce7 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -447,6 +447,41 @@ describe('the page\'s store', () => { store.stop(); }); + it('sends the scope at every refresh once it has heard the policy, and none before', async () => { + let hear: () => void = () => {}; + configureStore({ + inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', scope: { days: 7, attachments: true, maxAttachmentBytes: 1, maxTotalBytes: 1 }, invertedKeywordSort: true }), + refreshAuth: async () => null, + onEvent: () => {}, + ownerTaken: () => new Promise((resolve) => { hear = resolve; }), + }); + const store = getStore(); + await store.ensureOpen('a@x'); + const worker = FakeWorker.all[0]; + const owner = new FakeChannel('bulwark-store-rpc'); + const sent: string[] = []; + owner.onmessage = (event) => { + const message = event.data as { kind: string; id: string; from: string; method: string }; + if (message.kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'worker' }); + if (message.kind !== 'rpc') return; + sent.push(message.method); + owner.postMessage({ kind: 'reply', id: message.id, to: message.from }); + }; + worker.becomeOwner(); + await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true)); + const open = worker.posted.find((m) => m.kind === 'open') as { params: { scope?: unknown } }; + expect(open.params.scope).toBeUndefined(); + store.refresh('a@x'); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(sent).toEqual([]); + + hear(); + await vi.waitFor(() => expect(sent).toEqual(['setScope'])); + store.refresh('a@x'); + await vi.waitFor(() => expect(sent).toEqual(['setScope', 'setScope'])); + store.stop(); + }); + it('stops waiting when the engine couldn\'t start in this browser', async () => { allowEngine(); const store = getStore(); diff --git a/lib/store/area.ts b/lib/store/area.ts index a442ae641..9bdea4dc2 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -19,6 +19,11 @@ export interface StoreScope { /** The largest attachment kept, and the total for all of them. */ maxAttachmentBytes: number; maxTotalBytes: number; + /** + * The organisation's cap on one file, when it set one. Unlike + * `maxAttachmentBytes`, which is also the user's own choice, a pin obeys it. + */ + orgMaxFileBytes?: number | null; } export const DEFAULT_SCOPE: StoreScope = { diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index 81c8d2e5b..5c39da0fa 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ import type { StoreEvent, StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; -import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, STORE_OPENED_KEY, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { @@ -26,6 +26,11 @@ export interface StorePageHooks { onEvent(event: StoreEvent): void; /** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */ keyLayers?(): readonly WrapLayer[]; + /** + * This tab just took over the store. Resolves when it has heard the + * organisation's policy since: until then it sends no scope, which may be old. + */ + ownerTaken?(): Promise; /** An account this device kept mail for has an empty store now: its key or its file went. */ onStoreLost?(account: string): void; } @@ -54,11 +59,7 @@ export interface StoreBackend { const OPEN_RETRIES = 25; const OPEN_RETRY_MS = 200; -/** - * localStorage: accounts that have had a store here. Holds nothing but the - * account's id, and it outlives what a browser may clear of the rest. - */ -export const STORE_OPENED_KEY = 'bulwark-store-opened'; +export { STORE_OPENED_KEY }; function readOpenedAccounts(): string[] { try { @@ -89,8 +90,8 @@ class PageStore implements StoreBackend { * can start the open again, and the second try finds the key the first one made. */ private readonly keyJustMade = new Set(); - /** What the owner's engine was last told to keep. */ - private readonly sentScope = new Map(); + /** False from taking over the store until this tab has heard the policy again. */ + private scopeCurrent = true; constructor(private readonly hooks: () => StorePageHooks | null) {} @@ -112,6 +113,15 @@ class PageStore implements StoreBackend { }, onOwner: () => { this.opening.clear(); + const hooks = this.hooks(); + if (hooks?.ownerTaken) { + this.scopeCurrent = false; + const heard = () => { + this.scopeCurrent = true; + for (const account of this.wanted.keys()) this.refresh(account); + }; + void hooks.ownerTaken().then(heard, heard); + } for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); }, onNeedAuth: (account) => { @@ -128,7 +138,7 @@ class PageStore implements StoreBackend { private reset(): void { this.client = null; - this.sentScope.clear(); + this.scopeCurrent = true; this.wanted.clear(); this.opening.clear(); this.broken = false; @@ -145,13 +155,12 @@ class PageStore implements StoreBackend { // The old file, if the key alone went, can never be read again. if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; - if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope)); const opened = await client.openAccount({ account, file: record.file, keyHex, session: inputs.session ?? this.wanted.get(account) ?? null, - scope: inputs.scope, + scope: this.scopeCurrent ? inputs.scope : undefined, invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); @@ -180,12 +189,8 @@ class PageStore implements StoreBackend { if (!inputs) return; if (inputs.session) client.setSession(account, inputs.session); client.setAuth(account, inputs.auth); - // Only a real change: setting it starts over what a full disk made the store give up on. - const scope = inputs.scope ? JSON.stringify(inputs.scope) : null; - if (inputs.scope && scope !== this.sentScope.get(account)) { - if (this.sentScope.has(account)) void client.setScope(account, inputs.scope).catch(() => {}); - this.sentScope.set(account, scope!); - } + // The engine drops one that's no change. + if (inputs.scope && this.scopeCurrent) void client.setScope(account, inputs.scope).catch(() => {}); if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort); } @@ -212,7 +217,6 @@ class PageStore implements StoreBackend { } setScope(account: string, scope: StoreScope): Promise { - this.sentScope.set(account, JSON.stringify(scope)); return this.whenOpen(account, (client) => client.setScope(account, scope)); } diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index 9723c6710..059beb916 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -7,6 +7,12 @@ * the record makes the file unreadable even if deleting it failed. */ +/** + * localStorage: accounts that have had a store here. Holds nothing but the + * account's id, and it outlives what a browser may clear of the rest. + */ +export const STORE_OPENED_KEY = 'bulwark-store-opened'; + const DB_NAME = 'bulwark-store'; const DB_VERSION = 1; const KEYS = 'keys'; diff --git a/lib/store/client/purge.ts b/lib/store/client/purge.ts index 10fb04dfb..5523c58e5 100644 --- a/lib/store/client/purge.ts +++ b/lib/store/client/purge.ts @@ -5,7 +5,7 @@ */ import { OWNER_LOCK, POOL_DIRECTORY, RPC_CHANNEL, type ChannelMessage } from '../worker/protocol'; -import { deleteKeyDatabase } from './keys'; +import { deleteKeyDatabase, STORE_OPENED_KEY } from './keys'; /** A worker just stopped may hold the files for a moment longer. */ const REMOVE_TRIES = 10; @@ -51,6 +51,12 @@ export async function deleteStorePool(): Promise { */ export function purgeStoreFiles(): Promise { deleteKeyDatabase(); + // Deleted on purpose: no account lost anything. The next open is a first one. + try { + localStorage.removeItem(STORE_OPENED_KEY); + } catch { + // Nothing kept, nothing to forget. + } try { const channel = new BroadcastChannel(RPC_CHANNEL); channel.postMessage({ kind: 'shutdown' } satisfies ChannelMessage); diff --git a/lib/store/files/keep.ts b/lib/store/files/keep.ts index 919d7cb97..8bc8ee655 100644 --- a/lib/store/files/keep.ts +++ b/lib/store/files/keep.ts @@ -4,8 +4,9 @@ * * What gets kept: * - A pinned file stays until it's unpinned or removed on the server. The cap - * doesn't push it out and the size limit for one file doesn't apply. The - * account's total limit (scope.maxTotalBytes, which a policy can lower) does. + * doesn't push it out and the user's own size limit doesn't apply. The + * organisation's limit on one file (scope.orgMaxFileBytes) and the account's + * total (scope.maxTotalBytes, which a policy can lower) do. * - A recently opened file is kept if it's within scope.maxAttachmentBytes, and * pushed out oldest first when the cap is reached. Its opened_at goes with * it, so it isn't fetched straight back. @@ -121,6 +122,12 @@ function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; return true; } +/** The organisation's limit on one file, which a pin obeys. The user's own doesn't bind a pin. */ +function overOrgLimit(ctx: Pick, size: number): boolean { + const cap = ctx.scope.orgMaxFileBytes; + return typeof cap === 'number' && size > cap; +} + function pinnedBytes(db: StorageAdapter): number { return keptBlobs(db).filter((blob) => blob.pinned).reduce((sum, blob) => sum + Number(blob.size), 0); } @@ -177,6 +184,10 @@ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { setError(db, jmap, blobId, 'tooLarge'); continue; @@ -200,11 +211,11 @@ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise { const now = wantedRow(db, jmap, blobId); if (!now) return; - if (now.pinned ? pinnedBytes(db) + data.length > ctx.scope.maxTotalBytes : data.length > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { + if (now.pinned ? overOrgLimit(ctx, data.length) || pinnedBytes(db) + data.length > ctx.scope.maxTotalBytes : data.length > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { // The size in the metadata was wrong or missing. With the real one // noted, the next pass sees this without downloading it again. db.run('UPDATE file_node SET size = ? WHERE jmap = ? AND blob_id = ?', [data.length, jmap, blobId]); - setError(db, jmap, blobId, now.pinned ? 'overLimit' : 'tooLarge'); + setError(db, jmap, blobId, !now.pinned || overOrgLimit(ctx, data.length) ? 'tooLarge' : 'overLimit'); return; } if (!makeRoom(ctx, data.length, { pinned: Boolean(now.pinned), openedAt: Number(now.opened_at ?? 0) })) { @@ -239,8 +250,13 @@ export function applyFilesScope(ctx: SyncContext, _change: ScopeChange): void { } const largest = Math.min(scope.maxAttachmentBytes, capBytes(ctx)); for (const blob of keptBlobs(db)) { - if (blob.pinned || Number(blob.size) <= largest) continue; - pushOut(db, blob); + if (blob.pinned ? !overOrgLimit(ctx, Number(blob.size)) : Number(blob.size) <= largest) continue; + if (blob.pinned) { + dropBlob(db, blob.jmap, blob.blob_id); + setError(db, blob.jmap, blob.blob_id, 'tooLarge'); + } else { + pushOut(db, blob); + } changed = true; } const before = keptBlobs(db).length; diff --git a/locales/ar/common.json b/locales/ar/common.json index 10fde651f..94f9c24b5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, "offlineStorage": { - "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة في هذا المتصفح. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", + "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", "window_label": "إلى أي مدة سابقة يُحتفظ بالبريد", "window_description": "يُزال البريد وأحداث التقويم الأقدم من هذا الجهاز. ما زال بإمكانك فتحها عندما يكون لديك اتصال.", "window_days": "{days, plural, zero {# يوم} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}", @@ -5792,7 +5792,7 @@ "clear_description": "يحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب، ثم ينزّله من جديد.", "clear_button": "مسح…", "clear_confirm_title": "هل تريد مسح هذا الحساب على هذا الجهاز؟", - "clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب ثم يُنزَّل من جديد. يبقى بريدك على الخادم.", + "clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز للحساب {account} ثم يُنزَّل من جديد. يبقى بريدك على الخادم.", "clear_confirm_unsent": "{count, plural, zero {لن تُفقد أي رسالة لم تُرسل.} one {ستُفقد رسالة واحدة لم تُرسل.} two {ستُفقد رسالتان لم تُرسلا.} few {ستُفقد # رسائل لم تُرسل.} many {ستُفقد # رسالة لم تُرسل.} other {ستُفقد # رسالة لم تُرسل.}}", "clear_confirm": "مسح", "persist_refused": "قد يحذف هذا المتصفح البريد المحفوظ عندما تقل المساحة. تثبيت التطبيق يمنع ذلك عادةً.", @@ -5816,7 +5816,13 @@ "kept_error_download": "تعذّر التنزيل", "keep_offline": "الاحتفاظ دون اتصال", "unpin": "إيقاف الاحتفاظ دون اتصال", - "unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال" + "unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال", + "warning_desktop": "يمكن لأي شخص يستخدم حساب المستخدم على هذا الحاسوب أن يقرأ هذا البريد. استخدم تشفير القرص.", + "off_confirm_text": "سيؤدي هذا إلى حذف كل ما يحتفظ به هذا الجهاز للحساب {account}. يبقى بريدك على الخادم.", + "unsent_changes": "{count, plural, zero {لن تُفقد أي تغييرات لم تصل إلى الخادم.} one {سيُفقد تغيير واحد لم يصل إلى الخادم.} two {سيُفقد تغييران لم يصلا إلى الخادم.} few {ستُفقد # تغييرات لم تصل إلى الخادم.} many {سيُفقد # تغييرًا لم يصل إلى الخادم.} other {سيُفقد # تغيير لم يصل إلى الخادم.}}", + "clear_needs_connection": "يحتاج هذا إلى اتصال، لأن كل شيء يُنزَّل من جديد.", + "usage_label": "ما يحتفظ به هذا الجهاز", + "pin_not_ready": "هذا الملف ليس في قائمة هذا الجهاز بعد. جرّب مرة أخرى بعد قليل." }, "offlineDrafts": { "title": "المسودات على هذا الجهاز", diff --git a/locales/ca/common.json b/locales/ca/common.json index 090d5ed0d..edc005181 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, "offlineStorage": { - "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest navegador. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", + "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", "window_label": "Fins a quan enrere desar", "window_description": "El correu i els esdeveniments més antics surten d'aquest dispositiu. Encara els podeu obrir quan tingueu connexió.", "window_days": "{days, plural, one {# dia} other {# dies}}", @@ -5792,7 +5792,7 @@ "clear_description": "Suprimeix tot el que aquest dispositiu desa d'aquest compte i ho torna a baixar.", "clear_button": "Buida…", "clear_confirm_title": "Voleu buidar aquest compte en aquest dispositiu?", - "clear_confirm_message": "Tot el que aquest dispositiu desa d'aquest compte se suprimeix i es torna a baixar. El vostre correu al servidor no es toca.", + "clear_confirm_message": "Tot el que aquest dispositiu desa de {account} se suprimeix i es torna a baixar. El vostre correu al servidor no es toca.", "clear_confirm_unsent": "{count, plural, one {Es perdrà # missatge sense enviar.} other {Es perdran # missatges sense enviar.}}", "clear_confirm": "Buida", "persist_refused": "Aquest navegador pot esborrar el correu desat quan falti espai. Instal·lar l'aplicació sol evitar-ho.", @@ -5816,7 +5816,13 @@ "kept_error_download": "No s'ha pogut baixar", "keep_offline": "Desa sense connexió", "unpin": "Deixa de desar sense connexió", - "unpin_label": "Deixa de desar {name} sense connexió" + "unpin_label": "Deixa de desar {name} sense connexió", + "warning_desktop": "Qualsevol persona que pugui fer servir el compte d'usuari d'aquest ordinador pot llegir aquest correu. Feu servir el xifratge de disc.", + "off_confirm_text": "Això suprimeix tot el que aquest dispositiu desa de {account}. El vostre correu al servidor no es toca.", + "unsent_changes": "{count, plural, one {Es perdrà # canvi que no ha arribat al servidor.} other {Es perdran # canvis que no han arribat al servidor.}}", + "clear_needs_connection": "Cal una connexió, perquè tot es torna a baixar.", + "usage_label": "Què desa aquest dispositiu", + "pin_not_ready": "Aquest fitxer encara no és a la llista d'aquest dispositiu. Torneu-ho a provar d'aquí a un moment." }, "offlineDrafts": { "title": "Esborranys en aquest dispositiu", diff --git a/locales/cs/common.json b/locales/cs/common.json index f5bf15a75..89b59af6e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, "offlineStorage": { - "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", + "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", "window_label": "Jak dlouho do minulosti uchovávat", "window_description": "Starší pošta a události z kalendáře toto zařízení opustí. Když budete online, můžete je stále otevřít.", "window_days": "{days, plural, one {# den} few {# dny} many {# dne} other {# dní}}", @@ -5792,7 +5792,7 @@ "clear_description": "Smaže vše, co toto zařízení pro tento účet uchovává, a stáhne to znovu.", "clear_button": "Vymazat…", "clear_confirm_title": "Vymazat tento účet na tomto zařízení?", - "clear_confirm_message": "Vše, co toto zařízení pro tento účet uchovává, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.", + "clear_confirm_message": "Vše, co toto zařízení uchovává pro účet {account}, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.", "clear_confirm_unsent": "{count, plural, one {# neodeslaná zpráva se ztratí.} few {# neodeslané zprávy se ztratí.} many {# neodeslané zprávy se ztratí.} other {# neodeslaných zpráv se ztratí.}}", "clear_confirm": "Vymazat", "persist_refused": "Tento prohlížeč může uchovanou poštu smazat, když dojde místo. Instalace aplikace tomu obvykle zabrání.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nepodařilo se stáhnout", "keep_offline": "Uchovat offline", "unpin": "Přestat uchovávat offline", - "unpin_label": "Přestat uchovávat {name} offline" + "unpin_label": "Přestat uchovávat {name} offline", + "warning_desktop": "Každý, kdo může používat uživatelský účet na tomto počítači, může tuto poštu číst. Použijte šifrování disku.", + "off_confirm_text": "Tím se smaže vše, co toto zařízení uchovává pro účet {account}. Vaše pošta na serveru zůstane.", + "unsent_changes": "{count, plural, one {# změna, která nedorazila na server, se ztratí.} few {# změny, které nedorazily na server, se ztratí.} many {# změny, která nedorazila na server, se ztratí.} other {# změn, které nedorazily na server, se ztratí.}}", + "clear_needs_connection": "Je potřeba připojení, protože se vše stáhne znovu.", + "usage_label": "Co toto zařízení uchovává", + "pin_not_ready": "Tento soubor ještě není v seznamu tohoto zařízení. Zkuste to za chvíli znovu." }, "offlineDrafts": { "title": "Koncepty v tomto zařízení", diff --git a/locales/da/common.json b/locales/da/common.json index c70eff586..14369fc2c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, "offlineStorage": { - "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", + "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", "window_label": "Hvor langt tilbage der beholdes", "window_description": "Ældre mail og kalenderbegivenheder forlader denne enhed. Du kan stadig åbne dem, når du har forbindelse.", "window_days": "{days, plural, one {# dag} other {# dage}}", @@ -5792,7 +5792,7 @@ "clear_description": "Sletter alt, denne enhed beholder for denne konto, og henter det derefter igen.", "clear_button": "Ryd…", "clear_confirm_title": "Ryd denne konto på denne enhed?", - "clear_confirm_message": "Alt, denne enhed beholder for denne konto, slettes og hentes igen. Din mail på serveren bliver liggende.", + "clear_confirm_message": "Alt, denne enhed beholder for {account}, slettes og hentes igen. Din mail på serveren bliver liggende.", "clear_confirm_unsent": "{count, plural, one {# usendt besked går tabt.} other {# usendte beskeder går tabt.}}", "clear_confirm": "Ryd", "persist_refused": "Denne browser kan slette den beholdte mail, når pladsen bliver knap. Det hjælper normalt at installere appen.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kunne ikke hentes", "keep_offline": "Behold offline", "unpin": "Behold ikke længere offline", - "unpin_label": "Behold ikke {name} offline længere" + "unpin_label": "Behold ikke {name} offline længere", + "warning_desktop": "Alle, der kan bruge denne computers brugerkonto, kan læse denne mail. Brug diskkryptering.", + "off_confirm_text": "Det sletter alt, denne enhed beholder for {account}. Din mail på serveren bliver liggende.", + "unsent_changes": "{count, plural, one {# ændring, der ikke er nået frem til serveren, går tabt.} other {# ændringer, der ikke er nået frem til serveren, går tabt.}}", + "clear_needs_connection": "Det kræver en forbindelse, fordi alt hentes igen.", + "usage_label": "Det her beholder denne enhed", + "pin_not_ready": "Denne fil er ikke på denne enheds liste endnu. Prøv igen om lidt." }, "offlineDrafts": { "title": "Kladder på denne enhed", diff --git a/locales/de/common.json b/locales/de/common.json index af3b0b033..04bd88d4f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, "offlineStorage": { - "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", + "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", "window_label": "Wie weit zurück behalten", "window_description": "Ältere E-Mails und Kalendertermine werden von diesem Gerät entfernt. Sie können sie weiterhin öffnen, solange Sie online sind.", "window_days": "{days, plural, one {# Tag} other {# Tage}}", @@ -5792,7 +5792,7 @@ "clear_description": "Löscht alles, was dieses Gerät für dieses Konto behält, und lädt es danach neu.", "clear_button": "Leeren…", "clear_confirm_title": "Dieses Konto auf diesem Gerät leeren?", - "clear_confirm_message": "Alles, was dieses Gerät für dieses Konto behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.", + "clear_confirm_message": "Alles, was dieses Gerät für {account} behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.", "clear_confirm_unsent": "{count, plural, one {# ungesendete Nachricht geht verloren.} other {# ungesendete Nachrichten gehen verloren.}}", "clear_confirm": "Leeren", "persist_refused": "Dieser Browser kann die behaltenen E-Mails löschen, wenn der Platz knapp wird. Die App zu installieren verhindert das meistens.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Konnte nicht geladen werden", "keep_offline": "Offline behalten", "unpin": "Nicht mehr offline behalten", - "unpin_label": "{name} nicht mehr offline behalten" + "unpin_label": "{name} nicht mehr offline behalten", + "warning_desktop": "Jeder, der dieses Benutzerkonto am Computer nutzen kann, kann diese E-Mails lesen. Nutzen Sie eine Festplattenverschlüsselung.", + "off_confirm_text": "Dadurch wird alles gelöscht, was dieses Gerät für {account} behält. Ihre E-Mails auf dem Server bleiben erhalten.", + "unsent_changes": "{count, plural, one {# Änderung, die den Server nicht erreicht hat, geht verloren.} other {# Änderungen, die den Server nicht erreicht haben, gehen verloren.}}", + "clear_needs_connection": "Dafür ist eine Verbindung nötig, weil alles neu geladen wird.", + "usage_label": "Was dieses Gerät behält", + "pin_not_ready": "Diese Datei steht noch nicht auf der Liste dieses Geräts. Versuchen Sie es gleich noch einmal." }, "offlineDrafts": { "title": "Entwürfe auf diesem Gerät", diff --git a/locales/en/common.json b/locales/en/common.json index 6f84023f7..3fbd37931 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "The mail server isn't answering. Try again in a moment." }, "offlineStorage": { - "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", + "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", "window_label": "How far back to keep", "window_description": "Older mail and calendar events leave this device. You can still open them when you have a connection.", "window_days": "{days, plural, one {# day} other {# days}}", @@ -5792,8 +5792,8 @@ "clear_description": "Deletes everything this device keeps for this account, then downloads it again.", "clear_button": "Clear…", "clear_confirm_title": "Clear this account on this device?", - "clear_confirm_message": "Everything this device keeps for this account is deleted and downloaded again. Your mail on the server stays.", - "clear_confirm_unsent": "{count, plural, one {# unsent message is lost.} other {# unsent messages are lost.}}", + "clear_confirm_message": "Everything this device keeps for {account} is deleted and downloaded again. Your mail on the server stays.", + "clear_confirm_unsent": "{count, plural, one {# message that was not sent is lost.} other {# messages that were not sent are lost.}}", "clear_confirm": "Clear", "persist_refused": "This browser may delete the kept mail when space runs low. Installing the app usually stops that.", "persist_retry": "Ask again", @@ -5816,7 +5816,13 @@ "kept_error_download": "Couldn't be downloaded", "keep_offline": "Keep offline", "unpin": "Stop keeping offline", - "unpin_label": "Stop keeping {name} offline" + "unpin_label": "Stop keeping {name} offline", + "warning_desktop": "Anyone who can use this computer account can read this mail. Use disk encryption.", + "off_confirm_text": "This deletes everything this device keeps for {account}. Your mail on the server stays.", + "unsent_changes": "{count, plural, one {# change that hasn't reached the server is lost.} other {# changes that haven't reached the server are lost.}}", + "clear_needs_connection": "This needs a connection, because everything is downloaded again.", + "usage_label": "What this device keeps", + "pin_not_ready": "This file isn't on this device's list yet. Try again in a moment." }, "offlineDrafts": { "title": "Drafts on this device", diff --git a/locales/es/common.json b/locales/es/common.json index c0c9ed0af..183a2e8f6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, "offlineStorage": { - "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", + "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", "window_label": "Cuánto tiempo atrás guardar", "window_description": "El correo y los eventos más antiguos salen de este dispositivo. Aún puedes abrirlos cuando tengas conexión.", "window_days": "{days, plural, one {# día} other {# días}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina todo lo que este dispositivo guarda de esta cuenta y lo vuelve a descargar.", "clear_button": "Vaciar…", "clear_confirm_title": "¿Vaciar esta cuenta en este dispositivo?", - "clear_confirm_message": "Todo lo que este dispositivo guarda de esta cuenta se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.", + "clear_confirm_message": "Todo lo que este dispositivo guarda de {account} se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.", "clear_confirm_unsent": "{count, plural, one {Se perderá # mensaje sin enviar.} other {Se perderán # mensajes sin enviar.}}", "clear_confirm": "Vaciar", "persist_refused": "Este navegador puede borrar el correo guardado cuando falte espacio. Instalar la aplicación suele evitarlo.", @@ -5816,7 +5816,13 @@ "kept_error_download": "No se pudo descargar", "keep_offline": "Guardar sin conexión", "unpin": "Dejar de guardar sin conexión", - "unpin_label": "Dejar de guardar {name} sin conexión" + "unpin_label": "Dejar de guardar {name} sin conexión", + "warning_desktop": "Cualquiera que pueda usar la cuenta de usuario de este ordenador puede leer este correo. Usa el cifrado de disco.", + "off_confirm_text": "Esto elimina todo lo que este dispositivo guarda de {account}. Tu correo en el servidor se mantiene.", + "unsent_changes": "{count, plural, one {Se perderá # cambio que no ha llegado al servidor.} other {Se perderán # cambios que no han llegado al servidor.}}", + "clear_needs_connection": "Hace falta conexión, porque todo se vuelve a descargar.", + "usage_label": "Lo que guarda este dispositivo", + "pin_not_ready": "Este archivo aún no está en la lista de este dispositivo. Inténtalo de nuevo en un momento." }, "offlineDrafts": { "title": "Borradores en este dispositivo", diff --git a/locales/et/common.json b/locales/et/common.json index 9f477d450..9ea087926 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, "offlineStorage": { - "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", + "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", "window_label": "Kui kaugele minevikku hoida", "window_description": "Vanemad kirjad ja kalendrisündmused kaovad sellest seadmest. Saad neid endiselt avada, kui oled võrgus.", "window_days": "{days, plural, one {# päev} other {# päeva}}", @@ -5792,7 +5792,7 @@ "clear_description": "Kustutab kõik, mida see seade selle konto jaoks hoiab, ja laadib selle uuesti alla.", "clear_button": "Tühjenda…", "clear_confirm_title": "Kas tühjendada see konto selles seadmes?", - "clear_confirm_message": "Kõik, mida see seade selle konto jaoks hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.", + "clear_confirm_message": "Kõik, mida see seade kontoga {account} seoses hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.", "clear_confirm_unsent": "{count, plural, one {# saatmata kiri läheb kaotsi.} other {# saatmata kirja lähevad kaotsi.}}", "clear_confirm": "Tühjenda", "persist_refused": "See brauser võib hoitud kirjad kustutada, kui ruumi napib. Rakenduse paigaldamine hoiab selle tavaliselt ära.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Allalaadimine ebaõnnestus", "keep_offline": "Hoia võrguta", "unpin": "Ära hoia enam võrguta", - "unpin_label": "Ära hoia faili {name} enam võrguta" + "unpin_label": "Ära hoia faili {name} enam võrguta", + "warning_desktop": "Igaüks, kes saab selle arvuti kasutajakontot kasutada, saab neid kirju lugeda. Kasuta kettakrüptimist.", + "off_confirm_text": "See kustutab kõik, mida see seade kontoga {account} seoses hoiab. Sinu kirjad serveris jäävad alles.", + "unsent_changes": "{count, plural, one {# muudatus, mis ei jõudnud serverisse, läheb kaotsi.} other {# muudatust, mis ei jõudnud serverisse, lähevad kaotsi.}}", + "clear_needs_connection": "See vajab ühendust, sest kõik laaditakse uuesti alla.", + "usage_label": "Mida see seade hoiab", + "pin_not_ready": "Seda faili ei ole selle seadme loendis veel. Proovi hetke pärast uuesti." }, "offlineDrafts": { "title": "Mustandid selles seadmes", diff --git a/locales/fa/common.json b/locales/fa/common.json index cd75ac0f5..126945b74 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, "offlineStorage": { - "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این مرورگر نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", + "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", "window_label": "تا چه مدت قبل نگه داشته شود", "window_description": "ایمیل‌ها و رویدادهای تقویم قدیمی‌تر از این دستگاه حذف می‌شوند. وقتی اتصال دارید همچنان می‌توانید آن‌ها را باز کنید.", "window_days": "{days, plural, one {# روز} other {# روز}}", @@ -5792,7 +5792,7 @@ "clear_description": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف می‌کند و دوباره دانلود می‌کند.", "clear_button": "پاک‌کردن…", "clear_confirm_title": "این حساب روی این دستگاه پاک شود؟", - "clear_confirm_message": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.", + "clear_confirm_message": "هر چه این دستگاه برای {account} نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.", "clear_confirm_unsent": "{count, plural, one {# پیام ارسال‌نشده از بین می‌رود.} other {# پیام ارسال‌نشده از بین می‌روند.}}", "clear_confirm": "پاک‌کردن", "persist_refused": "وقتی فضا کم شود، این مرورگر ممکن است ایمیل‌های نگه‌داشته‌شده را حذف کند. نصب برنامه معمولاً جلوی این را می‌گیرد.", @@ -5816,7 +5816,13 @@ "kept_error_download": "دانلود نشد", "keep_offline": "نگه داشتن آفلاین", "unpin": "دیگر آفلاین نگه داشته نشود", - "unpin_label": "{name} دیگر آفلاین نگه داشته نشود" + "unpin_label": "{name} دیگر آفلاین نگه داشته نشود", + "warning_desktop": "هر کسی که بتواند از حساب کاربری این رایانه استفاده کند، می‌تواند این ایمیل‌ها را بخواند. از رمزگذاری دیسک استفاده کنید.", + "off_confirm_text": "این کار هر چه این دستگاه برای {account} نگه می‌دارد حذف می‌کند. ایمیل‌های شما روی سرور می‌مانند.", + "unsent_changes": "{count, plural, one {# تغییری که به سرور نرسیده از بین می‌رود.} other {# تغییری که به سرور نرسیده‌اند از بین می‌روند.}}", + "clear_needs_connection": "این کار به اتصال نیاز دارد، چون همه چیز دوباره دانلود می‌شود.", + "usage_label": "آنچه این دستگاه نگه می‌دارد", + "pin_not_ready": "این فایل هنوز در فهرست این دستگاه نیست. کمی بعد دوباره امتحان کنید." }, "offlineDrafts": { "title": "پیش‌نویس‌های این دستگاه", diff --git a/locales/fr/common.json b/locales/fr/common.json index b7e97abce..62c584256 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, "offlineStorage": { - "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", + "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", "window_label": "Durée de conservation", "window_description": "Les e-mails et événements plus anciens quittent cet appareil. Vous pouvez toujours les ouvrir quand vous êtes connecté.", "window_days": "{days, plural, one {# jour} other {# jours}}", @@ -5792,7 +5792,7 @@ "clear_description": "Supprime tout ce que cet appareil garde pour ce compte, puis le télécharge de nouveau.", "clear_button": "Vider…", "clear_confirm_title": "Vider ce compte sur cet appareil ?", - "clear_confirm_message": "Tout ce que cet appareil garde pour ce compte est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.", + "clear_confirm_message": "Tout ce que cet appareil garde pour {account} est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.", "clear_confirm_unsent": "{count, plural, one {# message non envoyé sera perdu.} other {# messages non envoyés seront perdus.}}", "clear_confirm": "Vider", "persist_refused": "Ce navigateur peut supprimer les e-mails gardés quand la place manque. Installer l'application l'évite en général.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Téléchargement impossible", "keep_offline": "Garder hors ligne", "unpin": "Ne plus garder hors ligne", - "unpin_label": "Ne plus garder {name} hors ligne" + "unpin_label": "Ne plus garder {name} hors ligne", + "warning_desktop": "Toute personne qui peut utiliser le compte utilisateur de cet ordinateur peut lire ces e-mails. Utilisez le chiffrement du disque.", + "off_confirm_text": "Cela supprime tout ce que cet appareil garde pour {account}. Vos e-mails sur le serveur restent en place.", + "unsent_changes": "{count, plural, one {# modification qui n'a pas atteint le serveur sera perdue.} other {# modifications qui n'ont pas atteint le serveur seront perdues.}}", + "clear_needs_connection": "Une connexion est nécessaire, car tout est téléchargé de nouveau.", + "usage_label": "Ce que cet appareil garde", + "pin_not_ready": "Ce fichier n'est pas encore dans la liste de cet appareil. Réessayez dans un instant." }, "offlineDrafts": { "title": "Brouillons sur cet appareil", diff --git a/locales/he/common.json b/locales/he/common.json index d64d300fa..ddc84c647 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5765,7 +5765,7 @@ "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, "offlineStorage": { - "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", + "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", "window_label": "כמה זמן אחורה לשמור", "window_description": "דואר ואירועי יומן ישנים יותר יוסרו מהמכשיר הזה. אפשר עדיין לפתוח אותם כשיש חיבור.", "window_days": "{days, plural, one {יום #} two {יומיים} other {# ימים}}", @@ -5793,7 +5793,7 @@ "clear_description": "מוחק את כל מה שהמכשיר הזה שומר עבור החשבון הזה, ואז מוריד אותו מחדש.", "clear_button": "ניקוי…", "clear_confirm_title": "לנקות את החשבון הזה במכשיר הזה?", - "clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור החשבון הזה יימחק ויורד מחדש. הדואר שלך בשרת נשאר.", + "clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור {account} יימחק ויורד מחדש. הדואר שלך בשרת נשאר.", "clear_confirm_unsent": "{count, plural, one {הודעה אחת שלא נשלחה תאבד.} two {# הודעות שלא נשלחו יאבדו.} other {# הודעות שלא נשלחו יאבדו.}}", "clear_confirm": "ניקוי", "persist_refused": "הדפדפן הזה עלול למחוק את הדואר השמור כשנגמר המקום. התקנת האפליקציה בדרך כלל מונעת זאת.", @@ -5817,7 +5817,13 @@ "kept_error_download": "לא ניתן היה להוריד", "keep_offline": "שמירה במצב לא מקוון", "unpin": "הפסקת שמירה במצב לא מקוון", - "unpin_label": "הפסקת שמירה של {name} במצב לא מקוון" + "unpin_label": "הפסקת שמירה של {name} במצב לא מקוון", + "warning_desktop": "כל מי שיכול להשתמש בחשבון המשתמש במחשב הזה יכול לקרוא את הדואר הזה. השתמש בהצפנת דיסק.", + "off_confirm_text": "פעולה זו מוחקת את כל מה שהמכשיר הזה שומר עבור {account}. הדואר שלך בשרת נשאר.", + "unsent_changes": "{count, plural, one {שינוי אחד שלא הגיע לשרת יאבד.} two {# שינויים שלא הגיעו לשרת יאבדו.} other {# שינויים שלא הגיעו לשרת יאבדו.}}", + "clear_needs_connection": "נדרש חיבור, כי הכול יורד מחדש.", + "usage_label": "מה שהמכשיר הזה שומר", + "pin_not_ready": "הקובץ הזה עוד לא ברשימה של המכשיר הזה. נסה שוב בעוד רגע." }, "offlineDrafts": { "title": "טיוטות במכשיר זה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 5d4a9dd70..f44e5c82c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, "offlineStorage": { - "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", + "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", "window_label": "Mennyi időre visszamenőleg maradjon meg", "window_description": "A régebbi levelek és naptáresemények lekerülnek erről az eszközről. Online állapotban továbbra is megnyithatja őket.", "window_days": "{days, plural, one {# nap} other {# nap}}", @@ -5792,7 +5792,7 @@ "clear_description": "Törli mindazt, amit ez az eszköz ehhez a fiókhoz megtart, majd újra letölti.", "clear_button": "Kiürítés…", "clear_confirm_title": "Kiüríti a fiókot ezen az eszközön?", - "clear_confirm_message": "Minden, amit ez az eszköz ehhez a fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.", + "clear_confirm_message": "Minden, amit ez az eszköz a(z) {account} fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.", "clear_confirm_unsent": "{count, plural, one {# el nem küldött levél elvész.} other {# el nem küldött levél elvész.}}", "clear_confirm": "Kiürítés", "persist_refused": "Ez a böngésző törölheti a megtartott leveleket, ha fogy a hely. Az alkalmazás telepítése ezt általában megakadályozza.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nem sikerült letölteni", "keep_offline": "Megtartás offline", "unpin": "Megtartás offline megszüntetése", - "unpin_label": "{name} offline megtartásának megszüntetése" + "unpin_label": "{name} offline megtartásának megszüntetése", + "warning_desktop": "Aki használhatja ennek a számítógépnek a felhasználói fiókját, elolvashatja ezeket a leveleket. Használjon lemeztitkosítást.", + "off_confirm_text": "Ez törli mindazt, amit ez az eszköz a(z) {account} fiókhoz megtart. A szerveren lévő levelei megmaradnak.", + "unsent_changes": "{count, plural, one {# módosítás, amely nem jutott el a szerverre, elvész.} other {# módosítás, amely nem jutott el a szerverre, elvész.}}", + "clear_needs_connection": "Ehhez kapcsolat kell, mert minden újra letöltődik.", + "usage_label": "Mit tart meg ez az eszköz", + "pin_not_ready": "Ez a fájl még nincs rajta az eszköz listáján. Próbálja újra egy pillanat múlva." }, "offlineDrafts": { "title": "Piszkozatok ezen az eszközön", diff --git a/locales/it/common.json b/locales/it/common.json index 58ba03188..ead7132ec 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, "offlineStorage": { - "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", + "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", "window_label": "Da quanto tempo conservare", "window_description": "La posta e gli eventi più vecchi lasciano questo dispositivo. Puoi comunque aprirli quando sei online.", "window_days": "{days, plural, one {# giorno} other {# giorni}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina tutto ciò che questo dispositivo conserva per questo account, poi lo scarica di nuovo.", "clear_button": "Svuota…", "clear_confirm_title": "Svuotare questo account su questo dispositivo?", - "clear_confirm_message": "Tutto ciò che questo dispositivo conserva per questo account viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.", + "clear_confirm_message": "Tutto ciò che questo dispositivo conserva per {account} viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.", "clear_confirm_unsent": "{count, plural, one {# messaggio non inviato andrà perso.} other {# messaggi non inviati andranno persi.}}", "clear_confirm": "Svuota", "persist_refused": "Questo browser può eliminare la posta conservata quando lo spazio scarseggia. Installare l'app di solito lo evita.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Impossibile scaricare", "keep_offline": "Conserva offline", "unpin": "Non conservare più offline", - "unpin_label": "Non conservare più {name} offline" + "unpin_label": "Non conservare più {name} offline", + "warning_desktop": "Chiunque possa usare l'account utente di questo computer può leggere questa posta. Usa la cifratura del disco.", + "off_confirm_text": "Questo elimina tutto ciò che questo dispositivo conserva per {account}. La tua posta sul server resta dov'è.", + "unsent_changes": "{count, plural, one {# modifica che non ha raggiunto il server andrà persa.} other {# modifiche che non hanno raggiunto il server andranno perse.}}", + "clear_needs_connection": "Serve una connessione, perché viene scaricato di nuovo tutto.", + "usage_label": "Cosa conserva questo dispositivo", + "pin_not_ready": "Questo file non è ancora nell'elenco di questo dispositivo. Riprova tra un momento." }, "offlineDrafts": { "title": "Bozze su questo dispositivo", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3532ea448..082f5243a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, "offlineStorage": { - "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのブラウザーに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", + "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", "window_label": "保存する期間", "window_description": "これより古いメールとカレンダーの予定はこのデバイスから削除されます。オンラインのときは引き続き開けます。", "window_days": "{days, plural, other {#日}}", @@ -5792,7 +5792,7 @@ "clear_description": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。", "clear_button": "消去…", "clear_confirm_title": "このデバイスでこのアカウントを消去しますか?", - "clear_confirm_message": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。", + "clear_confirm_message": "このデバイスに保存されている{account}のデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。", "clear_confirm_unsent": "{count, plural, other {未送信のメッセージ#件が失われます。}}", "clear_confirm": "消去", "persist_refused": "空き容量が少なくなると、このブラウザーは保存したメールを削除することがあります。アプリをインストールすると、たいていは防げます。", @@ -5816,7 +5816,13 @@ "kept_error_download": "ダウンロードできませんでした", "keep_offline": "オフライン用に保存", "unpin": "オフライン保存をやめる", - "unpin_label": "{name} のオフライン保存をやめる" + "unpin_label": "{name} のオフライン保存をやめる", + "warning_desktop": "このパソコンのユーザーアカウントを使える人は、このメールを読めます。ディスクを暗号化してください。", + "off_confirm_text": "このデバイスに保存されている{account}のデータをすべて削除します。サーバー上のメールはそのまま残ります。", + "unsent_changes": "{count, plural, other {サーバーに届いていない変更#件が失われます。}}", + "clear_needs_connection": "すべてをあらためてダウンロードするため、接続が必要です。", + "usage_label": "このデバイスに保存しているもの", + "pin_not_ready": "このファイルはまだこのデバイスのリストにありません。しばらくしてからもう一度お試しください。" }, "offlineDrafts": { "title": "このデバイスの下書き", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4b48fc595..46fba2617 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, "offlineStorage": { - "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", + "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", "window_label": "보관할 기간", "window_description": "이보다 오래된 메일과 캘린더 일정은 이 기기에서 삭제됩니다. 온라인일 때는 계속 열어볼 수 있습니다.", "window_days": "{days, plural, other {#일}}", @@ -5792,7 +5792,7 @@ "clear_description": "이 기기에 보관된 이 계정의 데이터를 모두 삭제한 뒤 다시 다운로드합니다.", "clear_button": "비우기…", "clear_confirm_title": "이 기기에서 이 계정을 비울까요?", - "clear_confirm_message": "이 기기에 보관된 이 계정의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.", + "clear_confirm_message": "이 기기에 보관된 {account}의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.", "clear_confirm_unsent": "{count, plural, other {보내지 않은 메시지 #개가 사라집니다.}}", "clear_confirm": "비우기", "persist_refused": "저장 공간이 부족해지면 이 브라우저가 보관한 메일을 삭제할 수 있습니다. 앱을 설치하면 대개 막을 수 있습니다.", @@ -5816,7 +5816,13 @@ "kept_error_download": "다운로드하지 못함", "keep_offline": "오프라인용으로 보관", "unpin": "오프라인 보관 중지", - "unpin_label": "{name} 오프라인 보관 중지" + "unpin_label": "{name} 오프라인 보관 중지", + "warning_desktop": "이 컴퓨터의 사용자 계정을 쓸 수 있는 사람은 누구나 이 메일을 읽을 수 있습니다. 디스크 암호화를 사용하세요.", + "off_confirm_text": "이 기기에 보관된 {account}의 데이터가 모두 삭제됩니다. 서버에 있는 메일은 그대로 남습니다.", + "unsent_changes": "{count, plural, other {서버에 도달하지 못한 변경 사항 #개가 사라집니다.}}", + "clear_needs_connection": "모든 것을 다시 다운로드하므로 연결이 필요합니다.", + "usage_label": "이 기기에 보관된 항목", + "pin_not_ready": "이 파일은 아직 이 기기의 목록에 없습니다. 잠시 후 다시 시도하세요." }, "offlineDrafts": { "title": "이 기기의 임시 보관함", diff --git a/locales/lv/common.json b/locales/lv/common.json index d9aa9961a..72ac39998 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, "offlineStorage": { - "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", + "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", "window_label": "Cik tālu atpakaļ glabāt", "window_description": "Vecākās vēstules un kalendāra notikumi pazūd no šīs ierīces. Tās joprojām varat atvērt, kad ir savienojums.", "window_days": "{days, plural, zero {# dienu} one {# diena} other {# dienas}}", @@ -5792,7 +5792,7 @@ "clear_description": "Izdzēš visu, ko šī ierīce glabā šim kontam, un lejupielādē to no jauna.", "clear_button": "Notīrīt…", "clear_confirm_title": "Vai notīrīt šo kontu šajā ierīcē?", - "clear_confirm_message": "Viss, ko šī ierīce glabā šim kontam, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.", + "clear_confirm_message": "Viss, ko šī ierīce glabā kontam {account}, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.", "clear_confirm_unsent": "{count, plural, zero {# neizsūtītu vēstuļu tiks zaudēts.} one {Tiks zaudēta # neizsūtīta vēstule.} other {Tiks zaudētas # neizsūtītas vēstules.}}", "clear_confirm": "Notīrīt", "persist_refused": "Šis pārlūks var izdzēst glabāto e-pastu, kad pietrūkst vietas. Lietotnes instalēšana to parasti novērš.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nevarēja lejupielādēt", "keep_offline": "Glabāt bezsaistē", "unpin": "Pārtraukt glabāt bezsaistē", - "unpin_label": "Pārtraukt glabāt {name} bezsaistē" + "unpin_label": "Pārtraukt glabāt {name} bezsaistē", + "warning_desktop": "Ikviens, kas var izmantot šī datora lietotāja kontu, var lasīt šo e-pastu. Izmantojiet diska šifrēšanu.", + "off_confirm_text": "Tas izdzēš visu, ko šī ierīce glabā kontam {account}. Jūsu e-pasts serverī paliek.", + "unsent_changes": "{count, plural, zero {# izmaiņu, kas nesasniedza serveri, tiks zaudēts.} one {Tiks zaudēta # izmaiņa, kas nesasniedza serveri.} other {Tiks zaudētas # izmaiņas, kas nesasniedza serveri.}}", + "clear_needs_connection": "Nepieciešams savienojums, jo viss tiek lejupielādēts no jauna.", + "usage_label": "Ko glabā šī ierīce", + "pin_not_ready": "Šī faila vēl nav šīs ierīces sarakstā. Mēģiniet vēlreiz pēc brīža." }, "offlineDrafts": { "title": "Melnraksti šajā ierīcē", diff --git a/locales/mn/common.json b/locales/mn/common.json index b0e0847f8..ebb31a650 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, "offlineStorage": { - "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", + "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", "window_label": "Хэр удаан хугацаагаар хадгалах вэ", "window_description": "Илүү хуучин имэйл болон хуанлийн үйл явдлууд энэ төхөөрөмжөөс устна. Холболттой үед та тэдгээрийг нээж болно.", "window_days": "{days, plural, one {# өдөр} other {# өдөр}}", @@ -5792,7 +5792,7 @@ "clear_description": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхнийг устгаад дахин татна.", "clear_button": "Цэвэрлэх…", "clear_confirm_title": "Энэ бүртгэлийг энэ төхөөрөмж дээр цэвэрлэх үү?", - "clear_confirm_message": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.", + "clear_confirm_message": "Энэ төхөөрөмж {account}-д зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.", "clear_confirm_unsent": "{count, plural, one {# илгээгээгүй мессеж алдагдана.} other {# илгээгээгүй мессеж алдагдана.}}", "clear_confirm": "Цэвэрлэх", "persist_refused": "Зай дутвал энэ хөтөч хадгалсан имэйлийг устгаж болно. Аппыг суулгавал ихэвчлэн ийм зүйл болдоггүй.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Татаж чадсангүй", "keep_offline": "Офлайн хадгалах", "unpin": "Офлайн хадгалахаа болих", - "unpin_label": "{name}-г офлайн хадгалахаа болих" + "unpin_label": "{name}-г офлайн хадгалахаа болих", + "warning_desktop": "Энэ компьютерийн хэрэглэгчийн бүртгэлийг ашиглаж чадах хэн ч энэ имэйлийг уншиж чадна. Дискний шифрлэлт ашиглаарай.", + "off_confirm_text": "Энэ нь төхөөрөмж {account}-д зориулж хадгалсан бүхнийг устгана. Серверийн имэйл тань хэвээр үлдэнэ.", + "unsent_changes": "{count, plural, one {Серверт хүрээгүй # өөрчлөлт алдагдана.} other {Серверт хүрээгүй # өөрчлөлт алдагдана.}}", + "clear_needs_connection": "Бүгдийг дахин татах тул холболт хэрэгтэй.", + "usage_label": "Энэ төхөөрөмж юу хадгалдаг вэ", + "pin_not_ready": "Энэ файл төхөөрөмжийн жагсаалтад хараахан ороогүй байна. Түр хүлээгээд дахин оролдоно уу." }, "offlineDrafts": { "title": "Энэ төхөөрөмж дээрх ноорог", diff --git a/locales/nb/common.json b/locales/nb/common.json index 2c62bd288..c41bee853 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, "offlineStorage": { - "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", + "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", "window_label": "Hvor langt tilbake som beholdes", "window_description": "Eldre e-post og kalenderhendelser forlater denne enheten. Du kan fortsatt åpne dem når du er tilkoblet.", "window_days": "{days, plural, one {# dag} other {# dager}}", @@ -5792,7 +5792,7 @@ "clear_description": "Sletter alt denne enheten beholder for denne kontoen, og laster det ned på nytt.", "clear_button": "Tøm…", "clear_confirm_title": "Tømme denne kontoen på denne enheten?", - "clear_confirm_message": "Alt denne enheten beholder for denne kontoen, slettes og lastes ned på nytt. E-posten din på serveren blir værende.", + "clear_confirm_message": "Alt denne enheten beholder for {account}, slettes og lastes ned på nytt. E-posten din på serveren blir værende.", "clear_confirm_unsent": "{count, plural, one {# usendt melding går tapt.} other {# usendte meldinger går tapt.}}", "clear_confirm": "Tøm", "persist_refused": "Denne nettleseren kan slette den beholdte e-posten når plassen blir knapp. Å installere appen hjelper vanligvis.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kunne ikke lastes ned", "keep_offline": "Behold frakoblet", "unpin": "Slutt å beholde frakoblet", - "unpin_label": "Slutt å beholde {name} frakoblet" + "unpin_label": "Slutt å beholde {name} frakoblet", + "warning_desktop": "Alle som kan bruke brukerkontoen på denne datamaskinen, kan lese denne e-posten. Bruk diskkryptering.", + "off_confirm_text": "Dette sletter alt denne enheten beholder for {account}. E-posten din på serveren blir værende.", + "unsent_changes": "{count, plural, one {# endring som ikke har nådd serveren, går tapt.} other {# endringer som ikke har nådd serveren, går tapt.}}", + "clear_needs_connection": "Dette krever en tilkobling, fordi alt lastes ned på nytt.", + "usage_label": "Dette beholder enheten", + "pin_not_ready": "Denne filen er ikke på listen til denne enheten ennå. Prøv igjen om litt." }, "offlineDrafts": { "title": "Utkast på denne enheten", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7cb5b803e..c60206d7a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, "offlineStorage": { - "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", + "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", "window_label": "Hoe ver terug bewaren", "window_description": "Oudere e-mail en agenda-items verdwijnen van dit apparaat. Je kunt ze nog openen als je online bent.", "window_days": "{days, plural, one {# dag} other {# dagen}}", @@ -5792,7 +5792,7 @@ "clear_description": "Verwijdert alles wat dit apparaat voor dit account bewaart en downloadt het daarna opnieuw.", "clear_button": "Wissen…", "clear_confirm_title": "Dit account op dit apparaat wissen?", - "clear_confirm_message": "Alles wat dit apparaat voor dit account bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.", + "clear_confirm_message": "Alles wat dit apparaat voor {account} bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.", "clear_confirm_unsent": "{count, plural, one {# niet-verzonden bericht gaat verloren.} other {# niet-verzonden berichten gaan verloren.}}", "clear_confirm": "Wissen", "persist_refused": "Deze browser kan de bewaarde e-mail verwijderen als de ruimte opraakt. De app installeren voorkomt dat meestal.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kon niet worden gedownload", "keep_offline": "Offline bewaren", "unpin": "Niet meer offline bewaren", - "unpin_label": "{name} niet meer offline bewaren" + "unpin_label": "{name} niet meer offline bewaren", + "warning_desktop": "Iedereen die dit gebruikersaccount op deze computer kan gebruiken, kan deze e-mail lezen. Gebruik schijfversleuteling.", + "off_confirm_text": "Hiermee wordt alles verwijderd wat dit apparaat voor {account} bewaart. Je e-mail op de server blijft staan.", + "unsent_changes": "{count, plural, one {# wijziging die de server niet heeft bereikt, gaat verloren.} other {# wijzigingen die de server niet hebben bereikt, gaan verloren.}}", + "clear_needs_connection": "Hiervoor is een verbinding nodig, omdat alles opnieuw wordt gedownload.", + "usage_label": "Wat dit apparaat bewaart", + "pin_not_ready": "Dit bestand staat nog niet op de lijst van dit apparaat. Probeer het zo meteen opnieuw." }, "offlineDrafts": { "title": "Concepten op dit apparaat", diff --git a/locales/pl/common.json b/locales/pl/common.json index a9a82b95c..b5f387c30 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, "offlineStorage": { - "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", + "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", "window_label": "Jak daleko wstecz przechowywać", "window_description": "Starsza poczta i wydarzenia z kalendarza znikają z tego urządzenia. Nadal możesz je otworzyć, gdy masz połączenie.", "window_days": "{days, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}}", @@ -5792,7 +5792,7 @@ "clear_description": "Usuwa wszystko, co to urządzenie przechowuje dla tego konta, a potem pobiera to ponownie.", "clear_button": "Wyczyść…", "clear_confirm_title": "Wyczyścić to konto na tym urządzeniu?", - "clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla tego konta, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.", + "clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla konta {account}, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.", "clear_confirm_unsent": "{count, plural, one {# niewysłana wiadomość zostanie utracona.} few {# niewysłane wiadomości zostaną utracone.} many {# niewysłanych wiadomości zostanie utraconych.} other {# niewysłanej wiadomości zostanie utraconej.}}", "clear_confirm": "Wyczyść", "persist_refused": "Ta przeglądarka może usunąć przechowywaną pocztę, gdy zabraknie miejsca. Zainstalowanie aplikacji zwykle temu zapobiega.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nie udało się pobrać", "keep_offline": "Przechowuj offline", "unpin": "Nie przechowuj już offline", - "unpin_label": "Nie przechowuj już {name} offline" + "unpin_label": "Nie przechowuj już {name} offline", + "warning_desktop": "Każdy, kto może korzystać z konta użytkownika na tym komputerze, może czytać tę pocztę. Użyj szyfrowania dysku.", + "off_confirm_text": "To usuwa wszystko, co to urządzenie przechowuje dla konta {account}. Twoja poczta na serwerze zostaje.", + "unsent_changes": "{count, plural, one {# zmiana, która nie dotarła do serwera, zostanie utracona.} few {# zmiany, które nie dotarły do serwera, zostaną utracone.} many {# zmian, które nie dotarły do serwera, zostanie utraconych.} other {# zmiany, która nie dotarła do serwera, zostanie utraconej.}}", + "clear_needs_connection": "Potrzebne jest połączenie, bo wszystko zostanie pobrane ponownie.", + "usage_label": "Co przechowuje to urządzenie", + "pin_not_ready": "Tego pliku nie ma jeszcze na liście tego urządzenia. Spróbuj ponownie za chwilę." }, "offlineDrafts": { "title": "Wersje robocze na tym urządzeniu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8eaa09aba..a47b9cd99 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, "offlineStorage": { - "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", + "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", "window_label": "Quanto tempo atrás manter", "window_description": "Os e-mails e eventos mais antigos saem deste dispositivo. Pode continuar a abri-los quando tiver ligação.", "window_days": "{days, plural, one {# dia} other {# dias}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina tudo o que este dispositivo mantém para esta conta e volta a transferi-lo.", "clear_button": "Limpar…", "clear_confirm_title": "Limpar esta conta neste dispositivo?", - "clear_confirm_message": "Tudo o que este dispositivo mantém para esta conta é eliminado e transferido novamente. O seu correio no servidor mantém-se.", + "clear_confirm_message": "Tudo o que este dispositivo mantém para {account} é eliminado e transferido novamente. O seu correio no servidor mantém-se.", "clear_confirm_unsent": "{count, plural, one {# mensagem por enviar será perdida.} other {# mensagens por enviar serão perdidas.}}", "clear_confirm": "Limpar", "persist_refused": "Este navegador pode eliminar o correio mantido quando o espaço escasseia. Instalar a aplicação costuma evitá-lo.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Não foi possível transferir", "keep_offline": "Manter offline", "unpin": "Deixar de manter offline", - "unpin_label": "Deixar de manter {name} offline" + "unpin_label": "Deixar de manter {name} offline", + "warning_desktop": "Qualquer pessoa que possa usar a conta de utilizador deste computador pode ler este correio. Use a cifragem do disco.", + "off_confirm_text": "Isto elimina tudo o que este dispositivo mantém para {account}. O seu correio no servidor mantém-se.", + "unsent_changes": "{count, plural, one {# alteração que não chegou ao servidor será perdida.} other {# alterações que não chegaram ao servidor serão perdidas.}}", + "clear_needs_connection": "É preciso ter ligação, porque tudo é transferido novamente.", + "usage_label": "O que este dispositivo mantém", + "pin_not_ready": "Este ficheiro ainda não está na lista deste dispositivo. Tente novamente dentro de instantes." }, "offlineDrafts": { "title": "Rascunhos neste dispositivo", diff --git a/locales/ro/common.json b/locales/ro/common.json index a50a742c9..a84e8ecf4 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, "offlineStorage": { - "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", + "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", "window_label": "Cât de departe în urmă se păstrează", "window_description": "E-mailurile și evenimentele mai vechi dispar de pe acest dispozitiv. Le puteți deschide în continuare când sunteți online.", "window_days": "{days, plural, one {# zi} few {# zile} other {# de zile}}", @@ -5792,7 +5792,7 @@ "clear_description": "Șterge tot ce păstrează acest dispozitiv pentru acest cont, apoi descarcă din nou.", "clear_button": "Golește…", "clear_confirm_title": "Goliți acest cont pe acest dispozitiv?", - "clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru acest cont este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.", + "clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru {account} este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.", "clear_confirm_unsent": "{count, plural, one {# mesaj netrimis se pierde.} few {# mesaje netrimise se pierd.} other {# de mesaje netrimise se pierd.}}", "clear_confirm": "Golește", "persist_refused": "Acest browser poate șterge e-mailurile păstrate când se termină spațiul. Instalarea aplicației de obicei previne asta.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nu a putut fi descărcat", "keep_offline": "Păstrează offline", "unpin": "Nu mai păstra offline", - "unpin_label": "Nu mai păstra {name} offline" + "unpin_label": "Nu mai păstra {name} offline", + "warning_desktop": "Oricine poate folosi contul de utilizator al acestui computer poate citi acest e-mail. Folosiți criptarea discului.", + "off_confirm_text": "Aceasta șterge tot ce păstrează acest dispozitiv pentru {account}. E-mailurile dvs. de pe server rămân.", + "unsent_changes": "{count, plural, one {# modificare care nu a ajuns pe server se pierde.} few {# modificări care nu au ajuns pe server se pierd.} other {# de modificări care nu au ajuns pe server se pierd.}}", + "clear_needs_connection": "Este nevoie de o conexiune, deoarece totul este descărcat din nou.", + "usage_label": "Ce păstrează acest dispozitiv", + "pin_not_ready": "Acest fișier nu se află încă în lista acestui dispozitiv. Încercați din nou peste un moment." }, "offlineDrafts": { "title": "Ciorne pe acest dispozitiv", diff --git a/locales/ru/common.json b/locales/ru/common.json index a9da6db0b..7ea9f08b6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, "offlineStorage": { - "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", + "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", "window_label": "За какой период хранить", "window_description": "Более старые письма и события календаря удаляются с этого устройства. Их по-прежнему можно открыть, когда есть сеть.", "window_days": "{days, plural, one {# день} few {# дня} many {# дней} other {# дня}}", @@ -5792,7 +5792,7 @@ "clear_description": "Удаляет всё, что это устройство хранит для этой учётной записи, и загружает заново.", "clear_button": "Очистить…", "clear_confirm_title": "Очистить эту учётную запись на этом устройстве?", - "clear_confirm_message": "Всё, что это устройство хранит для этой учётной записи, будет удалено и загружено заново. Ваша почта на сервере останется.", + "clear_confirm_message": "Всё, что это устройство хранит для {account}, будет удалено и загружено заново. Ваша почта на сервере останется.", "clear_confirm_unsent": "{count, plural, one {# неотправленное письмо будет потеряно.} few {# неотправленных письма будут потеряны.} many {# неотправленных писем будут потеряны.} other {# неотправленного письма будет потеряно.}}", "clear_confirm": "Очистить", "persist_refused": "Этот браузер может удалить сохранённую почту, когда не хватает места. Установка приложения обычно это предотвращает.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Не удалось загрузить", "keep_offline": "Хранить без сети", "unpin": "Больше не хранить без сети", - "unpin_label": "Больше не хранить {name} без сети" + "unpin_label": "Больше не хранить {name} без сети", + "warning_desktop": "Любой, кто может пользоваться учётной записью на этом компьютере, может читать эту почту. Используйте шифрование диска.", + "off_confirm_text": "Будет удалено всё, что это устройство хранит для {account}. Ваша почта на сервере останется.", + "unsent_changes": "{count, plural, one {# изменение, не дошедшее до сервера, будет потеряно.} few {# изменения, не дошедшие до сервера, будут потеряны.} many {# изменений, не дошедших до сервера, будут потеряны.} other {# изменения, не дошедшего до сервера, будет потеряно.}}", + "clear_needs_connection": "Нужно подключение, потому что всё загружается заново.", + "usage_label": "Что хранит это устройство", + "pin_not_ready": "Этого файла ещё нет в списке этого устройства. Попробуйте ещё раз через минуту." }, "offlineDrafts": { "title": "Черновики на этом устройстве", diff --git a/locales/sk/common.json b/locales/sk/common.json index 365f3dfda..bd0c8e306 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, "offlineStorage": { - "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", + "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", "window_label": "Ako dlho do minulosti uchovávať", "window_description": "Staršia pošta a udalosti z kalendára toto zariadenie opustia. Keď budete online, môžete ich stále otvoriť.", "window_days": "{days, plural, one {# deň} few {# dni} many {# dňa} other {# dní}}", @@ -5792,7 +5792,7 @@ "clear_description": "Zmaže všetko, čo toto zariadenie pre tento účet uchováva, a stiahne to znova.", "clear_button": "Vymazať…", "clear_confirm_title": "Vymazať tento účet na tomto zariadení?", - "clear_confirm_message": "Všetko, čo toto zariadenie pre tento účet uchováva, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.", + "clear_confirm_message": "Všetko, čo toto zariadenie uchováva pre účet {account}, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.", "clear_confirm_unsent": "{count, plural, one {# neodoslaná správa sa stratí.} few {# neodoslané správy sa stratia.} many {# neodoslanej správy sa stratí.} other {# neodoslaných správ sa stratí.}}", "clear_confirm": "Vymazať", "persist_refused": "Tento prehliadač môže uchovanú poštu zmazať, keď dôjde miesto. Inštalácia aplikácie tomu zvyčajne zabráni.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nepodarilo sa stiahnuť", "keep_offline": "Uchovať offline", "unpin": "Prestať uchovávať offline", - "unpin_label": "Prestať uchovávať {name} offline" + "unpin_label": "Prestať uchovávať {name} offline", + "warning_desktop": "Každý, kto môže používať používateľský účet na tomto počítači, môže túto poštu čítať. Použite šifrovanie disku.", + "off_confirm_text": "Tým sa zmaže všetko, čo toto zariadenie uchováva pre účet {account}. Vaša pošta na serveri zostane.", + "unsent_changes": "{count, plural, one {# zmena, ktorá sa nedostala na server, sa stratí.} few {# zmeny, ktoré sa nedostali na server, sa stratia.} many {# zmeny, ktorá sa nedostala na server, sa stratí.} other {# zmien, ktoré sa nedostali na server, sa stratí.}}", + "clear_needs_connection": "Je potrebné pripojenie, pretože sa všetko stiahne znova.", + "usage_label": "Čo toto zariadenie uchováva", + "pin_not_ready": "Tento súbor ešte nie je v zozname tohto zariadenia. Skúste to o chvíľu znova." }, "offlineDrafts": { "title": "Koncepty v tomto zariadení", diff --git a/locales/th/common.json b/locales/th/common.json index 600b9f8a0..1ec3a6997 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2109,7 +2109,7 @@ "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, "offlineStorage": { - "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในเบราว์เซอร์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", + "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", "window_label": "เก็บย้อนหลังนานแค่ไหน", "window_description": "อีเมลและกิจกรรมในปฏิทินที่เก่ากว่านี้จะถูกนำออกจากอุปกรณ์นี้ คุณยังเปิดดูได้เมื่อออนไลน์", "window_days": "{days, plural, other {# วัน}}", @@ -2137,7 +2137,7 @@ "clear_description": "ลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้ แล้วดาวน์โหลดใหม่", "clear_button": "ล้าง…", "clear_confirm_title": "ล้างบัญชีนี้บนอุปกรณ์นี้ไหม", - "clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับ {account} จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", "clear_confirm_unsent": "{count, plural, other {ข้อความที่ยังไม่ได้ส่ง # ข้อความจะสูญหาย}}", "clear_confirm": "ล้าง", "persist_refused": "เบราว์เซอร์นี้อาจลบอีเมลที่เก็บไว้เมื่อพื้นที่เหลือน้อย การติดตั้งแอปมักช่วยป้องกันได้", @@ -2161,7 +2161,13 @@ "kept_error_download": "ดาวน์โหลดไม่ได้", "keep_offline": "เก็บไว้ใช้ออฟไลน์", "unpin": "เลิกเก็บไว้ใช้ออฟไลน์", - "unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์" + "unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์", + "warning_desktop": "ทุกคนที่ใช้บัญชีผู้ใช้บนคอมพิวเตอร์เครื่องนี้ได้จะอ่านอีเมลนี้ได้ ควรใช้การเข้ารหัสดิสก์", + "off_confirm_text": "การดำเนินการนี้จะลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับ {account} อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "unsent_changes": "{count, plural, other {การเปลี่ยนแปลงที่ยังไปไม่ถึงเซิร์ฟเวอร์ # รายการจะสูญหาย}}", + "clear_needs_connection": "ต้องมีการเชื่อมต่อ เพราะทุกอย่างจะถูกดาวน์โหลดใหม่", + "usage_label": "สิ่งที่อุปกรณ์นี้เก็บไว้", + "pin_not_ready": "ไฟล์นี้ยังไม่อยู่ในรายการของอุปกรณ์นี้ ลองอีกครั้งในอีกสักครู่" }, "offlineDrafts": { "title": "ฉบับร่างในอุปกรณ์นี้", diff --git a/locales/tr/common.json b/locales/tr/common.json index 3ceb48445..ec40e5c20 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, "offlineStorage": { - "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", + "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", "window_label": "Ne kadar geriye tutulsun", "window_description": "Daha eski e-postalar ve takvim etkinlikleri bu cihazdan kaldırılır. Bağlantınız olduğunda yine açabilirsiniz.", "window_days": "{days, plural, one {# gün} other {# gün}}", @@ -5792,7 +5792,7 @@ "clear_description": "Bu cihazın bu hesap için tuttuğu her şeyi siler, sonra yeniden indirir.", "clear_button": "Temizle…", "clear_confirm_title": "Bu hesap bu cihazda temizlensin mi?", - "clear_confirm_message": "Bu cihazın bu hesap için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.", + "clear_confirm_message": "Bu cihazın {account} için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.", "clear_confirm_unsent": "{count, plural, one {# gönderilmemiş ileti kaybolacak.} other {# gönderilmemiş ileti kaybolacak.}}", "clear_confirm": "Temizle", "persist_refused": "Alan azaldığında bu tarayıcı tutulan e-postaları silebilir. Uygulamayı yüklemek genellikle bunu önler.", @@ -5816,7 +5816,13 @@ "kept_error_download": "İndirilemedi", "keep_offline": "Çevrimdışı tut", "unpin": "Çevrimdışı tutmayı bırak", - "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak" + "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak", + "warning_desktop": "Bu bilgisayarın kullanıcı hesabını kullanabilen herkes bu e-postaları okuyabilir. Disk şifrelemesini kullanın.", + "off_confirm_text": "Bu işlem, bu cihazın {account} için tuttuğu her şeyi siler. Sunucudaki e-postalarınız kalır.", + "unsent_changes": "{count, plural, one {Sunucuya ulaşmamış # değişiklik kaybolacak.} other {Sunucuya ulaşmamış # değişiklik kaybolacak.}}", + "clear_needs_connection": "Her şey yeniden indirileceği için bağlantı gerekir.", + "usage_label": "Bu cihazın tuttukları", + "pin_not_ready": "Bu dosya henüz bu cihazın listesinde değil. Biraz sonra yeniden deneyin." }, "offlineDrafts": { "title": "Bu cihazdaki taslaklar", diff --git a/locales/uk/common.json b/locales/uk/common.json index fb286e371..c85a7b154 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, "offlineStorage": { - "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", + "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", "window_label": "За який період зберігати", "window_description": "Старіші листи й події календаря видаляються з цього пристрою. Їх і надалі можна відкрити, коли є мережа.", "window_days": "{days, plural, one {# день} few {# дні} many {# днів} other {# дня}}", @@ -5792,7 +5792,7 @@ "clear_description": "Видаляє все, що цей пристрій зберігає для цього облікового запису, і завантажує знову.", "clear_button": "Очистити…", "clear_confirm_title": "Очистити цей обліковий запис на цьому пристрої?", - "clear_confirm_message": "Усе, що цей пристрій зберігає для цього облікового запису, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.", + "clear_confirm_message": "Усе, що цей пристрій зберігає для {account}, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.", "clear_confirm_unsent": "{count, plural, one {# невідправлений лист буде втрачено.} few {# невідправлені листи буде втрачено.} many {# невідправлених листів буде втрачено.} other {# невідправленого листа буде втрачено.}}", "clear_confirm": "Очистити", "persist_refused": "Цей браузер може видалити збережену пошту, коли бракує місця. Встановлення застосунку зазвичай цьому запобігає.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Не вдалося завантажити", "keep_offline": "Зберігати без мережі", "unpin": "Більше не зберігати без мережі", - "unpin_label": "Більше не зберігати {name} без мережі" + "unpin_label": "Більше не зберігати {name} без мережі", + "warning_desktop": "Будь-хто, хто може користуватися обліковим записом на цьому комп'ютері, може читати цю пошту. Використовуйте шифрування диска.", + "off_confirm_text": "Буде видалено все, що цей пристрій зберігає для {account}. Ваша пошта на сервері залишиться.", + "unsent_changes": "{count, plural, one {# зміну, що не дійшла до сервера, буде втрачено.} few {# зміни, що не дійшли до сервера, буде втрачено.} many {# змін, що не дійшли до сервера, буде втрачено.} other {# зміни, що не дійшла до сервера, буде втрачено.}}", + "clear_needs_connection": "Потрібне з'єднання, бо все завантажується знову.", + "usage_label": "Що зберігає цей пристрій", + "pin_not_ready": "Цього файла ще немає у списку цього пристрою. Спробуйте ще раз за хвилину." }, "offlineDrafts": { "title": "Чернетки на цьому пристрої", diff --git a/locales/vi/common.json b/locales/vi/common.json index 41282e782..c58e2cdc7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5776,7 +5776,7 @@ "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, "offlineStorage": { - "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", + "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", "window_label": "Giữ lại bao lâu", "window_description": "Thư và sự kiện lịch cũ hơn sẽ bị xóa khỏi thiết bị này. Bạn vẫn có thể mở chúng khi có kết nối.", "window_days": "{days, plural, other {# ngày}}", @@ -5804,7 +5804,7 @@ "clear_description": "Xóa mọi thứ thiết bị này giữ cho tài khoản này, rồi tải lại.", "clear_button": "Xóa…", "clear_confirm_title": "Xóa tài khoản này trên thiết bị này?", - "clear_confirm_message": "Mọi thứ thiết bị này giữ cho tài khoản này sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.", + "clear_confirm_message": "Mọi thứ thiết bị này giữ cho {account} sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.", "clear_confirm_unsent": "{count, plural, other {# thư chưa gửi sẽ bị mất.}}", "clear_confirm": "Xóa", "persist_refused": "Trình duyệt này có thể xóa thư đã giữ khi sắp hết dung lượng. Cài đặt ứng dụng thường giúp tránh điều này.", @@ -5828,7 +5828,13 @@ "kept_error_download": "Không tải xuống được", "keep_offline": "Giữ ngoại tuyến", "unpin": "Ngừng giữ ngoại tuyến", - "unpin_label": "Ngừng giữ {name} ngoại tuyến" + "unpin_label": "Ngừng giữ {name} ngoại tuyến", + "warning_desktop": "Bất kỳ ai dùng được tài khoản người dùng trên máy tính này đều có thể đọc thư này. Hãy dùng mã hóa ổ đĩa.", + "off_confirm_text": "Thao tác này xóa mọi thứ thiết bị này giữ cho {account}. Thư của bạn trên máy chủ vẫn còn.", + "unsent_changes": "{count, plural, other {# thay đổi chưa đến được máy chủ sẽ bị mất.}}", + "clear_needs_connection": "Cần có kết nối, vì mọi thứ sẽ được tải lại.", + "usage_label": "Những gì thiết bị này giữ", + "pin_not_ready": "Tệp này chưa có trong danh sách của thiết bị này. Hãy thử lại sau giây lát." }, "offlineDrafts": { "title": "Bản nháp trên thiết bị này", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b3310374d..67bbafae1 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, "offlineStorage": { - "keep_description": "在此瀏覽器中加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", + "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", "window_label": "保留多久以內的內容", "window_description": "更早的郵件和行事曆活動會從此裝置移除。連線時你仍可以開啟它們。", "window_days": "{days, plural, other {# 天}}", @@ -5792,7 +5792,7 @@ "clear_description": "刪除此裝置為該帳號保留的全部內容,然後重新下載。", "clear_button": "清除…", "clear_confirm_title": "在此裝置上清除該帳號?", - "clear_confirm_message": "此裝置為該帳號保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。", + "clear_confirm_message": "此裝置為 {account} 保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。", "clear_confirm_unsent": "{count, plural, other {# 封未寄出的郵件將會遺失。}}", "clear_confirm": "清除", "persist_refused": "儲存空間不足時,此瀏覽器可能會刪除保留的郵件。安裝應用程式通常可以避免這種情況。", @@ -5816,7 +5816,13 @@ "kept_error_download": "無法下載", "keep_offline": "離線保留", "unpin": "不再離線保留", - "unpin_label": "不再離線保留 {name}" + "unpin_label": "不再離線保留 {name}", + "warning_desktop": "任何能使用這台電腦使用者帳號的人都可以閱讀這些郵件。請使用磁碟加密。", + "off_confirm_text": "這會刪除此裝置為 {account} 保留的全部內容。伺服器上的郵件不受影響。", + "unsent_changes": "{count, plural, other {# 項尚未送達伺服器的變更將會遺失。}}", + "clear_needs_connection": "需要連線,因為所有內容都會重新下載。", + "usage_label": "此裝置保留的內容", + "pin_not_ready": "此檔案還不在此裝置的清單中。請稍後再試。" }, "offlineDrafts": { "title": "此裝置上的草稿", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4693f1f58..4897cae00 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, "offlineStorage": { - "keep_description": "在此浏览器中加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", + "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", "window_label": "保留多久以内的内容", "window_description": "更早的邮件和日历事件会从此设备中移除。联网时你仍可以打开它们。", "window_days": "{days, plural, other {# 天}}", @@ -5792,7 +5792,7 @@ "clear_description": "删除此设备为该账户保留的全部内容,然后重新下载。", "clear_button": "清除…", "clear_confirm_title": "在此设备上清除该账户?", - "clear_confirm_message": "此设备为该账户保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。", + "clear_confirm_message": "此设备为 {account} 保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。", "clear_confirm_unsent": "{count, plural, other {# 封未发送的邮件将丢失。}}", "clear_confirm": "清除", "persist_refused": "存储空间不足时,此浏览器可能会删除保留的邮件。安装应用通常可以避免这种情况。", @@ -5816,7 +5816,13 @@ "kept_error_download": "无法下载", "keep_offline": "离线保留", "unpin": "不再离线保留", - "unpin_label": "不再离线保留 {name}" + "unpin_label": "不再离线保留 {name}", + "warning_desktop": "任何能使用这台电脑用户账户的人都可以阅读这些邮件。请使用磁盘加密。", + "off_confirm_text": "这会删除此设备为 {account} 保留的全部内容。服务器上的邮件不受影响。", + "unsent_changes": "{count, plural, other {# 项尚未到达服务器的更改将丢失。}}", + "clear_needs_connection": "需要联网,因为所有内容都会重新下载。", + "usage_label": "此设备保留的内容", + "pin_not_ready": "此文件还不在此设备的列表中。请稍后再试。" }, "offlineDrafts": { "title": "此设备上的草稿", diff --git a/stores/__tests__/policy-store-partial.test.ts b/stores/__tests__/policy-store-partial.test.ts index bf40f6ac9..e22ce8110 100644 --- a/stores/__tests__/policy-store-partial.test.ts +++ b/stores/__tests__/policy-store-partial.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Before signing in the server answers with the public part of the policy; // the store has to fetch the rest once a session exists. @@ -20,6 +20,33 @@ beforeEach(() => { usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false, partial: false }); }); +describe('a public answer to a page that is signed in', () => { + const FULL = { ...DEFAULT_POLICY, organisation: { devices: { noOffline: true } } }; + + afterEach(() => localStorage.removeItem('auth-storage')); + + it('doesn\'t replace the whole policy it has: the public part has no caps', async () => { + localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true } })); + fetchMock.mockResolvedValueOnce(answer(FULL)); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBe(true); + + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().partial).toBe(false); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBe(true); + }); + + it('does once nobody is signed in', async () => { + fetchMock.mockResolvedValueOnce(answer(FULL)); + await usePolicyStore.getState().fetchPolicy(); + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().partial).toBe(true); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBeFalsy(); + }); +}); + describe('policy store', () => { it('fetches the full policy after signing in when only the public part was loaded', async () => { fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); diff --git a/stores/policy-store.ts b/stores/policy-store.ts index ece536085..df2e1e577 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -157,6 +157,15 @@ function resolved(rawPolicy: SettingsPolicy, subject: PolicySubject | null) { const INITIAL = resolved({ ...DEFAULT_POLICY }, null); +/** The session auth-store keeps, read without importing it. */ +function persistedSignedIn(): boolean { + try { + return JSON.parse(localStorage.getItem('auth-storage') ?? 'null')?.state?.isAuthenticated === true; + } catch { + return false; + } +} + export const usePolicyStore = create()((set, get) => ({ ...INITIAL, subject: null, @@ -216,6 +225,12 @@ export const usePolicyStore = create()((set, get) => ({ set({ loaded: true }); return; } + // A signed-in page that got the public part (its cookie wasn't there yet) + // keeps the whole policy it has: the public part has no caps and no scopes. + if (partial && !current.partial && (current.source === 'server' || current.source === 'cache') && persistedSignedIn()) { + set({ loaded: true }); + return; + } lastServerJson = json; set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, source: 'server', partial, scopeDomains }); if (!partial) writeCachedPolicy(data); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index c3f90fa99..d5214d78c 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -128,7 +128,7 @@ export interface ProTab { * localStorage would give it away. The tab's body puts the title back when it * has read the message. */ -const TITLE_UNTIL_LOADED = '…'; +export const TITLE_UNTIL_LOADED = '…'; function withoutSubjects(tabs: ProTab[]): ProTab[] { if (useOfflineStore.getState().enabled.length === 0) return tabs; @@ -687,7 +687,9 @@ export const useProTabStore = create()( updateTabTitle: (id, title) => { set({ - tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, title } : tab)), + tabs: get().tabs.map((tab) => (tab.id === id + ? { ...tab, title, ...(tab.emailData ? { emailData: { ...tab.emailData, title } } : {}) } + : tab)), }); },