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
+241 -103
View File
@@ -1,69 +1,44 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native'; import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants'; 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 { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button'; import Button from '../Button';
import Dialog from '../Dialog';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; 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 { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store'; import { useOutboxStore } from '../../stores/outbox-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync'; 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 APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev'; const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const APP_STORE_URL = 'https://github.com/bulwarkmail/native/releases';
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.' },
];
export function AboutDataSettings() { export function AboutDataSettings() {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons); const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated); const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate); const hydrate = useSettingsStore((s) => s.hydrate);
const resetExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy); const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
const trustedSenders = useSettingsStore((s) => s.trustedSenders); const exportSettings = useSettingsStore((s) => s.exportSettings);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender); 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 [confirmReset, setConfirmReset] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled); const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays); const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
@@ -79,11 +54,33 @@ export function AboutDataSettings() {
const clearAllCache = useOfflineCacheStore((s) => s.clearAll); const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching'; 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(() => { useEffect(() => {
if (!hydrated) void hydrate(); if (!hydrated) void hydrate();
if (!cacheHydrated) void cacheHydrate(); if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, 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 = () => { const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB }); void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it. // Also drain any queued offline changes while we're at it.
@@ -92,21 +89,88 @@ export function AboutDataSettings() {
const handleClearCache = () => { const handleClearCache = () => {
Alert.alert( Alert.alert(
'Clear offline mail', t('settings.offline.clear_title', 'Clear offline mail'),
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`, 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: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } }, { text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } },
], ],
); );
}; };
const handleReset = () => { const handleReset = () => {
resetExternalContentPolicy('ask');
setSenderFaviconsStore(true);
for (const email of trustedSenders) removeTrustedSender(email);
setConfirmReset(false); 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 ( return (
@@ -117,15 +181,34 @@ export function AboutDataSettings() {
<Text style={styles.logoText}>B</Text> <Text style={styles.logoText}>B</Text>
</View> </View>
<View style={{ flex: 1 }}> <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}> <Text style={styles.aboutVersion}>
v{APP_VERSION}{' '} v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text> <Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</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> </View>
<Pressable <Pressable
style={styles.ghLink} 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> <Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={c.mutedForeground} /> <ExternalLink size={12} color={c.mutedForeground} />
@@ -134,12 +217,15 @@ export function AboutDataSettings() {
</View> </View>
<SettingsSection <SettingsSection
title="Offline mail" title={t('settings.offline.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." 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 <SettingItem
label="Cache recent mail" label={t('settings.offline.enabled', 'Cache recent mail')}
description="When on, the app keeps message bodies for the selected window on this device." description={t('settings.offline.enabled_desc', 'When on, the app keeps message bodies for the selected window on this device.')}
> >
<ToggleSwitch <ToggleSwitch
checked={offlineEnabled} checked={offlineEnabled}
@@ -147,23 +233,23 @@ export function AboutDataSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Window" label={t('settings.offline.window', 'Window')}
description="How far back to cache, measured by message receipt date." description={t('settings.offline.window_desc', 'How far back to cache, measured by message receipt date.')}
> >
<Select <Select
value={String(offlineDays)} value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))} onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={DAY_OPTIONS} options={dayOptions}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Maximum size" label={t('settings.offline.max_size', 'Maximum size')}
description="Oldest messages are removed when the cache grows past this." description={t('settings.offline.max_size_desc', 'Oldest messages are removed when the cache grows past this.')}
> >
<Select <Select
value={String(offlineMaxMB)} value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))} onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={MAX_MB_OPTIONS} options={maxMbOptions}
/> />
</SettingItem> </SettingItem>
@@ -172,26 +258,26 @@ export function AboutDataSettings() {
<CloudDownload size={16} color={c.textSecondary} /> <CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}> <Text style={styles.cacheStatsTitle}>
{cacheCount === 0 {cacheCount === 0
? 'Nothing cached yet' ? t('settings.offline.nothing_cached', 'Nothing cached yet')
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`} : `${t('settings.offline.cached_count', '{count, plural, one {# message} other {# messages}}', { count: cacheCount })} • ${formatBytes(cacheBytes)}`}
</Text> </Text>
</View> </View>
<Text style={styles.cacheStatsSub}> <Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching' {sync.phase === 'fetching'
? `Downloading ${sync.completed}/${sync.total}…` ? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total })
: sync.phase === 'scanning' : sync.phase === 'scanning'
? 'Scanning recent mail…' ? t('settings.offline.scanning', 'Scanning recent mail…')
: sync.phase === 'error' : sync.phase === 'error'
? `Last sync failed: ${sync.message ?? 'unknown error'}` ? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? 'unknown error' })
: `Last sync ${formatRelativeTime(sync.finishedAt)}`} : t('settings.offline.last_sync', 'Last sync {when}', { when: formatRelativeTime(sync.finishedAt) })}
</Text> </Text>
{queuedChanges > 0 && ( {queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}> <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> </Text>
)} )}
{syncBusy && sync.total > 0 && ( {syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}> <View style={styles.progressTrack} accessibilityRole="progressbar">
<View <View
style={[ style={[
styles.progressFill, styles.progressFill,
@@ -208,7 +294,7 @@ export function AboutDataSettings() {
disabled={!offlineEnabled || syncBusy} disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined} 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>
<Button <Button
variant="outline" variant="outline"
@@ -216,61 +302,102 @@ export function AboutDataSettings() {
onPress={handleClearCache} onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy} disabled={cacheCount === 0 || syncBusy}
> >
Clear cache {t('settings.offline.clear_cache', 'Clear cache')}
</Button> </Button>
</View> </View>
</View> </View>
</SettingsSection> </SettingsSection>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting."> <SettingsSection
<SettingItem label="Debug mode" description="Enable verbose logging for support and development."> title={t('settings.advanced.title', 'Advanced')}
<ToggleSwitch checked={debugMode} onChange={setDebugMode} /> 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> </SettingItem>
{debugMode && ( {debugMode && (
<View style={styles.debugCategoriesBox}> <View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}> <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> </Text>
{DEBUG_CATEGORIES.map((cat) => ( {ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => (
<SettingItem key={cat.id} label={cat.label} description={cat.description}> <SettingItem
key={cat}
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
>
<ToggleSwitch <ToggleSwitch
checked={debugCategories[cat.id] !== false} checked={debugCategories[cat] !== false}
onChange={(v) => setDebugCategories({ ...debugCategories, [cat.id]: v })} onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
/> />
</SettingItem> </SettingItem>
))} ))}
</View> </View>
)} )}
<SettingItem label="Settings sync" description="Sync settings across your signed-in devices.">
<ToggleSwitch checked={syncEnabled} onChange={setSyncEnabled} />
</SettingItem>
<SettingItem <SettingItem
label="Sender favicons" label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description="Fetch website favicons for sender avatars (experimental)." description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
> >
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} /> <ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem> </SettingItem>
<SettingItem label="Reset settings" description="Restore saved preferences (external content, trusted senders, favicons) to defaults."> <SettingItem
<Button label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
variant={confirmReset ? 'destructive' : 'outline'} description={t(
size="sm" 'settings.advanced.refresh_cache.description',
onPress={() => { 'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
if (confirmReset) { )}
handleReset();
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 5000);
}
}}
> >
{confirmReset ? 'Confirm reset' : 'Reset'} <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> </Button>
</SettingItem> </SettingItem>
</SettingsSection> </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> </View>
); );
} }
@@ -302,6 +429,17 @@ function makeStyles(c: ThemePalette) {
aboutTitle: { ...typography.bodyMedium, color: c.text }, aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 }, 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: { ghLink: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -309,10 +447,10 @@ function makeStyles(c: ThemePalette) {
}, },
ghText: { ...typography.caption, color: c.mutedForeground }, ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: { debugCategoriesBox: {
marginLeft: spacing.lg, marginStart: spacing.lg,
paddingLeft: spacing.lg, paddingStart: spacing.lg,
borderLeftWidth: 2, borderStartWidth: 2,
borderLeftColor: c.muted, borderStartColor: c.muted,
gap: 4, gap: 4,
}, },
debugCategoriesHint: { 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 FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type MailLayout = 'split' | 'focus';
export type MailAttachmentAction = 'preview' | 'download'; export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header'; export type AttachmentPosition = 'beside-sender' | 'below-header';
export type SwipeAction = export type SwipeAction =
@@ -84,10 +83,24 @@ export type FilesFolderLayout = 'inline' | 'sidebar';
export type FilesViewMode = 'list' | 'grid'; export type FilesViewMode = 'list' | 'grid';
export type FilesSortKey = 'name' | 'size' | 'modified'; export type FilesSortKey = 'name' | 'size' | 'modified';
export type FilesSortDir = 'asc' | 'desc'; export type FilesSortDir = 'asc' | 'desc';
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
// Filename transform for downloads/exports (mirrors webmail SpaceReplacement). // Filename transform for downloads/exports (mirrors webmail SpaceReplacement).
export type SpaceReplacement = 'keep' | 'underscore' | 'dash'; 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'; const STORAGE_KEY = 'webmail:settings:v1';
export interface SidebarApp { export interface SidebarApp {
@@ -103,7 +116,9 @@ interface PersistedSettings {
// Privacy & content // Privacy & content
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[]; 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; senderFavicons: boolean;
hideInlineImageAttachments: boolean; hideInlineImageAttachments: boolean;
@@ -141,7 +156,6 @@ interface PersistedSettings {
deleteAction: DeleteAction; deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; permanentlyDeleteJunk: boolean;
showPreview: boolean; showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number; emailsPerPage: number;
// Mail list sort order: oldest-first when true. Applies to every mailbox // Mail list sort order: oldest-first when true. Applies to every mailbox
// (the JMAP Email/query sorts by receivedAt). // (the JMAP Email/query sorts by receivedAt).
@@ -190,28 +204,23 @@ interface PersistedSettings {
filesShowThumbnails: boolean; filesShowThumbnails: boolean;
filesShowHiddenFiles: boolean; filesShowHiddenFiles: boolean;
// Notifications // Notifications. Sound/vibration live in the Android notification channel
notificationSoundChoice: NotificationSound; // (the OS owns them after channel creation), so there are no sound keys.
emailNotificationsEnabled: boolean; emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean; calendarInvitationParsingEnabled: boolean;
// Sidebar apps // Sidebar apps
sidebarApps: SidebarApp[]; sidebarApps: SidebarApp[];
keepAppsLoaded: boolean; 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 // Filters UI state
filtersExpandedView: boolean; filtersExpandedView: boolean;
// Plugins (UI-level enable map) // Debug logging (see lib/debug.ts). Persisted like the webmail so a support
pluginEnabled: Record<string, boolean>; // session survives restarts.
debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>;
// Downloads / export filenames: templates and a filename transform applied // Downloads / export filenames: templates and a filename transform applied
// when exporting a message as .eml or saving an attachment. // when exporting a message as .eml or saving an attachment.
@@ -235,11 +244,11 @@ interface PersistedSettings {
const DEFAULT_PERSISTED: PersistedSettings = { const DEFAULT_PERSISTED: PersistedSettings = {
dateFormat: 'smart', dateFormat: 'smart',
timeFormat: '24h', timeFormat: '24h',
includeGroupInUnified: false, includeGroupInUnified: true,
externalContentPolicy: 'ask', externalContentPolicy: 'ask',
trustedSenders: [], trustedSenders: [],
trustedSendersAddressBook: false, trustedSendersAddressBook: null,
senderFavicons: true, senderFavicons: true,
hideInlineImageAttachments: true, hideInlineImageAttachments: true,
@@ -253,9 +262,38 @@ const DEFAULT_PERSISTED: PersistedSettings = {
emailAlwaysLightMode: false, emailAlwaysLightMode: false,
activeThemeId: null, activeThemeId: null,
autoSelectReplyIdentity: true, autoSelectReplyIdentity: false,
attachmentReminderEnabled: true, 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, plainTextMode: false,
sendDelaySeconds: 0, sendDelaySeconds: 0,
@@ -263,7 +301,6 @@ const DEFAULT_PERSISTED: PersistedSettings = {
deleteAction: 'trash', deleteAction: 'trash',
permanentlyDeleteJunk: false, permanentlyDeleteJunk: false,
showPreview: true, showPreview: true,
mailLayout: 'split',
emailsPerPage: 25, emailsPerPage: 25,
mailSortAscending: false, mailSortAscending: false,
disableThreading: false, disableThreading: false,
@@ -285,7 +322,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
calendarShowWeekNumbers: false, calendarShowWeekNumbers: false,
calendarHoverPreview: 'delay-500ms', calendarHoverPreview: 'delay-500ms',
calendarTimeZone: 'auto', calendarTimeZone: 'auto',
showBirthdayCalendar: true, showBirthdayCalendar: false,
enableCalendarTasks: false, enableCalendarTasks: false,
showTasksOnCalendar: true, showTasksOnCalendar: true,
sharedCalendarColors: {}, sharedCalendarColors: {},
@@ -299,23 +336,26 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filesShowThumbnails: true, filesShowThumbnails: true,
filesShowHiddenFiles: false, filesShowHiddenFiles: false,
notificationSoundChoice: 'default',
emailNotificationsEnabled: true, emailNotificationsEnabled: true,
emailNotificationSound: true,
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true,
calendarInvitationParsingEnabled: true, calendarInvitationParsingEnabled: true,
sidebarApps: [], sidebarApps: [],
keepAppsLoaded: false, keepAppsLoaded: false,
smimeDefaultEncrypt: false,
smimeRememberUnlocked: false,
smimeAutoImport: true,
filtersExpandedView: false, 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}', emailExportTemplate: '{date} ({from}-{to}) {subject}',
attachmentExportTemplate: '{filename}', attachmentExportTemplate: '{filename}',
@@ -376,8 +416,14 @@ export interface SettingsState extends PersistedSettings {
removeSidebarApp: (id: string) => void; removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void; reorderSidebarApps: (apps: SidebarApp[]) => void;
// Plugins // Restore every persisted key to its default (keeps identities/session state).
setPluginEnabled: (id: string, enabled: boolean) => void; 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; 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 }; const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
for (const k of PERSIST_KEYS) { for (const k of PERSIST_KEYS) {
const v = parsed[k]; const v = parsed[k];
if (v === undefined || v === null) continue; if (v === undefined || v === null) continue;
const def = DEFAULT_PERSISTED[k]; const def = DEFAULT_PERSISTED[k];
// Type-tolerant merge: only adopt when the basic shape matches the default. // Type-tolerant merge: only adopt when the basic shape matches the default
if (Array.isArray(def)) { // 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; if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') { } 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) { } else if (typeof def === typeof v) {
out[k] = v; out[k] = v;
} }
@@ -418,6 +517,72 @@ function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSetting
return out as unknown as PersistedSettings; 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) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
...DEFAULT_PERSISTED, ...DEFAULT_PERSISTED,
identities: [], identities: [],
@@ -473,7 +638,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); }, setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => { addTrustedSender: (email) => {
const normalized = email.toLowerCase().trim(); const normalized = stripDisplayName(email);
if (!normalized) return; if (!normalized) return;
const current = get().trustedSenders; const current = get().trustedSenders;
if (current.includes(normalized)) return; if (current.includes(normalized)) return;
@@ -482,13 +647,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
}, },
removeTrustedSender: (email) => { removeTrustedSender: (email) => {
const normalized = email.toLowerCase().trim(); const normalized = stripDisplayName(email);
set({ trustedSenders: get().trustedSenders.filter((e) => e !== normalized) }); set({ trustedSenders: get().trustedSenders.filter((e) => e !== normalized) });
persist(snapshot(get())); persist(snapshot(get()));
}, },
isSenderTrusted: (email) => { isSenderTrusted: (email) => {
const normalized = email.toLowerCase().trim(); const normalized = stripDisplayName(email);
return get().trustedSenders.includes(normalized); return get().trustedSenders.includes(normalized);
}, },
@@ -526,11 +691,30 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
persist(snapshot(get())); persist(snapshot(get()));
}, },
setPluginEnabled: (id, enabled) => { resetToDefaults: () => {
set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } }); set({ ...DEFAULT_PERSISTED });
persist(snapshot(get())); 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({ reset: () => set({
identities: [], identities: [],
loading: false, loading: false,