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 (
diff --git a/src/components/settings/FilesSettings.tsx b/src/components/settings/FilesSettings.tsx
index fe534d65..56b5fab8 100644
--- a/src/components/settings/FilesSettings.tsx
+++ b/src/components/settings/FilesSettings.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
@@ -6,34 +6,25 @@ import {
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
-
-type FolderLayout = 'inline' | 'sidebar';
-type ViewMode = 'list' | 'grid';
-type SortKey = 'name' | 'size' | 'modified';
-type SortDir = 'asc' | 'desc';
+import {
+ useSettingsStore,
+ type FilesFolderLayout,
+ type FilesViewMode,
+ type FilesSortKey,
+ type FilesSortDir,
+} from '../../stores/settings-store';
interface FilesPrefs {
- folderLayout: FolderLayout;
- defaultViewMode: ViewMode;
- defaultSortKey: SortKey;
- defaultSortDir: SortDir;
+ folderLayout: FilesFolderLayout;
+ defaultViewMode: FilesViewMode;
+ defaultSortKey: FilesSortKey;
+ defaultSortDir: FilesSortDir;
showIcons: boolean;
coloredIcons: boolean;
showThumbnails: boolean;
showHiddenFiles: boolean;
}
-const DEFAULTS: FilesPrefs = {
- folderLayout: 'inline',
- defaultViewMode: 'list',
- defaultSortKey: 'name',
- defaultSortDir: 'asc',
- showIcons: true,
- coloredIcons: true,
- showThumbnails: true,
- showHiddenFiles: false,
-};
-
interface SampleFile {
name: string;
isFolder: boolean;
@@ -132,8 +123,33 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
export function FilesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
- const [prefs, setPrefs] = useState(DEFAULTS);
- const update = (patch: Partial) => setPrefs((p) => ({ ...p, ...patch }));
+ const hydrated = useSettingsStore((s) => s.hydrated);
+ const hydrate = useSettingsStore((s) => s.hydrate);
+ const set = useSettingsStore((s) => s.updateSetting);
+
+ const folderLayout = useSettingsStore((s) => s.filesFolderLayout);
+ const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
+ const defaultSortKey = useSettingsStore((s) => s.filesDefaultSortKey);
+ const defaultSortDir = useSettingsStore((s) => s.filesDefaultSortDir);
+ const showIcons = useSettingsStore((s) => s.filesShowIcons);
+ const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
+ const showThumbnails = useSettingsStore((s) => s.filesShowThumbnails);
+ const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
+
+ useEffect(() => {
+ if (!hydrated) void hydrate();
+ }, [hydrated, hydrate]);
+
+ const prefs: FilesPrefs = {
+ folderLayout,
+ defaultViewMode,
+ defaultSortKey,
+ defaultSortDir,
+ showIcons,
+ coloredIcons,
+ showThumbnails,
+ showHiddenFiles,
+ };
return (
@@ -145,8 +161,8 @@ export function FilesSettings() {
update({ folderLayout: v as FolderLayout })}
+ value={folderLayout}
+ onChange={(v) => set('filesFolderLayout', v as FilesFolderLayout)}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'sidebar', label: 'Sidebar' },
@@ -156,8 +172,8 @@ export function FilesSettings() {
update({ defaultViewMode: v as ViewMode })}
+ value={defaultViewMode}
+ onChange={(v) => set('filesDefaultViewMode', v as FilesViewMode)}
options={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
@@ -167,8 +183,8 @@ export function FilesSettings() {
update({ defaultSortKey: v as SortKey })}
+ value={defaultSortKey}
+ onChange={(v) => set('filesDefaultSortKey', v as FilesSortKey)}
options={[
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
@@ -179,8 +195,8 @@ export function FilesSettings() {
update({ defaultSortDir: v as SortDir })}
+ value={defaultSortDir}
+ onChange={(v) => set('filesDefaultSortDir', v as FilesSortDir)}
options={[
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' },
@@ -191,25 +207,28 @@ export function FilesSettings() {
- update({ showIcons: v })} />
+ set('filesShowIcons', v)} />
-
+
update({ coloredIcons: v })}
- disabled={!prefs.showIcons}
+ checked={coloredIcons}
+ onChange={(v) => set('filesColoredIcons', v)}
+ disabled={!showIcons}
/>
- update({ showThumbnails: v })} />
+ set('filesShowThumbnails', v)}
+ />
update({ showHiddenFiles: v })}
+ checked={showHiddenFiles}
+ onChange={(v) => set('filesShowHiddenFiles', v)}
/>
diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx
index 2341c1ea..bb973d1f 100644
--- a/src/components/settings/FilterSettings.tsx
+++ b/src/components/settings/FilterSettings.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 { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import { SettingsSection, 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 Rule {
id: string;
@@ -19,11 +20,18 @@ const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const hydrated = useSettingsStore((s) => s.hydrated);
+ const hydrate = useSettingsStore((s) => s.hydrate);
+ const expandedView = useSettingsStore((s) => s.filtersExpandedView);
+ const update = useSettingsStore((s) => s.updateSetting);
const [rules, setRules] = useState(SAMPLE_RULES);
- const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState(null);
+ useEffect(() => {
+ if (!hydrated) void hydrate();
+ }, [hydrated, hydrate]);
+
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
@@ -115,7 +123,7 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && (
Expanded view
-
+ update('filtersExpandedView', v)} />
)}
diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx
index 5a62e621..b8c39a48 100644
--- a/src/components/settings/NotificationSettings.tsx
+++ b/src/components/settings/NotificationSettings.tsx
@@ -13,6 +13,7 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
+import { useSettingsStore, type NotificationSound } from '../../stores/settings-store';
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
@@ -21,7 +22,7 @@ import {
teardownPushNotifications,
} from '../../lib/push-notifications';
-const SOUNDS = [
+const SOUNDS: { value: NotificationSound; label: string }[] = [
{ value: 'default', label: 'Default' },
{ value: 'chime', label: 'Chime' },
{ value: 'ping', label: 'Ping' },
@@ -40,18 +41,25 @@ export function NotificationSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
- const [sound, setSound] = useState('default');
- const [emailEnabled, setEmailEnabled] = useState(true);
- const [emailSound, setEmailSound] = useState(true);
- const [calEnabled, setCalEnabled] = useState(true);
- const [calSound, setCalSound] = useState(true);
- const [invitationParsing, setInvitationParsing] = useState(true);
+ const hydrated = useSettingsStore((s) => s.hydrated);
+ const hydrate = useSettingsStore((s) => s.hydrate);
+ const update = useSettingsStore((s) => s.updateSetting);
+ const sound = useSettingsStore((s) => s.notificationSoundChoice);
+ const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
+ const emailSound = useSettingsStore((s) => s.emailNotificationSound);
+ const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
+ const calSound = useSettingsStore((s) => s.calendarNotificationSound);
+ const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [hasSaved, setHasSaved] = useState(false);
const [pushStatus, setPushStatus] = useState({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false);
+ useEffect(() => {
+ if (!hydrated) void hydrate();
+ }, [hydrated, hydrate]);
+
useEffect(() => {
void (async () => {
const stored = await getStoredRelayBaseUrl();
@@ -190,7 +198,7 @@ export function NotificationSettings() {
/>
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() {
update('markAsReadDelay', Number(v))}
options={[
{ value: '0', label: 'Instant' },
{ value: '3000', label: 'After 3s' },
@@ -180,7 +174,7 @@ 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() {
setArchiveMode(v as ArchiveMode)}
+ onChange={(v) => update('archiveMode', v as ArchiveMode)}
options={[
{ value: 'single', label: 'Single folder' },
{ value: 'year', label: 'By year' },
@@ -231,14 +225,14 @@ export function ReadingSettings() {
-
+ update('permanentlyDeleteJunk', v)} />
setMailLayout(v as MailLayout)}
+ onChange={(v) => update('mailLayout', v as MailLayout)}
options={[
{ value: 'split', label: 'Split' },
{ value: 'focus', label: 'Focus' },
@@ -249,25 +243,25 @@ export function ReadingSettings() {
-
+ update('showPreview', v)} />
-
+ update('disableThreading', v)} />
-
+ update('plainTextMode', v)} />
-
+ update('autoSelectReplyIdentity', v)} />
update('attachmentReminderEnabled', v)}
/>
@@ -281,7 +275,10 @@ export function ReadingSettings() {
{kw}
- setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
+ update(
+ 'attachmentReminderKeywords',
+ attachmentReminderKeywords.filter((k) => k !== kw),
+ )
}
hitSlop={6}
>
@@ -300,7 +297,7 @@ export function ReadingSettings() {
onSubmitEditing={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
- setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
+ update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
@@ -310,7 +307,7 @@ export function ReadingSettings() {
onPress={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
- setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
+ update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
@@ -323,13 +320,13 @@ export function ReadingSettings() {
)}
-
+ update('hideInlineImageAttachments', v)} />
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() {
setAttachmentPosition(v as AttachmentPosition)}
+ onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: 'Beside sender' },
{ value: 'below-header', label: 'Below header' },
@@ -350,8 +347,8 @@ 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,