mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +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 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 {
|
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
|
||||||
setConfirmReset(true);
|
{t('settings.advanced.refresh_cache.button', 'Refresh')}
|
||||||
setTimeout(() => setConfirmReset(false), 5000);
|
</Button>
|
||||||
}
|
</SettingItem>
|
||||||
}}
|
|
||||||
>
|
<SettingItem
|
||||||
{confirmReset ? 'Confirm reset' : 'Reset'}
|
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: {
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user