- draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
- draft.clearOrgValue('devices', 'noOffline')} />
+ draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
+ draft.clearOrgValue('devices', 'noOffline')} />
+
+
+ {CAP_FIELDS.map((field) => {
+ const { value, own, everyone } = draft.orgValue('devices', field.key);
+ return (
+
+
+
+ 0 ? value : ''}
+ onChange={(e) => {
+ const raw = e.target.value.trim();
+ const parsed = raw === '' ? 0 : Math.round(Number(raw));
+ if (!Number.isFinite(parsed)) return;
+ draft.setOrgValue('devices', field.key, Math.min(field.max, Math.max(0, parsed)));
+ }}
+ className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
+ />
+ {field.unit}
+
+
draft.clearOrgValue('devices', field.key)} />
+
+
+ );
+ })}
+
+
+ draft.setOrgValue('devices', 'noOfflineAttachments', v)} testId="org-devices-noOfflineAttachments" />
+ draft.clearOrgValue('devices', 'noOfflineAttachments')} />
- What a device keeps: the last 30 days of the Inbox and of the folders the person opens, at most 250 MB, encrypted in
- the browser. That protects against someone copying the browser's files, not against someone using the unlocked
- browser. Every sign-out, the automatic one after inactivity included, deletes it. With several accounts signed in,
- each account follows its own setting. Bulwark Lite has no offline mode.{' '}
+ 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
+ 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.
+ Bulwark Lite reads the same policy but has no offline mode.{' '}
How offline mode works
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx
index 37d375816..d8bbc9683 100644
--- a/components/files/file-browser.tsx
+++ b/components/files/file-browser.tsx
@@ -3,12 +3,13 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
- Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
+ Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
+import { useKeepFileOffline } from "@/hooks/use-keep-file-offline";
import { useRegisterCommands } from "@/lib/command-registry";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { Button } from "@/components/ui/button";
@@ -319,6 +320,8 @@ export function FileBrowser({
const dragDepth = useRef(0);
const anchorName = useRef(null);
const menu = useContextMenu();
+ const tOffline = useTranslations("offlineStorage");
+ const { keptOffline, canKeepOffline, toggleKeptOffline } = useKeepFileOffline(client);
const fileInputRef = useRef(null);
const folderInputRef = useRef(null);
const searchInputRef = useRef(null);
@@ -939,6 +942,13 @@ export function FileBrowser({
)}
>
)}
+ {canKeepOffline(resource) && (
+ { closeMenu(); void toggleKeptOffline(resource); }}
+ />
+ )}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && }
{canShare(resource) && (
{ closeMenu(); setShareTargetId(resource.id); }} />
diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx
new file mode 100644
index 000000000..7fcde2e2a
--- /dev/null
+++ b/components/offline/__tests__/account-storage.test.tsx
@@ -0,0 +1,258 @@
+import 'fake-indexeddb/auto';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const offline = vi.hoisted(() => ({
+ clearAccount: vi.fn(async () => {}),
+ chooseScope: vi.fn(),
+ requestPersistence: vi.fn(async () => 'refused'),
+ dismissAreaConflict: vi.fn(async () => {}),
+}));
+vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
+ ...(await importOriginal()),
+ clearAccount: offline.clearAccount,
+ chooseScope: offline.chooseScope,
+ requestPersistence: offline.requestPersistence,
+ dismissAreaConflict: offline.dismissAreaConflict,
+ readPersistence: vi.fn(async () => 'refused'),
+ refreshUsage: vi.fn(async () => {}),
+}));
+
+import { OfflineSettings } from '../offline-settings';
+import { OfflineBanner } from '../offline-banner';
+import { useOfflineStore } from '@/lib/offline/state';
+import { useAuthStore } from '@/stores/auth-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
+import { fileRefFor } from '@/hooks/use-keep-file-offline';
+
+const area = vi.fn(async (_account: string, name: string) => (name === 'files.kept' ? {
+ files: [
+ { jmap: 'j', id: 'f1', name: 'Pinned.pdf', size: 2048, pinned: true, kept: true, error: null },
+ { jmap: 'j', id: 'f2', name: 'Big.iso', size: 1048576, pinned: true, kept: false, error: 'overLimit' },
+ { jmap: 'j', id: 'f3', name: 'Opened.txt', size: 5, pinned: false, kept: true, error: null },
+ ],
+} : null));
+
+function setDevices(devices: Partial) {
+ usePolicyStore.setState({
+ loaded: true, source: 'server', subject: null,
+ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
+ } as never);
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ setStoreBackend({ supported: () => true, area, setScope: vi.fn(async () => {}) } as unknown as StoreBackend);
+ useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map()) as never });
+ useOfflineStore.setState({
+ enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
+ storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
+ usage: { acc: { bytes: 5000, parts: [{ part: 'mail', bytes: 4000, items: 12 }, { part: 'attachments', bytes: 1000, items: 2 }] } },
+ });
+ setDevices({});
+});
+
+afterEach(() => { setStoreBackend(null); });
+
+const windowSelect = () => screen.getByText('window_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
+
+describe('what an account keeps', () => {
+ it('offers every window and hands the choice on', () => {
+ render( );
+ expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '90', '365', 'all']);
+ expect(windowSelect().value).toBe('90');
+ fireEvent.change(windowSelect(), { target: { value: 'all' } });
+ expect(offline.chooseScope).toHaveBeenCalledWith('acc', { days: null });
+ });
+
+ it('under a cap offers only what fits, and says who set it', () => {
+ setDevices({ offlineMaxDays: 60 });
+ render( );
+ expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '60']);
+ expect(windowSelect().value).toBe('60');
+ expect(within(screen.getByText('window_label').closest('[data-search-label]') as HTMLElement).getByText('org_locked')).toBeInTheDocument();
+ });
+
+ it('locks the window when the cap leaves no choice', () => {
+ setDevices({ offlineMaxDays: 14 });
+ render( );
+ const row = screen.getByText('window_label').closest('[data-search-label]') as HTMLElement;
+ expect(windowSelect().parentElement?.closest('[inert]')).not.toBeNull();
+ expect(within(row).getByText('org_locked')).toBeInTheDocument();
+ });
+
+ it('locks attachments off when the organisation forbids them, and hides the size', () => {
+ useOfflineStore.setState({ choices: { acc: { on: true, attachments: true } } });
+ setDevices({ noOfflineAttachments: true });
+ render( );
+ const toggle = screen.getByTestId('offline-attachments');
+ expect(toggle.getAttribute('aria-checked')).toBe('false');
+ expect(toggle.closest('[inert]')).not.toBeNull();
+ expect(screen.queryByText('attachment_cap_label')).toBeNull();
+ fireEvent.click(toggle);
+ });
+
+ it('caps the size choice', () => {
+ setDevices({ offlineMaxAttachmentMb: 10 });
+ render( );
+ const select = screen.getByText('attachment_cap_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
+ expect([...select.options].map((o) => o.value)).toEqual(['5', '10']);
+ fireEvent.change(select, { target: { value: '5' } });
+ expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentBytes: 5 * 1024 * 1024 });
+ });
+
+ it('says when the total is set by the organisation', () => {
+ setDevices({ offlineMaxTotalMb: 100 });
+ render( );
+ expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked');
+ });
+
+ it('locks the switch when the desktop policy forces it', () => {
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled: { value: true, locked: true, source: 'machine' } }) } };
+ render( );
+ expect(screen.getByTestId('offline-switch').closest('[inert]')).not.toBeNull();
+ delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ });
+
+ it('shows nothing of this with offline off', () => {
+ useOfflineStore.setState({ enabled: [] });
+ render( );
+ expect(screen.queryByText('window_label')).toBeNull();
+ expect(screen.queryByTestId('offline-clear')).toBeNull();
+ });
+});
+
+describe('what it takes', () => {
+ it('lists each part', () => {
+ render( );
+ const usage = screen.getByTestId('offline-usage');
+ expect(usage.textContent).toContain('usage_mail');
+ expect(usage.textContent).toContain('usage_attachments');
+ expect(screen.getByTestId('offline-status').textContent).toContain('usage');
+ });
+
+ it('shows the time of the last sync from before a reload', () => {
+ useOfflineStore.setState({ health: { acc: { lastSync: Date.now() - 60_000 } } });
+ render( );
+ expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated');
+ });
+
+ it('shows a large sync going on, with the time left once it can tell', () => {
+ const now = Date.now();
+ useOfflineStore.setState({
+ syncing: ['acc'],
+ progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now - 60_000, startedDone: 0, at: now } },
+ });
+ render( );
+ const line = screen.getByTestId('offline-progress');
+ expect(line.textContent).toContain('progress_bodies');
+ expect(line.textContent).toContain('progress_count');
+ expect(line.textContent).toContain('progress_left');
+ expect(line.querySelector('progress')?.getAttribute('max')).toBe('5000');
+ });
+
+ it('keeps a small catch-up quiet', () => {
+ const now = Date.now();
+ useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 1, total: 3, startedAt: now, startedDone: 0, at: now } } });
+ render( );
+ expect(screen.queryByTestId('offline-progress')).toBeNull();
+ });
+});
+
+describe('clearing an account', () => {
+ it('asks first, naming unsent mail', () => {
+ useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'Hi', to: [], status: 'waiting' }] });
+ render( );
+ fireEvent.click(screen.getByTestId('offline-clear'));
+ expect(offline.clearAccount).not.toHaveBeenCalled();
+ expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' }));
+ expect(offline.clearAccount).toHaveBeenCalledWith('acc');
+ });
+});
+
+describe('what the browser may do', () => {
+ it('warns when it refused to keep the data, and can ask again', async () => {
+ useOfflineStore.setState({ persist: 'refused' });
+ render( );
+ expect(screen.getByTestId('offline-persist-refused').textContent).toContain('persist_refused');
+ await act(async () => { fireEvent.click(screen.getByTestId('offline-persist-retry')); });
+ expect(offline.requestPersistence).toHaveBeenCalled();
+ });
+
+ it('says nothing when it agreed', () => {
+ render( );
+ expect(screen.queryByTestId('offline-persist-refused')).toBeNull();
+ });
+
+ it('warns about a store it deleted, with the count, until read', () => {
+ useOfflineStore.setState({ health: { acc: { reset: { at: 1, lost: 2 } } } });
+ render( );
+ expect(screen.getByTestId('offline-evicted').textContent).toContain('evicted_lost');
+ fireEvent.click(within(screen.getByTestId('offline-evicted')).getByRole('button', { name: 'evicted_dismiss' }));
+ expect(useOfflineStore.getState().health.acc.reset).toBeUndefined();
+ });
+
+ it('says the device is full, with what to do', () => {
+ useOfflineStore.setState({ storageFull: { acc: 1 } });
+ render( );
+ const alert = screen.getByTestId('offline-storage-full');
+ expect(alert.textContent).toContain('storage_full_title');
+ fireEvent.click(within(alert).getByRole('button', { name: 'clear_button' }));
+ expect(screen.getByRole('button', { name: 'clear_confirm' })).toBeInTheDocument();
+ });
+
+ it('gives the Safari hint only in Safari', () => {
+ const real = window.navigator.userAgent;
+ render( );
+ expect(screen.queryByTestId('offline-safari-hint')).toBeNull();
+ Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' });
+ render( );
+ expect(screen.getByTestId('offline-safari-hint')).toBeInTheDocument();
+ Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: real });
+ });
+});
+
+describe('everything that did not go through', () => {
+ const conflicts = [
+ { area: 'calendar', account: 'acc', id: 1, createdAt: 1, title: 'Team meeting', reason: 'changed', destroy: false, detail: null },
+ { area: 'contacts', account: 'acc', id: 2, createdAt: 1, title: 'Ana Silva', reason: 'refused', destroy: false, detail: 'Nope' },
+ ] as const;
+
+ it('lists calendar and contacts next to mail, and each can be dismissed', async () => {
+ useOfflineStore.setState({ areaConflicts: [...conflicts] });
+ render( );
+ const rows = screen.getAllByTestId('offline-conflict-row');
+ expect(rows.map((row) => row.getAttribute('data-area'))).toEqual(['calendar', 'contacts']);
+ expect(rows[0].textContent).toContain('Team meeting');
+ fireEvent.click(within(rows[1]).getByRole('button', { name: 'dismiss_label' }));
+ await waitFor(() => expect(offline.dismissAreaConflict).toHaveBeenCalledWith(conflicts[1]));
+ });
+
+ it('counts them all in the banner', () => {
+ useOfflineStore.setState({ areaConflicts: [...conflicts] });
+ render( );
+ expect(screen.getByTestId('offline-banner-conflicts')).toBeInTheDocument();
+ });
+});
+
+describe('files kept offline', () => {
+ it('lists the pinned ones with their state, and can let go of one', async () => {
+ render( );
+ const rows = await screen.findAllByTestId('offline-kept-file');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].textContent).toContain('kept_state_kept');
+ expect(rows[1].textContent).toContain('kept_error_overLimit');
+ expect(screen.queryByText('Opened.txt')).toBeNull();
+ fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
+ await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.objectContaining({ jmap: 'j', id: 'f1' })));
+ });
+
+ 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/account-storage.tsx b/components/offline/account-storage.tsx
new file mode 100644
index 000000000..773ca78e6
--- /dev/null
+++ b/components/offline/account-storage.tsx
@@ -0,0 +1,205 @@
+'use client';
+
+import { useEffect, 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 { formatFileSize } from '@/lib/utils';
+import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode';
+import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope';
+import { useOfflineStore } from '@/lib/offline/state';
+import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
+import { devicesForAccount } from '@/lib/device-policy';
+import { KeptFiles } from './kept-files';
+
+const MB = 1024 * 1024;
+const ALL = 'all';
+
+function ProgressLine({ progress }: { progress: SyncProgress }) {
+ const t = useTranslations('offlineStorage');
+ const format = useFormatter();
+ const phase = progress.phase === 'list' ? t('progress_list')
+ : progress.phase === 'bodies' ? t('progress_bodies')
+ : progress.phase === 'threads' ? t('progress_threads')
+ : progress.area === 'files' ? t('progress_files')
+ : t('progress_other');
+ const left = minutesLeft(progress);
+ const count = progress.total === null
+ ? t('progress_count_open', { done: format.number(progress.done) })
+ : t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) });
+ return (
+
+
+ {phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}>}
+
+ {progress.total !== null && progress.total > 0 && (
+
+ )}
+
+ );
+}
+
+/** 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');
+ const tStorage = useTranslations('offlineStorage');
+ const format = useFormatter();
+ const enabled = useOfflineStore((s) => s.enabled.includes(account));
+ const usage = useOfflineStore((s) => s.usage[account]);
+ const syncing = useOfflineStore((s) => s.syncing.includes(account));
+ const lastSynced = useOfflineStore((s) => s.lastSyncedAt[account] ?? s.health[account]?.lastSync);
+ 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);
+ // Re-renders on a choice; the value itself is worked out below, with the policy.
+ useOfflineStore((s) => s.choices[account]);
+ const [confirmOff, setConfirmOff] = useState(false);
+ const [confirmClear, setConfirmClear] = useState(false);
+ const devices = devicesForAccount(account);
+ const { scope, locked } = scopeForAccount(account);
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => { setNow(Date.now()); }, [lastSynced, syncing]);
+
+ const testId = (id: string) => (primary ? id : `${id}-other`);
+
+ if (locked.offline) {
+ return (
+
+ {heading &&
{heading}
}
+
{t('keep_label')}
+
{t('forbidden')}
+
+
+ );
+ }
+
+ const windows = windowOptions(devices.offlineMaxDays);
+ const windowLabel = (days: number | null) => {
+ if (days === null) return tStorage('window_everything');
+ if (days === 365) return tStorage('window_year');
+ return tStorage('window_days', { days });
+ };
+ const caps = attachmentCapOptions(devices.offlineMaxAttachmentMb);
+ const currentCap = maxAttachmentMb(scope);
+
+ return (
+
+ {heading &&
{heading}
}
+
+ {
+ if (on) void enableOffline(account);
+ else if (unsent > 0) setConfirmOff(true);
+ else void disableOffline(account);
+ }}
+ />
+
+ {storeReset && (
+
+
+ {tStorage('evicted')}
+ {storeReset.lost > 0 && <> {tStorage('evicted_lost', { count: storeReset.lost })}>}
+
+
dismissStoreReset(account)}>{tStorage('evicted_dismiss')}
+
+ )}
+ {enabled && (
+ <>
+ {storageFull && (
+
+
{tStorage('storage_full_title')} {tStorage('storage_full_text')}
+
setConfirmClear(true)}>{tStorage('clear_button')}
+
+ )}
+
+
+ {syncing
+ ? t('syncing')
+ : lastSynced
+ ? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) })
+ : t('usage', { size: formatFileSize(usage?.bytes ?? 0) })}
+
+ {syncing && progressWorthShowing(progress) &&
}
+ {usage && usage.parts.length > 0 && (
+
+ {usage.parts.filter((part) => part.items > 0).map((part) => (
+
+
{tStorage(`usage_${part.part}`)}
+ {tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}
+
+ ))}
+
+ )}
+
+
+
+ chooseScope(account, { days: value === ALL ? null : Number(value) })}
+ options={windows.map((days) => ({ value: days === null ? ALL : String(days), label: windowLabel(days) }))}
+ />
+ {locked.days && windows.length > 1 && }
+
+
+
+ chooseScope(account, { attachments: on })}
+ />
+
+ {locked.maxTotal && !locked.attachments && (
+
+
+ {tStorage('total_locked', { size: formatFileSize(scope.maxTotalBytes) })}
+
+ )}
+ {scope.attachments && (
+
+
+ chooseScope(account, { maxAttachmentBytes: Number(value) * MB })}
+ options={(caps.includes(currentCap) ? caps : [...caps, currentCap].sort((a, b) => a - b)).map((mb) => ({ value: String(mb), label: formatFileSize(mb * MB) }))}
+ />
+ {locked.maxAttachment && caps.length > 1 && }
+
+
+ )}
+
+
+ setConfirmClear(true)} data-testid={testId('offline-clear')}>{tStorage('clear_button')}
+
+ >
+ )}
+
setConfirmOff(false)}
+ onConfirm={() => { setConfirmOff(false); void disableOffline(account); }}
+ title={t('off_confirm_title')}
+ message={t('off_confirm_message', { count: unsent })}
+ confirmText={t('off_confirm')}
+ variant="destructive"
+ />
+ 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')}
+ confirmText={tStorage('clear_confirm')}
+ variant="destructive"
+ />
+
+ );
+}
diff --git a/components/offline/kept-files.tsx b/components/offline/kept-files.tsx
new file mode 100644
index 000000000..f38bf9059
--- /dev/null
+++ b/components/offline/kept-files.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/button';
+import { formatFileSize } from '@/lib/utils';
+import { useKeptFiles } from '@/hooks/use-kept-files';
+
+/** The files this account keeps until told otherwise, in Settings > Backup & reset > Offline. */
+export function KeptFiles({ account }: { account: string }) {
+ const t = useTranslations('offlineStorage');
+ const { pinned, unpin } = useKeptFiles(account);
+ if (pinned.length === 0) return null;
+ return (
+
+
{t('kept_files_title')}
+
+ {pinned.map((file) => (
+
+
+
{file.name}
+
+ {file.size !== null && <>{formatFileSize(file.size)} · >}
+ {file.kept ? t('kept_state_kept') : file.error ? t(`kept_error_${file.error}` as 'kept_error_download') : t('kept_state_waiting')}
+
+
+ void unpin(file)}>{t('unpin')}
+
+ ))}
+
+
+ );
+}
diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx
index dc179e362..2174bfb43 100644
--- a/components/offline/offline-banner.tsx
+++ b/components/offline/offline-banner.tsx
@@ -24,7 +24,7 @@ export function OfflineBanner() {
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
- const conflicts = useOfflineStore((s) => s.conflicts.length);
+ const conflicts = useOfflineStore((s) => s.conflicts.length + s.areaConflicts.length);
const tConflicts = useTranslations('offlineConflicts');
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx
index 0ddb51046..23208b1d8 100644
--- a/components/offline/offline-host.tsx
+++ b/components/offline/offline-host.tsx
@@ -2,10 +2,12 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
+import { useRouter } from '@/i18n/navigation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { addSignOutGate } from '@/lib/sign-out-cleanup';
import { reloadToNewBuild } from '@/lib/app-update';
+import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { toast } from '@/stores/toast-store';
@@ -22,6 +24,12 @@ export function OfflineHost() {
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
+ const tStorage = useTranslations('offlineStorage');
+ const tStorageRef = useRef(tStorage);
+ tStorageRef.current = tStorage;
+ const router = useRouter();
+ const routerRef = useRef(router);
+ routerRef.current = router;
const { dialogProps, confirm } = useConfirmDialog();
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
@@ -29,6 +37,7 @@ export function OfflineHost() {
useEffect(() => {
const show = (notice: OfflineNotice) => {
const tr = tRef.current;
+ const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) };
switch (notice.kind) {
case 'queued':
toast.info(tr('toast.queued'));
@@ -51,13 +60,20 @@ export function OfflineHost() {
toast.warning(tConflictsRef.current('toast', { count: notice.count }));
break;
case 'storage-failed':
- toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 });
+ toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
+ break;
+ case 'default-on':
+ toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
break;
case 'store-reset':
- toast.warning(tConflictsRef.current('store_reset'), { duration: 0 });
+ toast.warning(tConflictsRef.current('store_reset'), {
+ message: notice.lost > 0 ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined,
+ duration: 0,
+ action: openStorage,
+ });
break;
case 'store-unavailable':
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
@@ -103,7 +119,7 @@ export function OfflineHost() {
return () => {
removeGate();
stop();
- setOfflineNotifier(() => {});
+ setOfflineNotifier(null);
};
}, []);
diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx
index d8c1a0439..2dfea8a3a 100644
--- a/components/offline/offline-settings.tsx
+++ b/components/offline/offline-settings.tsx
@@ -1,45 +1,54 @@
'use client';
-import { useState } from 'react';
-import { useFormatter, useTranslations } from 'next-intl';
+import { useEffect, useState } from 'react';
+import { useTranslations } from 'next-intl';
import { AlertTriangle } from '@/components/icons';
-import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
-import { OrgLockedNote } from '@/components/settings/org-locked-note';
-import { ConfirmDialog } from '@/components/ui/confirm-dialog';
+import { SettingsSection } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
import { IS_LITE } from '@/lib/lite';
-import { formatFileSize } from '@/lib/utils';
-import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode';
+import { signedInAccountIds } from '@/lib/device-policy';
+import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { useDevicePolicy } from '@/hooks/use-device-policy';
+import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
+import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
/**
- * Settings > Backup & reset > Offline (#523): "Keep mail on this device"
- * for the active account, the space it uses, and the outbox. Not in Lite.
- * When the organisation forbids it, the switch is gone and a note says so.
+ * Settings > Backup & reset > Offline (#523): for each account signed in here,
+ * whether it keeps mail on this device, how much, what that takes, and the way
+ * to clear it. Above that what the browser may do to it, below it the outbox
+ * and whatever didn't go through. Not in Lite.
*/
export function OfflineSettings() {
const t = useTranslations('offline.settings');
- const format = useFormatter();
- const account = useAuthStore((s) => s.activeAccountId);
+ const tStorage = useTranslations('offlineStorage');
+ const active = useAuthStore((s) => s.activeAccountId);
+ useAuthStore((s) => s.connectedAccountsRevision);
// Re-renders when the organisation's device settings change.
- const devices = useDevicePolicy();
- const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account));
- const usage = useOfflineStore((s) => s.usageBytes);
- const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
- const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
- const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length);
+ useDevicePolicy();
+ const labels = useAccountStore((s) => s.accounts);
+ const enabledCount = useOfflineStore((s) => s.enabled.length);
+ const persist = useOfflineStore((s) => s.persist);
const blocked = useOfflineStore((s) => s.storageBlocked);
const tNotes = useTranslations('offlineConflicts');
- const [confirmOff, setConfirmOff] = useState(false);
+ const [asking, setAsking] = useState(false);
+
+ useEffect(() => {
+ if (IS_LITE || enabledCount === 0) return;
+ void readPersistence();
+ void refreshUsage();
+ }, [enabledCount]);
if (IS_LITE) return null;
- const allowed = !devices.noOffline && offlineAllowed(account);
- const turnOff = () => {
- if (!account) return;
- void disableOffline(account);
+ const accounts = [...new Set([...(active ? [active] : []), ...signedInAccountIds()])];
+ const several = accounts.length > 1;
+ const askAgain = async () => {
+ setAsking(true);
+ await requestPersistence();
+ setAsking(false);
};
return (
@@ -48,51 +57,32 @@ export function OfflineSettings() {
{tNotes('store_private')}
) : !offlineSupported() ? (
{t('unsupported')}
- ) : !allowed ? (
-
-
{t('keep_label')}
-
{t('forbidden')}
-
-
) : (
<>
-
- {
- if (!account) return;
- if (on) void enableOffline(account);
- else if (unsent > 0) setConfirmOff(true);
- else turnOff();
- }}
- />
-
- {enabled && (
-
- {syncing
- ? t('syncing')
- : lastSynced
- ? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) })
- : t('usage', { size: formatFileSize(usage) })}
-
+ {enabledCount > 0 && persist === 'refused' && (
+
+
{tStorage('persist_refused')}
+
void askAgain()} data-testid="offline-persist-retry">{tStorage('persist_retry')}
+
)}
+ {enabledCount > 0 && safariMayDelete() && (
+ {tStorage('safari_hint')}
+ )}
+ {accounts.map((account, index) => (
+ entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
+ />
+ ))}
>
)}
- setConfirmOff(false)}
- onConfirm={() => { setConfirmOff(false); turnOff(); }}
- title={t('off_confirm_title')}
- message={t('off_confirm_message', { count: unsent })}
- confirmText={t('off_confirm')}
- variant="destructive"
- />
);
}
diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx
index 7e8c3a864..37ef47d71 100644
--- a/components/offline/outbox-list.tsx
+++ b/components/offline/outbox-list.tsx
@@ -5,9 +5,9 @@ import { useFormatter, useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
-import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
+import { dismissAreaConflict, refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissConflict, type ConflictEntry } from '@/lib/offline/queue';
-import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
+import { isOfflineNow, useOfflineStore, type AreaConflict, type OutboxRow } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
/**
@@ -22,10 +22,12 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
const t = useTranslations('offline.outbox');
const tErrors = useTranslations('offline.errors');
const tConflicts = useTranslations('offlineConflicts');
+ const tStorage = useTranslations('offlineStorage');
const format = useFormatter();
const rows = useOfflineStore((s) => s.outbox);
const changes = useOfflineStore((s) => s.pendingChanges);
const conflicts = useOfflineStore((s) => s.conflicts);
+ const areaConflicts = useOfflineStore((s) => s.areaConflicts);
const accounts = useAccountStore((s) => s.accounts);
const [deleting, setDeleting] = useState