mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user