Files
webmail/components/offline/offline-settings.tsx
T
Linus Rath b082ce582e feat: put offline, privacy and about on settings pages of their own and widen the settings search
Offline sat in the middle of Backup & reset, privacy settings were spread
over Reading, Composing and Message list, and the version card took room
from the page list in the sidebar's footer. The search matched letters
inside words ("lock" found Time format and Send Checks) and knew no other
words for a setting ("offline", "autoreply", "logout" found nothing).

- Offline is a page in the top group: Keep mail on this device, what is
  kept, Remove everything from this device, and the Outbox as its own
  section. Old `/settings/backup/offline` links and the offline notice
  land there.
- Privacy holds remote images and trusted senders, read receipts, sender
  pictures from the web, the desktop app's usage info and crash reports,
  and a link to the push relay on Notifications. Reading, Composing and
  Message list keep a link row; old links to those sections land on
  Privacy.
- About is the last page and holds the version card; the footer keeps the
  Advanced switch.
- The search matches whole words and word starts (substrings only in
  Chinese, Japanese and Thai), and each row can carry synonyms in the
  user's language. A page matches by its name and keywords or when one of
  its rows has every word. Account gets a Sign out row so "logout" leads
  somewhere.
- The index test now also fails when a page draws a section the index
  doesn't know, and a new test fails on two indexed rows with the same
  name. Tests that rendered remote content and sender pictures on Reading
  and Message list render Privacy instead, since that's where the rows are.
2026-11-04 07:37:25 +01:00

99 lines
4.7 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
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 { offlineAllowed, 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';
import { KeyProtectionNotes } from './key-protection-notes';
import { WipeDeviceSection } from './wipe-device-section';
/**
* Settings > Offline (#523), a page of its own: 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; in a section
* below it the outbox and whatever didn't go through. Not in Lite.
*/
export function OfflineSettings() {
const t = useTranslations('offline.settings');
const tOutbox = useTranslations('offline.outbox');
const tStorage = useTranslations('offlineStorage');
const active = useAuthStore((s) => s.activeAccountId);
useAuthStore((s) => s.connectedAccountsRevision);
// Re-renders when the organisation's device settings change.
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 [asking, setAsking] = useState(false);
useEffect(() => {
if (IS_LITE || enabledCount === 0) return;
void readPersistence();
void refreshUsage();
}, [enabledCount]);
if (IS_LITE) return null;
const accounts = [...new Set([...(active ? [active] : []), ...signedInAccountIds()])];
const several = accounts.length > 1;
const askAgain = async () => {
setAsking(true);
await requestPersistence();
setAsking(false);
};
return (
<div className="space-y-8">
<SettingsSection id="offline" title={t('title')} description={t('description')}>
{blocked ? (
<p className="text-sm text-muted-foreground" data-testid="offline-private">{tNotes('store_private')}</p>
) : !offlineSupported() ? (
<p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p>
) : (
<>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
</div>
<KeyProtectionNotes browser />
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
<Button variant="outline" size="sm" className="shrink-0" disabled={asking} onClick={() => void askAgain()} data-testid="offline-persist-retry">{tStorage('persist_retry')}</Button>
</div>
)}
{enabledCount > 0 && safariMayDelete() && (
<p className="rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="offline-safari-hint">{tStorage('safari_hint')}</p>
)}
{accounts.map((account, index) => (
<AccountStorage
key={account}
account={account}
primary={index === 0}
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
/>
))}
{[active, ...accounts].some((account) => offlineAllowed(account)) && <WipeDeviceSection />}
</>
)}
</SettingsSection>
<SettingsSection id="outbox" title={tOutbox('title')}>
<OutboxList heading={false} />
</SettingsSection>
</div>
);
}