From 8deff662748fb2f9717f617675aa3cc99c7056a0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:04:21 +0200 Subject: [PATCH] feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset --- src/components/settings/AboutDataSettings.tsx | 346 ++++++++++++------ src/lib/clear-cached-data.ts | 43 +++ src/lib/debug.ts | 55 +++ src/stores/__tests__/settings-store.test.ts | 141 +++++++ src/stores/settings-store.ts | 264 +++++++++++-- 5 files changed, 705 insertions(+), 144 deletions(-) create mode 100644 src/lib/clear-cached-data.ts create mode 100644 src/lib/debug.ts create mode 100644 src/stores/__tests__/settings-store.test.ts diff --git a/src/components/settings/AboutDataSettings.tsx b/src/components/settings/AboutDataSettings.tsx index 705e116d..1bbd6a71 100644 --- a/src/components/settings/AboutDataSettings.tsx +++ b/src/components/settings/AboutDataSettings.tsx @@ -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>( - 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() { B - About + {t('settings.advanced.about.mobile_title', 'Bulwark Mobile')} v{APP_VERSION}{' '} ({GIT_COMMIT}) + {updateAvailable && latestRelease && ( + void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)} + accessibilityRole="link" + style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]} + > + + {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')}`} + + + )} Linking.openURL('https://github.com/bulwarkmail/webmail')} + accessibilityRole="link" + onPress={() => Linking.openURL('https://github.com/bulwarkmail/native')} > GitHub @@ -134,12 +217,15 @@ export function AboutDataSettings() { updateSetting('offlineCacheMaxMB', Number(v))} - options={MAX_MB_OPTIONS} + options={maxMbOptions} /> @@ -172,26 +258,26 @@ export function AboutDataSettings() { {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)}`} {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) })} {queuedChanges > 0 && ( - {`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`} + {t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })} )} {syncBusy && sync.total > 0 && ( - + : undefined} > - {syncBusy ? 'Syncing…' : 'Sync now'} + {syncBusy ? t('settings.offline.syncing', 'Syncing…') : t('settings.offline.sync_now', 'Sync now')} - - - + + + updateSetting('debugMode', v)} /> {debugMode && ( - 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.", + )} - {DEBUG_CATEGORIES.map((cat) => ( - + {ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => ( + setDebugCategories({ ...debugCategories, [cat.id]: v })} + checked={debugCategories[cat] !== false} + onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })} /> ))} )} - - - - - - + + + + + + + + + + + + + + setConfirmReset(false)} + onConfirm={handleReset} + /> ); } @@ -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: { diff --git a/src/lib/clear-cached-data.ts b/src/lib/clear-cached-data.ts new file mode 100644 index 00000000..67b99de1 --- /dev/null +++ b/src/lib/clear-cached-data.ts @@ -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 { + 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)); + } +} diff --git a/src/lib/debug.ts b/src/lib/debug.ts new file mode 100644 index 00000000..06bee189 --- /dev/null +++ b/src/lib/debug.ts @@ -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([ + '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}`); + }, +}; diff --git a/src/stores/__tests__/settings-store.test.ts b/src/stores/__tests__/settings-store.test.ts new file mode 100644 index 00000000..35b77b19 --- /dev/null +++ b/src/stores/__tests__/settings-store.test.ts @@ -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 '); + expect(useSettingsStore.getState().trustedSenders).toEqual(['alice@example.com']); + expect(useSettingsStore.getState().isSenderTrusted('alice@example.com')).toBe(true); + expect(useSettingsStore.getState().isSenderTrusted('"Alice" ')).toBe(true); + useSettingsStore.getState().removeTrustedSender('Alice '); + 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([]); + }); + }); +}); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index a5f06d56..63a36a21 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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; + // Debug logging (see lib/debug.ts). Persisted like the webmail so a support + // session survives restarts. + debugMode: boolean; + debugCategories: Record; // 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 { +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 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 { const out: Record = { ...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): 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> = { + 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 = new Set([ + 'swipeMode', + 'bottomQuickActions', + 'offlineCacheEnabled', + 'offlineCacheDays', + 'offlineCacheMaxMB', + 'mailSortAscending', + 'filesFolderLayout', + 'filesDefaultViewMode', + 'filesDefaultSortKey', + 'filesDefaultSortDir', + 'filesShowIcons', + 'filesColoredIcons', + 'filesShowThumbnails', + 'filesShowHiddenFiles', + 'calendarDefaultView', +]); + +export function toExportShape(state: PersistedSettings): Record { + const out: Record = {}; + 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): Partial { + const reverse = new Map(); + for (const [rnKey, webKey] of Object.entries(SETTINGS_KEY_MAP)) { + reverse.set(webKey as string, rnKey as keyof PersistedSettings); + } + const out: Record = {}; + 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; +} + +function stripDisplayName(email: string): string { + // "Name " → 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((set, get) => ({ ...DEFAULT_PERSISTED, identities: [], @@ -473,7 +638,7 @@ export const useSettingsStore = create((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((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((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); + // 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,