diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index 35b48dd2..459e4319 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -1,23 +1,38 @@ -import React, { useState } from 'react'; +import React, { useEffect } from 'react'; import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section'; +import { + useSettingsStore, + type CalendarView, + type FirstDayOfWeek, + type TimeFormat, + type CalendarHoverPreview, +} from '../../stores/settings-store'; export function CalendarSettings() { - const [viewMode, setViewMode] = useState('month'); - const [firstDay, setFirstDay] = useState('1'); - const [timeFormat, setTimeFormat] = useState('24h'); - const [showTimeInMonth, setShowTimeInMonth] = useState(true); - const [showWeekNumbers, setShowWeekNumbers] = useState(false); - const [hoverPreview, setHoverPreview] = useState('delay-500ms'); - const [birthdayCal, setBirthdayCal] = useState(true); - const [tasksEnabled, setTasksEnabled] = useState(false); - const [showTasksOnCal, setShowTasksOnCal] = useState(true); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const update = useSettingsStore((s) => s.updateSetting); + + const viewMode = useSettingsStore((s) => s.calendarDefaultView); + const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek); + const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); + const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); + const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); + const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview); + const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); + const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); + const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar); + + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); return ( update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)} options={[ { value: '1', label: 'Monday' }, { value: '0', label: 'Sunday' }, @@ -41,7 +56,7 @@ export function CalendarSettings() { update('calendarTimeFormat', v as TimeFormat)} options={[ { value: '12h', label: '12-hour' }, { value: '24h', label: '24-hour' }, @@ -53,14 +68,20 @@ export function CalendarSettings() { label="Show time in month view" description="Show start times next to events in the month grid." > - + update('calendarShowTimeInMonth', v)} + /> - + update('calendarShowWeekNumbers', v)} + /> update('notificationSoundChoice', v as NotificationSound)} options={SOUNDS} /> @@ -199,22 +207,33 @@ export function NotificationSettings() { - + update('emailNotificationsEnabled', v)} /> - + update('emailNotificationSound', v)} + disabled={!emailEnabled} + /> - + update('calendarNotificationsEnabled', v)} /> - + update('calendarNotificationSound', v)} + disabled={!calEnabled} + /> - + update('calendarInvitationParsingEnabled', v)} + /> diff --git a/src/components/settings/PluginsSettings.tsx b/src/components/settings/PluginsSettings.tsx index a42b41af..c27e6bf3 100644 --- a/src/components/settings/PluginsSettings.tsx +++ b/src/components/settings/PluginsSettings.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native'; +import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; interface Plugin { id: string; @@ -33,21 +34,43 @@ function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; f export function PluginsSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const enabledMap = useSettingsStore((s) => s.pluginEnabled); + const setPluginEnabled = useSettingsStore((s) => s.setPluginEnabled); const [plugins, setPlugins] = useState([]); const [expandedId, setExpandedId] = useState(null); + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + const togglePlugin = (id: string) => { - setPlugins((ps) => ps.map((p) => { - if (p.id !== id) return p; - const enabled = !p.enabled; - return { ...p, enabled, status: enabled ? 'enabled' : 'disabled' }; - })); + const current = plugins.find((p) => p.id === id); + if (!current) return; + const next = !current.enabled; + setPluginEnabled(id, next); + setPlugins((ps) => ps.map((p) => + p.id === id ? { ...p, enabled: next, status: next ? 'enabled' : 'disabled' } : p, + )); }; const uninstall = (id: string) => { setPlugins((ps) => ps.filter((p) => p.id !== id)); }; + // When plugins are populated from a real source later, hydrate their enable + // state from the persisted map. + useEffect(() => { + if (plugins.length === 0) return; + setPlugins((ps) => ps.map((p) => { + const stored = enabledMap[p.id]; + if (stored === undefined || stored === p.enabled) return p; + return { ...p, enabled: stored, status: stored ? 'enabled' : 'disabled' }; + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabledMap, plugins.length]); + return ( makeStyles(c), [c]); @@ -72,35 +72,29 @@ function MailLayoutPreview({ value }: { value: MailLayout }) { export function ReadingSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); - const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy); - const trustedSenders = useSettingsStore((s) => s.trustedSenders); - const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); - const archiveMode = useSettingsStore((s) => s.archiveMode); - const setArchiveMode = useSettingsStore((s) => s.setArchiveMode); + const update = useSettingsStore((s) => s.updateSetting); - const [markAsReadDelay, setMarkAsReadDelay] = useState('0'); - const [deleteAction, setDeleteAction] = useState('trash'); - const [permanentlyDeleteJunk, setPermanentlyDeleteJunk] = useState(false); - const [showPreview, setShowPreview] = useState(true); - const [mailLayout, setMailLayout] = useState('split'); - const [disableThreading, setDisableThreading] = useState(false); - const [autoSelectReplyIdentity, setAutoSelectReplyIdentity] = useState(true); - const [plainTextMode, setPlainTextMode] = useState(false); - const [emailsPerPage, setEmailsPerPage] = useState('25'); - const [mailAttachmentAction, setMailAttachmentAction] = useState('preview'); - const [attachmentPosition, setAttachmentPosition] = useState('beside-sender'); - const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay); + const deleteAction = useSettingsStore((s) => s.deleteAction); + const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk); + const showPreview = useSettingsStore((s) => s.showPreview); + const mailLayout = useSettingsStore((s) => s.mailLayout); + const disableThreading = useSettingsStore((s) => s.disableThreading); + const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); + const emailsPerPage = useSettingsStore((s) => s.emailsPerPage); + const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction); + const attachmentPosition = useSettingsStore((s) => s.attachmentPosition); + const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled); + const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords); + const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments); + + const [newKeyword, setNewKeyword] = useState(''); const [reorganizing, setReorganizing] = useState(false); const [reorganizeResult, setReorganizeResult] = useState(null); - const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true); - const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState(DEFAULT_KEYWORDS); - const [newKeyword, setNewKeyword] = useState(''); - const [hideInlineImageAttachments, setHideInlineImageAttachments] = useState(true); - const [trustedSendersAddressBook, setTrustedSendersAddressBook] = useState(false); - const [showTrustedList, setShowTrustedList] = useState(false); useEffect(() => { if (!hydrated) void hydrate(); @@ -165,8 +159,8 @@ export function ReadingSettings() { setDeleteAction(v as DeleteAction)} + onChange={(v) => update('deleteAction', v as DeleteAction)} options={[ { value: 'trash', label: 'Move to Trash' }, { value: 'permanent', label: 'Permanently delete' }, @@ -199,7 +193,7 @@ export function ReadingSettings() { setMailAttachmentAction(v as AttachmentAction)} + onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)} options={[ { value: 'preview', label: 'Preview' }, { value: 'download', label: 'Download' }, @@ -340,7 +337,7 @@ export function ReadingSettings() { update('emailsPerPage', Number(v))} options={[ { value: '10', label: '10' }, { value: '25', label: '25' }, diff --git a/src/components/settings/SidebarAppsSettings.tsx b/src/components/settings/SidebarAppsSettings.tsx index 835735ae..fc57fb2b 100644 --- a/src/components/settings/SidebarAppsSettings.tsx +++ b/src/components/settings/SidebarAppsSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native'; import { Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical, @@ -7,43 +7,46 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; - -interface SidebarApp { - id: string; - name: string; - url: string; - icon: string; - openMode: 'tab' | 'inline'; - showOnMobile: boolean; -} +import { useSettingsStore, type SidebarApp } from '../../stores/settings-store'; export function SidebarAppsSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const [keepLoaded, setKeepLoaded] = useState(false); - const [apps, setApps] = useState([]); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const update = useSettingsStore((s) => s.updateSetting); + const apps = useSettingsStore((s) => s.sidebarApps); + const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded); + const addSidebarApp = useSettingsStore((s) => s.addSidebarApp); + const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp); + const removeSidebarApp = useSettingsStore((s) => s.removeSidebarApp); + const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + const addApp = (data: Omit) => { - setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]); + addSidebarApp(data); setIsAdding(false); }; const updateApp = (id: string, data: Omit) => { - setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a)); + updateSidebarApp(id, data); setEditingId(null); }; const removeApp = (id: string) => { - setApps((list) => list.filter((a) => a.id !== id)); + removeSidebarApp(id); }; return ( - + update('keepAppsLoaded', v)} /> diff --git a/src/components/settings/SmimeSettings.tsx b/src/components/settings/SmimeSettings.tsx index 9e865431..5aa507a0 100644 --- a/src/components/settings/SmimeSettings.tsx +++ b/src/components/settings/SmimeSettings.tsx @@ -1,13 +1,14 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Upload, Trash2, Eye, Lock, Unlock, Download, ShieldCheck, ShieldAlert, Users, } from 'lucide-react-native'; -import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; +import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; interface KeyRecord { id: string; @@ -34,12 +35,18 @@ const MOCK_CERTS: PublicCert[] = []; export function SmimeSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const update = useSettingsStore((s) => s.updateSetting); + const defaultEncrypt = useSettingsStore((s) => s.smimeDefaultEncrypt); + const rememberUnlocked = useSettingsStore((s) => s.smimeRememberUnlocked); + const autoImport = useSettingsStore((s) => s.smimeAutoImport); const [keys, setKeys] = useState(MOCK_KEYS); const [certs, setCerts] = useState(MOCK_CERTS); - const [bindings, setBindings] = useState>({}); - const [defaultEncrypt, setDefaultEncrypt] = useState(false); - const [rememberUnlocked, setRememberUnlocked] = useState(false); - const [autoImport, setAutoImport] = useState(true); + + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); const isExpired = (d: string) => new Date(d) < new Date(); const formatDate = (d: string) => { @@ -167,21 +174,21 @@ export function SmimeSettings() { label="Encrypt by default" description="Encrypt new messages when recipient certificates are available." > - + update('smimeDefaultEncrypt', v)} /> - + update('smimeRememberUnlocked', v)} /> - + update('smimeAutoImport', v)} /> diff --git a/src/components/settings/ThemesSettings.tsx b/src/components/settings/ThemesSettings.tsx index 596b0551..157aa8d9 100644 --- a/src/components/settings/ThemesSettings.tsx +++ b/src/components/settings/ThemesSettings.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { Upload, Check, Palette, Trash2 } from 'lucide-react-native'; +import { Upload, Check, Palette } from 'lucide-react-native'; import { SettingsSection, SettingItem } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; interface Theme { id: string | null; @@ -23,8 +24,15 @@ const BUILT_IN: Theme[] = [ export function ThemesSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const active = useSettingsStore((s) => s.activeThemeId); + const update = useSettingsStore((s) => s.updateSetting); const [themes] = useState(BUILT_IN); - const [active, setActive] = useState(null); + + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); return ( setActive(theme.id)} + onPress={() => update('activeThemeId', theme.id)} style={[styles.card, isActive && styles.cardActive]} > diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index b060d8cc..dcc77613 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -7,9 +7,10 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ThemeMode = 'light' | 'dark' | 'system'; export type FontSize = 'small' | 'medium' | 'large'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; - -const STORAGE_KEY = 'webmail:settings:v1'; - +export type DeleteAction = 'trash' | 'permanent'; +export type MailLayout = 'split' | 'focus'; +export type MailAttachmentAction = 'preview' | 'download'; +export type AttachmentPosition = 'beside-sender' | 'below-header'; export type SwipeAction = | 'none' | 'archive' @@ -21,12 +22,38 @@ export type SwipeAction = | 'move'; export type SwipeMode = 'instant' | 'reveal'; export type ArchiveMode = 'single' | 'year' | 'month'; +export type CalendarView = 'month' | 'week' | 'day' | 'agenda'; +export type FirstDayOfWeek = 0 | 1; +export type TimeFormat = '12h' | '24h'; +export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off'; +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'; + +const STORAGE_KEY = 'webmail:settings:v1'; + +export interface SidebarApp { + id: string; + name: string; + url: string; + icon: string; + openMode: 'tab' | 'inline'; + showOnMobile: boolean; +} interface PersistedSettings { + // Privacy & content externalContentPolicy: ExternalContentPolicy; trustedSenders: string[]; + trustedSendersAddressBook: boolean; senderFavicons: boolean; + hideInlineImageAttachments: boolean; + + // Contacts groupContactsByLetter: boolean; + // Appearance theme: ThemeMode; fontSize: FontSize; @@ -34,36 +61,152 @@ interface PersistedSettings { showToolbarLabels: boolean; animationsEnabled: boolean; emailAlwaysLightMode: boolean; + activeThemeId: string | null; + // Composing autoSelectReplyIdentity: boolean; attachmentReminderEnabled: boolean; attachmentReminderKeywords: string[]; + plainTextMode: boolean; + + // Reading + markAsReadDelay: number; + deleteAction: DeleteAction; + permanentlyDeleteJunk: boolean; + showPreview: boolean; + mailLayout: MailLayout; + emailsPerPage: number; + disableThreading: boolean; + mailAttachmentAction: MailAttachmentAction; + attachmentPosition: AttachmentPosition; + // Layout / list interactions swipeLeftAction: SwipeAction; swipeRightAction: SwipeAction; swipeMode: SwipeMode; + // Archive archiveMode: ArchiveMode; + + // Calendar + calendarDefaultView: CalendarView; + calendarFirstDayOfWeek: FirstDayOfWeek; + calendarTimeFormat: TimeFormat; + calendarShowTimeInMonth: boolean; + calendarShowWeekNumbers: boolean; + calendarHoverPreview: CalendarHoverPreview; + showBirthdayCalendar: boolean; + enableCalendarTasks: boolean; + showTasksOnCalendar: boolean; + + // Files + filesFolderLayout: FilesFolderLayout; + filesDefaultViewMode: FilesViewMode; + filesDefaultSortKey: FilesSortKey; + filesDefaultSortDir: FilesSortDir; + filesShowIcons: boolean; + filesColoredIcons: boolean; + filesShowThumbnails: boolean; + filesShowHiddenFiles: boolean; + + // Notifications + notificationSoundChoice: NotificationSound; + 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; } const DEFAULT_PERSISTED: PersistedSettings = { externalContentPolicy: 'ask', trustedSenders: [], + trustedSendersAddressBook: false, senderFavicons: true, + hideInlineImageAttachments: true, + groupContactsByLetter: true, + theme: 'system', fontSize: 'medium', density: 'regular', showToolbarLabels: true, animationsEnabled: true, emailAlwaysLightMode: false, + activeThemeId: null, + autoSelectReplyIdentity: true, attachmentReminderEnabled: true, attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'], + plainTextMode: false, + + markAsReadDelay: 0, + deleteAction: 'trash', + permanentlyDeleteJunk: false, + showPreview: true, + mailLayout: 'split', + emailsPerPage: 25, + disableThreading: false, + mailAttachmentAction: 'preview', + attachmentPosition: 'beside-sender', + swipeLeftAction: 'archive', swipeRightAction: 'read', swipeMode: 'instant', + archiveMode: 'single', + + calendarDefaultView: 'month', + calendarFirstDayOfWeek: 1, + calendarTimeFormat: '24h', + calendarShowTimeInMonth: true, + calendarShowWeekNumbers: false, + calendarHoverPreview: 'delay-500ms', + showBirthdayCalendar: true, + enableCalendarTasks: false, + showTasksOnCalendar: true, + + filesFolderLayout: 'inline', + filesDefaultViewMode: 'list', + filesDefaultSortKey: 'name', + filesDefaultSortDir: 'asc', + filesShowIcons: true, + filesColoredIcons: true, + 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: {}, }; export interface SettingsState extends PersistedSettings { @@ -74,6 +217,14 @@ export interface SettingsState extends PersistedSettings { fetchIdentities: () => Promise; hydrate: () => Promise; + + // Generic setter — preferred for new code. + updateSetting: ( + key: K, + value: PersistedSettings[K], + ) => void; + + // Legacy named setters — preserved so existing call sites keep working. setExternalContentPolicy: (policy: ExternalContentPolicy) => void; setSenderFavicons: (enabled: boolean) => void; setGroupContactsByLetter: (enabled: boolean) => void; @@ -90,32 +241,34 @@ export interface SettingsState extends PersistedSettings { setSwipeRightAction: (action: SwipeAction) => void; setSwipeMode: (mode: SwipeMode) => void; setArchiveMode: (mode: ArchiveMode) => void; + + // Trusted senders addTrustedSender: (email: string) => void; removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; + + // Sidebar apps + addSidebarApp: (app: Omit) => void; + updateSidebarApp: (id: string, updates: Partial>) => void; + removeSidebarApp: (id: string) => void; + reorderSidebarApps: (apps: SidebarApp[]) => void; + + // Plugins + setPluginEnabled: (id: string, enabled: boolean) => void; + reset: () => void; } +const PERSIST_KEYS: (keyof PersistedSettings)[] = Object.keys( + DEFAULT_PERSISTED, +) as (keyof PersistedSettings)[]; + function snapshot(state: SettingsState): PersistedSettings { - return { - externalContentPolicy: state.externalContentPolicy, - trustedSenders: state.trustedSenders, - senderFavicons: state.senderFavicons, - groupContactsByLetter: state.groupContactsByLetter, - theme: state.theme, - fontSize: state.fontSize, - density: state.density, - showToolbarLabels: state.showToolbarLabels, - animationsEnabled: state.animationsEnabled, - emailAlwaysLightMode: state.emailAlwaysLightMode, - autoSelectReplyIdentity: state.autoSelectReplyIdentity, - attachmentReminderEnabled: state.attachmentReminderEnabled, - attachmentReminderKeywords: state.attachmentReminderKeywords, - swipeLeftAction: state.swipeLeftAction, - swipeRightAction: state.swipeRightAction, - swipeMode: state.swipeMode, - archiveMode: state.archiveMode, - }; + const out: Record = {}; + for (const k of PERSIST_KEYS) { + out[k] = state[k]; + } + return out as unknown as PersistedSettings; } function persist(state: PersistedSettings): void { @@ -124,6 +277,24 @@ function persist(state: PersistedSettings): void { }); } +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)) { + if (Array.isArray(v)) out[k] = v; + } else if (typeof def === 'object') { + if (typeof v === 'object' && !Array.isArray(v)) out[k] = v; + } else if (typeof def === typeof v) { + out[k] = v; + } + } + return out as unknown as PersistedSettings; +} + export const useSettingsStore = create((set, get) => ({ ...DEFAULT_PERSISTED, identities: [], @@ -147,26 +318,7 @@ export const useSettingsStore = create((set, get) => ({ const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as Partial; - set({ - externalContentPolicy: parsed.externalContentPolicy ?? DEFAULT_PERSISTED.externalContentPolicy, - trustedSenders: Array.isArray(parsed.trustedSenders) ? parsed.trustedSenders : [], - senderFavicons: typeof parsed.senderFavicons === 'boolean' ? parsed.senderFavicons : DEFAULT_PERSISTED.senderFavicons, - groupContactsByLetter: typeof parsed.groupContactsByLetter === 'boolean' ? parsed.groupContactsByLetter : DEFAULT_PERSISTED.groupContactsByLetter, - theme: parsed.theme ?? DEFAULT_PERSISTED.theme, - fontSize: parsed.fontSize ?? DEFAULT_PERSISTED.fontSize, - density: parsed.density ?? DEFAULT_PERSISTED.density, - showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels, - animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled, - emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode, - autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity, - attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled, - attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords, - swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction, - swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction, - swipeMode: parsed.swipeMode ?? DEFAULT_PERSISTED.swipeMode, - archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode, - hydrated: true, - }); + set({ ...mergeWithDefaults(parsed), hydrated: true }); return; } } catch (err) { @@ -175,6 +327,11 @@ export const useSettingsStore = create((set, get) => ({ set({ hydrated: true }); }, + updateSetting: (key, value) => { + set({ [key]: value } as Partial); + persist(snapshot(get())); + }, + setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); }, setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); }, setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); }, @@ -212,6 +369,34 @@ export const useSettingsStore = create((set, get) => ({ return get().trustedSenders.includes(normalized); }, + addSidebarApp: (app) => { + const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + set({ sidebarApps: [...get().sidebarApps, { ...app, id }] }); + persist(snapshot(get())); + }, + + updateSidebarApp: (id, updates) => { + set({ + sidebarApps: get().sidebarApps.map((a) => (a.id === id ? { ...a, ...updates } : a)), + }); + persist(snapshot(get())); + }, + + removeSidebarApp: (id) => { + set({ sidebarApps: get().sidebarApps.filter((a) => a.id !== id) }); + persist(snapshot(get())); + }, + + reorderSidebarApps: (apps) => { + set({ sidebarApps: apps }); + persist(snapshot(get())); + }, + + setPluginEnabled: (id, enabled) => { + set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } }); + persist(snapshot(get())); + }, + reset: () => set({ identities: [], loading: false,