feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset

This commit is contained in:
Linus Rath
2026-08-29 18:04:21 +02:00
parent a071d0581c
commit 8deff66274
5 changed files with 705 additions and 144 deletions
+242 -104
View File
@@ -1,69 +1,44 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import * as DocumentPicker from 'expo-document-picker';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
import { clearCachedData } from '../../lib/clear-cached-data';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const DAY_OPTIONS = [
{ value: '1', label: 'Last 24 hours' },
{ value: '3', label: 'Last 3 days' },
{ value: '7', label: 'Last 7 days' },
{ value: '14', label: 'Last 14 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
];
const MAX_MB_OPTIONS = [
{ value: '25', label: '25 MB' },
{ value: '50', label: '50 MB' },
{ value: '100', label: '100 MB' },
{ value: '250', label: '250 MB' },
{ value: '500', label: '500 MB' },
];
function formatRelativeTime(ms: number | undefined): string {
if (!ms) return 'never';
const diff = Date.now() - ms;
if (diff < 60_000) return 'just now';
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
}
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
{ id: 'sync', label: 'Sync', description: 'Background sync operations.' },
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
const APP_STORE_URL = 'https://github.com/bulwarkmail/native/releases';
export function AboutDataSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
const exportSettings = useSettingsStore((s) => s.exportSettings);
const importSettings = useSettingsStore((s) => s.importSettings);
const debugMode = useSettingsStore((s) => s.debugMode);
const debugCategories = useSettingsStore((s) => s.debugCategories);
const [debugMode, setDebugMode] = useState(false);
const [debugCategories, setDebugCategories] = useState<Record<string, boolean>>(
Object.fromEntries(DEBUG_CATEGORIES.map((c) => [c.id, true]))
);
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
@@ -79,11 +54,33 @@ export function AboutDataSettings() {
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const latestRelease = useUpdatesStore((s) => s.cachedLatest);
const updateAvailable = hasUpdate();
const updateSeverity = latestRelease?.severity ?? 'normal';
useEffect(() => {
if (!hydrated) void hydrate();
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const dayOptions = [1, 3, 7, 14, 30, 90].map((d) => ({
value: String(d),
label: d === 1
? t('settings.offline.window_24h', 'Last 24 hours')
: t('settings.offline.window_days', 'Last {count} days', { count: d }),
}));
const maxMbOptions = [25, 50, 100, 250, 500].map((mb) => ({ value: String(mb), label: `${mb} MB` }));
const formatRelativeTime = (ms: number | undefined): string => {
if (!ms) return t('settings.offline.never', 'never');
const diff = Date.now() - ms;
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
if (diff < 24 * 60 * 60_000) return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
};
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it.
@@ -92,21 +89,88 @@ export function AboutDataSettings() {
const handleClearCache = () => {
Alert.alert(
'Clear offline mail',
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
t('settings.offline.clear_title', 'Clear offline mail'),
t(
'settings.offline.clear_message',
'Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?',
{ count: cacheCount, size: formatBytes(cacheBytes) },
),
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
resetExternalContentPolicy('ask');
setSenderFaviconsStore(true);
for (const email of trustedSenders) removeTrustedSender(email);
setConfirmReset(false);
Alert.alert('Settings reset', 'Saved settings have been restored to defaults.');
resetToDefaults();
Alert.alert(
t('settings.advanced.reset_settings.label', 'Reset Settings'),
t('settings.advanced.reset_settings.done', 'All settings have been restored to their defaults.'),
);
};
const handleExport = async () => {
try {
const json = exportSettings();
const file = new File(Paths.cache, `bulwark-settings-${new Date().toISOString().slice(0, 10)}.json`);
file.write(json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(file.uri, {
mimeType: 'application/json',
dialogTitle: t('settings.advanced.export_settings.label', 'Export Settings'),
});
} else {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
t('settings.advanced.export_settings.saved_to', 'Saved to {path}', { path: file.uri }),
);
}
} catch (err) {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
err instanceof Error ? err.message : String(err),
);
}
};
const handleImport = async () => {
try {
const picked = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (picked.canceled || !picked.assets?.[0]) return;
const json = await new File(picked.assets[0].uri).text();
const ok = importSettings(json);
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
ok
? t('settings.import_success', 'Settings imported successfully')
: t('settings.import_error', 'Failed to import settings'),
);
} catch (err) {
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
err instanceof Error ? err.message : t('settings.import_error', 'Failed to import settings'),
);
}
};
const handleRefreshCache = async () => {
setRefreshing(true);
try {
await clearCachedData();
} catch (err) {
Alert.alert(
t('settings.advanced.refresh_cache.label', 'Refresh cached data'),
err instanceof Error ? err.message : String(err),
);
} finally {
setRefreshing(false);
}
};
return (
@@ -117,15 +181,34 @@ export function AboutDataSettings() {
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>About</Text>
<Text style={styles.aboutTitle}>{t('settings.advanced.about.mobile_title', 'Bulwark Mobile')}</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
{updateAvailable && latestRelease && (
<Pressable
onPress={() => void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)}
accessibilityRole="link"
style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]}
>
<Text style={[styles.updatePillText, updateSeverity !== 'normal' && styles.updatePillTextSecurity]}>
{updateSeverity === 'security'
? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latestRelease.tag })
: updateSeverity === 'deprecated'
? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latestRelease.tag })
: t('settings.advanced.about.update_available', 'Update: {version}', { version: latestRelease.tag })}
{supportsSideloadUpdates
? ''
: ` · ${t('settings.advanced.about.update_store_hint', 'available in TestFlight / App Store')}`}
</Text>
</Pressable>
)}
</View>
<Pressable
style={styles.ghLink}
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
accessibilityRole="link"
onPress={() => Linking.openURL('https://github.com/bulwarkmail/native')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={c.mutedForeground} />
@@ -134,12 +217,15 @@ export function AboutDataSettings() {
</View>
<SettingsSection
title="Offline mail"
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
title={t('settings.offline.title', 'Offline mail')}
description={t(
'settings.offline.description',
'Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.',
)}
>
<SettingItem
label="Cache recent mail"
description="When on, the app keeps message bodies for the selected window on this device."
label={t('settings.offline.enabled', 'Cache recent mail')}
description={t('settings.offline.enabled_desc', 'When on, the app keeps message bodies for the selected window on this device.')}
>
<ToggleSwitch
checked={offlineEnabled}
@@ -147,23 +233,23 @@ export function AboutDataSettings() {
/>
</SettingItem>
<SettingItem
label="Window"
description="How far back to cache, measured by message receipt date."
label={t('settings.offline.window', 'Window')}
description={t('settings.offline.window_desc', 'How far back to cache, measured by message receipt date.')}
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={DAY_OPTIONS}
options={dayOptions}
/>
</SettingItem>
<SettingItem
label="Maximum size"
description="Oldest messages are removed when the cache grows past this."
label={t('settings.offline.max_size', 'Maximum size')}
description={t('settings.offline.max_size_desc', 'Oldest messages are removed when the cache grows past this.')}
>
<Select
value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={MAX_MB_OPTIONS}
options={maxMbOptions}
/>
</SettingItem>
@@ -172,26 +258,26 @@ export function AboutDataSettings() {
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? 'Nothing cached yet'
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
? t('settings.offline.nothing_cached', 'Nothing cached yet')
: `${t('settings.offline.cached_count', '{count, plural, one {# message} other {# messages}}', { count: cacheCount })} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? `Downloading ${sync.completed}/${sync.total}…`
? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total })
: sync.phase === 'scanning'
? 'Scanning recent mail…'
? t('settings.offline.scanning', 'Scanning recent mail…')
: sync.phase === 'error'
? `Last sync failed: ${sync.message ?? 'unknown error'}`
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? 'unknown error' })
: t('settings.offline.last_sync', 'Last sync {when}', { when: formatRelativeTime(sync.finishedAt) })}
</Text>
{queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}>
{`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`}
{t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })}
</Text>
)}
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}>
<View style={styles.progressTrack} accessibilityRole="progressbar">
<View
style={[
styles.progressFill,
@@ -208,7 +294,7 @@ export function AboutDataSettings() {
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? 'Syncing…' : 'Sync now'}
{syncBusy ? t('settings.offline.syncing', 'Syncing…') : t('settings.offline.sync_now', 'Sync now')}
</Button>
<Button
variant="outline"
@@ -216,61 +302,102 @@ export function AboutDataSettings() {
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
Clear cache
{t('settings.offline.clear_cache', 'Clear cache')}
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
<SettingsSection
title={t('settings.advanced.title', 'Advanced')}
description={t('settings.advanced.description', 'Advanced options and developer settings')}
>
<SettingItem
label={t('settings.advanced.debug_mode.label', 'Debug Mode')}
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
>
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
Which categories to include in debug output.
{t(
'settings.advanced.debug_categories.description',
"Select which categories to log. Disable categories you don't need to reduce console noise.",
)}
</Text>
{DEBUG_CATEGORIES.map((cat) => (
<SettingItem key={cat.id} label={cat.label} description={cat.description}>
{ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => (
<SettingItem
key={cat}
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
>
<ToggleSwitch
checked={debugCategories[cat.id] !== false}
onChange={(v) => setDebugCategories({ ...debugCategories, [cat.id]: v })}
checked={debugCategories[cat] !== false}
onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem label="Settings sync" description="Sync settings across your signed-in devices.">
<ToggleSwitch checked={syncEnabled} onChange={setSyncEnabled} />
</SettingItem>
<SettingItem
label="Sender favicons"
description="Fetch website favicons for sender avatars (experimental)."
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem label="Reset settings" description="Restore saved preferences (external content, trusted senders, favicons) to defaults.">
<Button
variant={confirmReset ? 'destructive' : 'outline'}
size="sm"
onPress={() => {
if (confirmReset) {
handleReset();
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 5000);
}
}}
>
{confirmReset ? 'Confirm reset' : 'Reset'}
<SettingItem
label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
description={t(
'settings.advanced.refresh_cache.description',
'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
)}
>
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
{t('settings.advanced.refresh_cache.button', 'Refresh')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.export_settings.label', 'Export Settings')}
description={t('settings.advanced.export_settings.description', 'Download your settings as JSON')}
>
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
{t('settings.advanced.export_settings.button', 'Export')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.import_settings.label', 'Import Settings')}
description={t('settings.advanced.import_settings.description', 'Upload settings from JSON file')}
>
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
{t('settings.advanced.import_settings.button', 'Import')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
description={t('settings.advanced.reset_settings.description', 'Restore all settings to default values')}
>
<Button variant="outline" size="sm" onPress={() => setConfirmReset(true)}>
{t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
</Button>
</SettingItem>
</SettingsSection>
<Dialog
visible={confirmReset}
variant="destructive"
title={t('settings.advanced.reset_settings.label', 'Reset Settings')}
message={t('settings.reset_confirm', 'Are you sure you want to reset all settings to defaults?')}
confirmText={t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
onCancel={() => setConfirmReset(false)}
onConfirm={handleReset}
/>
</View>
);
}
@@ -302,6 +429,17 @@ function makeStyles(c: ThemePalette) {
aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
updatePill: {
alignSelf: 'flex-start',
marginTop: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
updatePillSecurity: { backgroundColor: c.errorBg },
updatePillText: { ...typography.caption, color: c.primary },
updatePillTextSecurity: { color: c.error },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
@@ -309,10 +447,10 @@ function makeStyles(c: ThemePalette) {
},
ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: {
marginLeft: spacing.lg,
paddingLeft: spacing.lg,
borderLeftWidth: 2,
borderLeftColor: c.muted,
marginStart: spacing.lg,
paddingStart: spacing.lg,
borderStartWidth: 2,
borderStartColor: c.muted,
gap: 4,
},
debugCategoriesHint: {
+43
View File
@@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useCalendarStore } from '../stores/calendar-store';
// Persisted, server-derived caches (Zustand persist names). The "Refresh
// cached data" action drops these so a stale or wrong-account view can be
// fixed WITHOUT signing out (which would drop the whole account list).
// Mirrors the webmail's lib/clear-cached-data.ts.
//
// Deliberately excluded: 'account-registry' (accounts), 'webmail:settings:v1'
// / 'webmail:locale:v1' (preferences), 'webmail:templates:v1' /
// 'webmail:keywords:v1' / 'calendar-subscriptions' (user-created content),
// the offline body cache (managed on its own pane) and push registrations.
export const CACHE_STORAGE_KEYS = [
'email-cache',
'contacts-cache',
'calendar-cache',
'webmail:calendar:hidden:v1',
'webmail:contacts:category:v1',
];
/**
* Clear cached, server-derived data (mail list snapshots, contacts,
* calendars) and re-fetch everything for the active account. Accounts and
* sessions are preserved.
*/
export async function clearCachedData(): Promise<void> {
try {
await AsyncStorage.multiRemove(CACHE_STORAGE_KEYS);
} catch {
// storage errors are non-fatal - the re-fetch below still refreshes state
}
const results = await Promise.allSettled([
useEmailStore.getState().fetchMailboxes().then(() => useEmailStore.getState().refreshEmails()),
useContactsStore.getState().refresh(),
useCalendarStore.getState().refresh(),
]);
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
if (failure) {
throw failure.reason instanceof Error ? failure.reason : new Error(String(failure.reason));
}
}
+55
View File
@@ -0,0 +1,55 @@
import { useSettingsStore, type DebugCategory } from '../stores/settings-store';
// Mirrors the webmail's lib/debug.ts: conditional logging gated on the
// persisted `debugMode` setting and its per-category switches (Settings →
// About & Data → Debug mode). `debug.error` always logs.
function isEnabled(category?: DebugCategory): boolean {
const state = useSettingsStore.getState();
if (!state.debugMode) return false;
if (!category) return true;
return state.debugCategories?.[category] !== false;
}
const CATEGORY_KEYS = new Set<string>([
'jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts',
]);
function isCategoryKey(value: unknown): value is DebugCategory {
return typeof value === 'string' && CATEGORY_KEYS.has(value);
}
/**
* Usage:
* debug.log('push', 'relay registered', id); // only when the push category is on
* debug.log('uncategorised message'); // whenever debugMode is on
*/
export const debug = {
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]): void => {
if (isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
} else if (isEnabled()) {
console.log('[DEBUG]', categoryOrMsg, ...args);
}
},
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]): void => {
if (isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
} else if (isEnabled()) {
console.warn('[DEBUG]', categoryOrMsg, ...args);
}
},
error: (...args: unknown[]): void => {
console.error('[ERROR]', ...args);
},
time: (label: string, category?: DebugCategory): void => {
if (isEnabled(category)) console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
},
timeEnd: (label: string, category?: DebugCategory): void => {
if (isEnabled(category)) console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
},
};
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../api/identity', () => ({ getIdentities: vi.fn(async () => []) }));
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
useSettingsStore,
mergeWithDefaults,
toExportShape,
fromExportShape,
} from '../settings-store';
describe('settings-store', () => {
beforeEach(async () => {
await AsyncStorage.clear();
useSettingsStore.getState().resetToDefaults();
});
describe('defaults', () => {
it('match the webmail where behaviour is identical', () => {
const s = useSettingsStore.getState();
expect(s.includeGroupInUnified).toBe(true);
expect(s.autoSelectReplyIdentity).toBe(false);
expect(s.showBirthdayCalendar).toBe(false);
expect(s.attachmentReminderKeywords).toContain('anhang');
expect(s.attachmentReminderKeywords).toContain('添付');
});
});
describe('mergeWithDefaults', () => {
it('rejects values outside the allowed set', () => {
const out = mergeWithDefaults({
density: 'x' as never,
swipeLeftAction: 'foo' as never,
sendDelaySeconds: 17,
emailsPerPage: -4,
theme: 'dark',
});
expect(out.density).toBe('regular');
expect(out.swipeLeftAction).toBe('archive');
expect(out.sendDelaySeconds).toBe(0);
expect(out.emailsPerPage).toBe(25);
expect(out.theme).toBe('dark');
});
it('keeps valid values and normalises the quick-action bar', () => {
const out = mergeWithDefaults({
sendDelaySeconds: 30,
bottomQuickActions: ['delete', 'delete', 'bogus' as never],
});
expect(out.sendDelaySeconds).toBe(30);
expect(out.bottomQuickActions).toEqual(['delete', 'reply', 'replyAll']);
});
it('fills missing debug categories from the default', () => {
const out = mergeWithDefaults({ debugCategories: { push: false } as never });
expect(out.debugCategories.push).toBe(false);
expect(out.debugCategories.jmap).toBe(true);
});
});
describe('trusted senders', () => {
it('strips a display name in angle form', () => {
const s = useSettingsStore.getState();
s.addTrustedSender('Alice Example <Alice@Example.com>');
expect(useSettingsStore.getState().trustedSenders).toEqual(['alice@example.com']);
expect(useSettingsStore.getState().isSenderTrusted('alice@example.com')).toBe(true);
expect(useSettingsStore.getState().isSenderTrusted('"Alice" <ALICE@example.com>')).toBe(true);
useSettingsStore.getState().removeTrustedSender('Alice <alice@example.com>');
expect(useSettingsStore.getState().trustedSenders).toEqual([]);
});
});
describe('export / import', () => {
it('renames keys to the webmail names and drops device-local keys', () => {
const shape = toExportShape({
...useSettingsStore.getState(),
calendarFirstDayOfWeek: 0,
emailExportTemplate: 'x',
swipeMode: 'reveal',
} as never);
expect(shape.firstDayOfWeek).toBe(0);
expect(shape.emailDownloadTemplate).toBe('x');
expect(shape).not.toHaveProperty('calendarFirstDayOfWeek');
expect(shape).not.toHaveProperty('swipeMode');
expect(shape).not.toHaveProperty('offlineCacheDays');
});
it('imports a webmail export, ignoring unknown and invalid keys', () => {
const ok = useSettingsStore.getState().importSettings(JSON.stringify({
firstDayOfWeek: 0,
density: 'compact',
sendDelaySeconds: 99,
messageListOrder: [{ property: 'receivedAt' }],
swipeMode: 'reveal',
unknownKey: 'whatever',
}));
expect(ok).toBe(true);
const s = useSettingsStore.getState();
expect(s.calendarFirstDayOfWeek).toBe(0);
expect(s.density).toBe('compact');
expect(s.sendDelaySeconds).toBe(0);
expect(s.swipeMode).toBe('instant');
});
it('round-trips through exportSettings', () => {
useSettingsStore.getState().updateSetting('fontSize', 'large');
const json = useSettingsStore.getState().exportSettings();
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState().fontSize).toBe('medium');
expect(useSettingsStore.getState().importSettings(json)).toBe(true);
expect(useSettingsStore.getState().fontSize).toBe('large');
});
it('rejects non-object JSON', () => {
expect(useSettingsStore.getState().importSettings('[1,2]')).toBe(false);
expect(useSettingsStore.getState().importSettings('not json')).toBe(false);
});
it('fromExportShape maps webmail names back', () => {
expect(fromExportShape({ expandedFilterView: true, filenameLowercase: true })).toEqual({
filtersExpandedView: true,
exportLowercase: true,
});
});
});
describe('resetToDefaults', () => {
it('restores every persisted key', () => {
const s = useSettingsStore.getState();
s.updateSetting('density', 'compact');
s.updateSetting('debugMode', true);
s.addTrustedSender('x@y.z');
useSettingsStore.getState().resetToDefaults();
const after = useSettingsStore.getState();
expect(after.density).toBe('regular');
expect(after.debugMode).toBe(false);
expect(after.trustedSenders).toEqual([]);
});
});
});
+224 -40
View File
@@ -8,7 +8,6 @@ export type ThemeMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type MailLayout = 'split' | 'focus';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type SwipeAction =
@@ -84,10 +83,24 @@ export type FilesFolderLayout = 'inline' | 'sidebar';
export type FilesViewMode = 'list' | 'grid';
export type FilesSortKey = 'name' | 'size' | 'modified';
export type FilesSortDir = 'asc' | 'desc';
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
// Filename transform for downloads/exports (mirrors webmail SpaceReplacement).
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
// Debug log categories (mirrors the webmail's DebugCategory). Used by lib/debug.ts.
export type DebugCategory =
| 'jmap'
| 'calendar'
| 'tasks'
| 'auth'
| 'filters'
| 'email'
| 'push'
| 'contacts';
export const ALL_DEBUG_CATEGORIES: DebugCategory[] = [
'jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts',
];
const STORAGE_KEY = 'webmail:settings:v1';
export interface SidebarApp {
@@ -103,7 +116,9 @@ interface PersistedSettings {
// Privacy & content
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
trustedSendersAddressBook: boolean;
// null = not decided yet; flips to true automatically once the account
// proves it supports JMAP contacts (webmail parity), false = user opted out.
trustedSendersAddressBook: boolean | null;
senderFavicons: boolean;
hideInlineImageAttachments: boolean;
@@ -141,7 +156,6 @@ interface PersistedSettings {
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean;
showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number;
// Mail list sort order: oldest-first when true. Applies to every mailbox
// (the JMAP Email/query sorts by receivedAt).
@@ -190,28 +204,23 @@ interface PersistedSettings {
filesShowThumbnails: boolean;
filesShowHiddenFiles: boolean;
// Notifications
notificationSoundChoice: NotificationSound;
// Notifications. Sound/vibration live in the Android notification channel
// (the OS owns them after channel creation), so there are no sound keys.
emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean;
// Sidebar apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
// S/MIME defaults (key/cert lists are server-managed; only UI-level prefs persist)
smimeDefaultEncrypt: boolean;
smimeRememberUnlocked: boolean;
smimeAutoImport: boolean;
// Filters UI state
filtersExpandedView: boolean;
// Plugins (UI-level enable map)
pluginEnabled: Record<string, boolean>;
// Debug logging (see lib/debug.ts). Persisted like the webmail so a support
// session survives restarts.
debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>;
// Downloads / export filenames: templates and a filename transform applied
// when exporting a message as .eml or saving an attachment.
@@ -235,11 +244,11 @@ interface PersistedSettings {
const DEFAULT_PERSISTED: PersistedSettings = {
dateFormat: 'smart',
timeFormat: '24h',
includeGroupInUnified: false,
includeGroupInUnified: true,
externalContentPolicy: 'ask',
trustedSenders: [],
trustedSendersAddressBook: false,
trustedSendersAddressBook: null,
senderFavicons: true,
hideInlineImageAttachments: true,
@@ -253,9 +262,38 @@ const DEFAULT_PERSISTED: PersistedSettings = {
emailAlwaysLightMode: false,
activeThemeId: null,
autoSelectReplyIdentity: true,
autoSelectReplyIdentity: false,
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
// Same multilingual list as the webmail so a synced/imported settings blob
// does not flip the reminder behaviour between clients.
attachmentReminderKeywords: [
// English
'attached', 'attachment', 'attachments', 'see attached', 'find attached', 'please find attached',
// German
'angehängt', 'anhang', 'anbei', 'im anhang',
// French
'ci-joint', 'pièce jointe',
// Spanish
'adjunto', 'adjunta', 'en adjunto',
// Italian
'allegato', 'in allegato',
// Dutch
'bijgevoegd', 'bijlage',
// Portuguese
'em anexo', 'anexo',
// Polish
'w załączniku',
// Russian
'во вложении',
// Japanese
'添付',
// Chinese
'附件',
// Korean
'첨부',
// Latvian
'pielikumā',
],
plainTextMode: false,
sendDelaySeconds: 0,
@@ -263,7 +301,6 @@ const DEFAULT_PERSISTED: PersistedSettings = {
deleteAction: 'trash',
permanentlyDeleteJunk: false,
showPreview: true,
mailLayout: 'split',
emailsPerPage: 25,
mailSortAscending: false,
disableThreading: false,
@@ -285,7 +322,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
calendarShowWeekNumbers: false,
calendarHoverPreview: 'delay-500ms',
calendarTimeZone: 'auto',
showBirthdayCalendar: true,
showBirthdayCalendar: false,
enableCalendarTasks: false,
showTasksOnCalendar: true,
sharedCalendarColors: {},
@@ -299,23 +336,26 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filesShowThumbnails: true,
filesShowHiddenFiles: false,
notificationSoundChoice: 'default',
emailNotificationsEnabled: true,
emailNotificationSound: true,
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
calendarInvitationParsingEnabled: true,
sidebarApps: [],
keepAppsLoaded: false,
smimeDefaultEncrypt: false,
smimeRememberUnlocked: false,
smimeAutoImport: true,
filtersExpandedView: false,
pluginEnabled: {},
debugMode: false,
debugCategories: {
jmap: true,
calendar: true,
tasks: true,
auth: true,
filters: true,
email: true,
push: true,
contacts: true,
},
emailExportTemplate: '{date} ({from}-{to}) {subject}',
attachmentExportTemplate: '{filename}',
@@ -376,8 +416,14 @@ export interface SettingsState extends PersistedSettings {
removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void;
// Plugins
setPluginEnabled: (id: string, enabled: boolean) => void;
// Restore every persisted key to its default (keeps identities/session state).
resetToDefaults: () => void;
// JSON blob in the webmail's export shape (lib/settings export) so a file
// round-trips between the two clients. See SETTINGS_KEY_MAP.
exportSettings: () => string;
// Returns false when the JSON is not a settings object. Unknown keys and
// invalid values are ignored; device-local keys are never imported.
importSettings: (json: string) => boolean;
reset: () => void;
}
@@ -400,17 +446,70 @@ function persist(state: PersistedSettings): void {
});
}
function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
const oneOf = (values: readonly unknown[]) => (v: unknown) => values.includes(v);
const intBetween = (min: number, max: number) => (v: unknown) =>
typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
const stringArray = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === 'string');
export const SWIPE_ACTIONS: SwipeAction[] = ['none', 'archive', 'delete', 'spam', 'read', 'star', 'pin', 'move'];
// Per-key validators applied on hydrate and import. A value that fails falls
// back to the default rather than flowing into the UI (a corrupt
// `density: "x"` used to break every row-height lookup).
const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolean>> = {
externalContentPolicy: oneOf(['allow', 'block', 'ask']),
trustedSenders: stringArray,
dateFormat: oneOf(['smart', 'relative', 'full']),
timeFormat: oneOf(['12h', '24h']),
theme: oneOf(['light', 'dark', 'system']),
fontSize: oneOf(['small', 'medium', 'large']),
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
attachmentReminderKeywords: stringArray,
// Same set the webmail accepts (stores/settings-store.ts importSettings).
sendDelaySeconds: oneOf([0, 10, 30, 60]),
markAsReadDelay: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -1,
deleteAction: oneOf(['trash', 'trash-and-read', 'permanent']),
emailsPerPage: intBetween(1, 500),
mailAttachmentAction: oneOf(['preview', 'download']),
attachmentPosition: oneOf(['beside-sender', 'below-header']),
swipeLeftAction: oneOf(SWIPE_ACTIONS),
swipeRightAction: oneOf(SWIPE_ACTIONS),
swipeMode: oneOf(['instant', 'reveal']),
archiveMode: oneOf(['single', 'year', 'month']),
calendarDefaultView: oneOf(['month', 'week', 'day', 'agenda']),
calendarFirstDayOfWeek: oneOf([0, 1]),
calendarTimeFormat: oneOf(['12h', '24h']),
calendarHoverPreview: oneOf(['instant', 'delay-500ms', 'delay-1s', 'delay-2s', 'off']),
filesFolderLayout: oneOf(['inline', 'sidebar']),
filesDefaultViewMode: oneOf(['list', 'grid']),
filesDefaultSortKey: oneOf(['name', 'size', 'modified']),
filesDefaultSortDir: oneOf(['asc', 'desc']),
exportSpaceReplacement: oneOf(['keep', 'underscore', 'dash']),
offlineCacheDays: intBetween(1, 3650),
offlineCacheMaxMB: intBetween(1, 100000),
sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
a && typeof a === 'object'
&& typeof (a as SidebarApp).id === 'string'
&& typeof (a as SidebarApp).name === 'string'
&& typeof (a as SidebarApp).url === 'string'),
};
export function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
for (const k of PERSIST_KEYS) {
const v = parsed[k];
if (v === undefined || v === null) continue;
const def = DEFAULT_PERSISTED[k];
// Type-tolerant merge: only adopt when the basic shape matches the default.
if (Array.isArray(def)) {
// Type-tolerant merge: only adopt when the basic shape matches the default
// and the per-key validator (when there is one) accepts the value.
const validator = VALIDATORS[k];
if (validator && !validator(v)) continue;
if (k === 'bottomQuickActions') {
out[k] = normalizeBottomQuickActions(v);
} else if (Array.isArray(def)) {
if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') {
if (typeof v === 'object' && !Array.isArray(v)) out[k] = v;
if (typeof v === 'object' && !Array.isArray(v)) out[k] = { ...(def as object), ...(v as object) };
} else if (typeof def === typeof v) {
out[k] = v;
}
@@ -418,6 +517,72 @@ function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSetting
return out as unknown as PersistedSettings;
}
// RN key → webmail key for the keys whose names differ. Everything else is
// exported under its own name. Device-local keys (DEVICE_LOCAL_KEYS) are
// never exported or imported. This is also the mapping a future settings
// sync would use (see docs/parity/08-settings-push-i18n-ui.md).
export const SETTINGS_KEY_MAP: Partial<Record<keyof PersistedSettings, string>> = {
calendarFirstDayOfWeek: 'firstDayOfWeek',
calendarTimeZone: 'timeZone',
calendarShowTimeInMonth: 'showTimeInMonthView',
calendarShowWeekNumbers: 'showWeekNumbers',
emailExportTemplate: 'emailDownloadTemplate',
attachmentExportTemplate: 'attachmentDownloadTemplate',
exportSpaceReplacement: 'filenameSpaceReplacement',
exportLowercase: 'filenameLowercase',
exportStripDiacritics: 'filenameStripDiacritics',
filtersExpandedView: 'expandedFilterView',
};
// Keys that describe this device rather than the user's preferences.
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof PersistedSettings> = new Set<keyof PersistedSettings>([
'swipeMode',
'bottomQuickActions',
'offlineCacheEnabled',
'offlineCacheDays',
'offlineCacheMaxMB',
'mailSortAscending',
'filesFolderLayout',
'filesDefaultViewMode',
'filesDefaultSortKey',
'filesDefaultSortDir',
'filesShowIcons',
'filesColoredIcons',
'filesShowThumbnails',
'filesShowHiddenFiles',
'calendarDefaultView',
]);
export function toExportShape(state: PersistedSettings): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of PERSIST_KEYS) {
if (DEVICE_LOCAL_KEYS.has(k)) continue;
out[SETTINGS_KEY_MAP[k] ?? k] = state[k];
}
return out;
}
export function fromExportShape(input: Record<string, unknown>): Partial<PersistedSettings> {
const reverse = new Map<string, keyof PersistedSettings>();
for (const [rnKey, webKey] of Object.entries(SETTINGS_KEY_MAP)) {
reverse.set(webKey as string, rnKey as keyof PersistedSettings);
}
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
const rnKey = reverse.get(key) ?? (PERSIST_KEYS.includes(key as keyof PersistedSettings) ? (key as keyof PersistedSettings) : null);
if (!rnKey || DEVICE_LOCAL_KEYS.has(rnKey)) continue;
out[rnKey] = value;
}
return out as Partial<PersistedSettings>;
}
function stripDisplayName(email: string): string {
// "Name <addr>" → addr, matching the webmail's trusted-sender normalisation.
const trimmed = email.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
return (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
...DEFAULT_PERSISTED,
identities: [],
@@ -473,7 +638,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();
const normalized = stripDisplayName(email);
if (!normalized) return;
const current = get().trustedSenders;
if (current.includes(normalized)) return;
@@ -482,13 +647,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
},
removeTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();
const normalized = stripDisplayName(email);
set({ trustedSenders: get().trustedSenders.filter((e) => e !== normalized) });
persist(snapshot(get()));
},
isSenderTrusted: (email) => {
const normalized = email.toLowerCase().trim();
const normalized = stripDisplayName(email);
return get().trustedSenders.includes(normalized);
},
@@ -526,11 +691,30 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
persist(snapshot(get()));
},
setPluginEnabled: (id, enabled) => {
set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } });
resetToDefaults: () => {
set({ ...DEFAULT_PERSISTED });
persist(snapshot(get()));
},
exportSettings: () => JSON.stringify(toExportShape(snapshot(get())), null, 2),
importSettings: (json) => {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
const incoming = fromExportShape(parsed as Record<string, unknown>);
// Validate against the current state so keys absent from the file keep
// their value instead of snapping back to the default.
const merged = mergeWithDefaults({ ...snapshot(get()), ...incoming });
set({ ...merged });
persist(snapshot(get()));
return true;
},
reset: () => set({
identities: [],
loading: false,