From 3d0d4406731a8ddd608a0591e250cfc70c0f90cd 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] refactor: drop dead mail layout, S/MIME and plugin controls from settings --- src/components/settings/PluginsSettings.tsx | 275 +++----------------- src/components/settings/ReadingSettings.tsx | 153 +---------- src/components/settings/SmimeSettings.tsx | 271 +++---------------- src/screens/SettingsScreen.tsx | 25 +- 4 files changed, 95 insertions(+), 629 deletions(-) diff --git a/src/components/settings/PluginsSettings.tsx b/src/components/settings/PluginsSettings.tsx index c27e6bf3..0d7612ee 100644 --- a/src/components/settings/PluginsSettings.tsx +++ b/src/components/settings/PluginsSettings.tsx @@ -1,254 +1,55 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native'; -import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; -import Button from '../Button'; +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Puzzle } from 'lucide-react-native'; +import { SettingsSection } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore } from '../../stores/settings-store'; - -interface Plugin { - id: string; - name: string; - author: string; - version: string; - type: string; - description?: string; - enabled: boolean; - status: 'installed' | 'enabled' | 'running' | 'disabled' | 'error'; - error?: string; - permissions: string[]; - forceEnabled?: boolean; -} - -function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } { - switch (status) { - case 'installed': return { bg: c.muted, fg: c.mutedForeground }; - case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' }; - case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' }; - case 'disabled': return { bg: c.muted, fg: c.mutedForeground }; - case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' }; - } -} +import { useLocaleStore } from '../../stores/locale-store'; +/** + * The webmail's plugin runtime (sandboxed iframes, marketplace, signing) has + * no counterpart in the native app, so this pane only explains where plugins + * live. The tab is hidden from the settings list; the component stays so a + * deep link to `settings/plugins` still renders something sensible. + */ 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) => { - 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]); + const t = useLocaleStore((s) => s.t); return ( - {plugins.length === 0 ? ( - - - No plugins installed - Upload a plugin .zip file to get started. - - ) : ( - - {plugins.map((plugin) => { - const expanded = expandedId === plugin.id; - const status = statusStyle(c, plugin.status); - return ( - - - setExpandedId(expanded ? null : plugin.id)} - > - - {plugin.name} - - {plugin.status} - - {plugin.forceEnabled && ( - - - Forced - - )} - - - {plugin.author} - v{plugin.version} - {plugin.type} - - - togglePlugin(plugin.id)} - disabled={plugin.forceEnabled} - /> - - - {expanded && ( - - {plugin.description && ( - {plugin.description} - )} - - {plugin.error && ( - - - {plugin.error} - - )} - - {plugin.permissions.length > 0 && ( - - Permissions - - {plugin.permissions.map((p) => ( - - {p} - - ))} - - - )} - - - - - - )} - - ); - })} - + title={t('settings.tabs.plugins', 'Plugins')} + description={t( + 'settings.plugins.mobile_description', + 'Plugins run inside the webmail and extend it in the browser.', )} - - - - + > + + + + {t( + 'settings.plugins.mobile_explainer', + 'The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here.', + )} + + ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ - empty: { - alignItems: 'center', - paddingVertical: spacing.xxxl, - gap: spacing.sm, - }, - emptyTitle: { ...typography.body, color: c.mutedForeground }, - emptyDesc: { ...typography.caption, color: c.mutedForeground }, - card: { - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - padding: spacing.md, - }, - headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' }, - pluginName: { ...typography.bodyMedium, color: c.text }, - statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full }, - statusText: { fontSize: 10, fontWeight: '500' }, - forcedPill: { - flexDirection: 'row', - alignItems: 'center', - gap: 2, - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: radius.full, - backgroundColor: 'rgba(251, 191, 36, 0.15)', - }, - forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' }, - metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 }, - meta: { ...typography.caption, color: c.mutedForeground }, - metaDot: { ...typography.caption, color: c.mutedForeground, opacity: 0.6 }, - cardBody: { - borderTopWidth: 1, - borderTopColor: c.border, - padding: spacing.md, - gap: spacing.md, - }, - description: { ...typography.caption, color: c.mutedForeground }, - errorBox: { - flexDirection: 'row', - gap: spacing.sm, - padding: spacing.sm, - borderRadius: radius.sm, - backgroundColor: c.errorBg, - alignItems: 'flex-start', - }, - errorText: { ...typography.caption, color: c.error, flex: 1 }, - sectionLabel: { ...typography.captionMedium, color: c.text, marginBottom: 4 }, - permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 }, - permPill: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: radius.xs, - backgroundColor: c.muted, - }, - permText: { fontSize: 10, color: c.mutedForeground }, - uninstallRow: { - flexDirection: 'row', - justifyContent: 'flex-end', - paddingTop: spacing.sm, - borderTopWidth: 1, - borderTopColor: c.border, - }, -}); + box: { + flexDirection: 'row', + gap: spacing.md, + alignItems: 'flex-start', + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.muted, + }, + text: { ...typography.body, color: c.mutedForeground, flex: 1 }, + }); } diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index 8e850e42..a46b72b4 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -1,14 +1,13 @@ import React, { useEffect, useState } from 'react'; import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native'; import { AlertTriangle, FolderSync, X } from 'lucide-react-native'; -import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section'; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useSettingsStore, type ArchiveMode, type DeleteAction, - type MailLayout, type MailAttachmentAction, type AttachmentPosition, } from '../../stores/settings-store'; @@ -16,60 +15,6 @@ import { useEmailStore } from '../../stores/email-store'; import { archiveEmails, queryEmails, getEmails } from '../../api/email'; import { ownMailboxes } from '../../lib/mailbox-tree'; -function MailLayoutPreview({ value }: { value: MailLayout }) { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const isSplit = value === 'split'; - return ( - - - {isSplit ? 'Split' : 'Focus'} - - {isSplit ? 'List and reader side by side.' : 'Single column for focused reading.'} - - - - - {isSplit ? ( - <> - - {[1, 2, 3].map((i) => ( - - Alice - Subject line - - ))} - - - - - - - - - ) : ( - - {[1, 2, 3].map((i) => ( - - - Alice - Subject line preview… - - - ))} - - )} - - - ); -} - export function ReadingSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -82,7 +27,6 @@ export function ReadingSettings() { 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 includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity); @@ -232,20 +176,6 @@ export function ReadingSettings() { update('permanentlyDeleteJunk', v)} /> - - - update('mailLayout', v as MailLayout)} - options={[ - { value: 'split', label: 'Split' }, - { value: 'focus', label: 'Focus' }, - ]} - /> - - - - update('showPreview', v)} /> @@ -469,87 +399,6 @@ function makeStyles(c: ThemePalette) { ...typography.body, color: c.text, }, - layoutPreview: { - marginTop: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.background, - padding: spacing.md, - }, - layoutTitle: { - ...typography.bodyMedium, - color: c.text, - }, - layoutDesc: { - ...typography.caption, - color: c.mutedForeground, - marginTop: 2, - }, - layoutFrame: { - flexDirection: 'row', - height: 112, - marginTop: spacing.md, - borderRadius: radius.sm, - borderWidth: 1, - borderColor: c.border, - overflow: 'hidden', - backgroundColor: 'rgba(24,24,27,0.4)', - }, - layoutRail: { - width: 32, - borderRightWidth: 1, - borderRightColor: c.border, - backgroundColor: 'rgba(24,24,27,0.6)', - }, - layoutList: { - width: 96, - borderRightWidth: 1, - borderRightColor: c.border, - backgroundColor: c.background, - }, - layoutRow: { - paddingHorizontal: 6, - paddingVertical: 4, - borderBottomWidth: 1, - borderBottomColor: c.border, - }, - layoutRowSel: { - backgroundColor: 'rgba(59,130,246,0.1)', - }, - layoutRowName: { - fontSize: 9, - fontWeight: '500', - color: c.text, - }, - layoutRowSub: { - fontSize: 9, - color: c.mutedForeground, - }, - layoutReader: { - flex: 1, - padding: spacing.md, - backgroundColor: c.background, - }, - layoutBar: { - height: 6, - borderRadius: 3, - backgroundColor: 'rgba(250,250,250,0.1)', - }, - layoutFocus: { - paddingHorizontal: spacing.sm, - paddingVertical: 4, - backgroundColor: 'rgba(24,24,27,0.4)', - borderRadius: radius.xs, - marginBottom: 4, - }, - layoutFocusSel: { - backgroundColor: 'rgba(59,130,246,0.1)', - }, - layoutFocusText: { - fontSize: 9, - color: c.text, - }, trustedList: { marginTop: spacing.sm, borderRadius: radius.sm, diff --git a/src/components/settings/SmimeSettings.tsx b/src/components/settings/SmimeSettings.tsx index 5aa507a0..0af9a542 100644 --- a/src/components/settings/SmimeSettings.tsx +++ b/src/components/settings/SmimeSettings.tsx @@ -1,246 +1,55 @@ -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 } from './settings-section'; -import Button from '../Button'; +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { KeyRound } from 'lucide-react-native'; +import { SettingsSection } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore } from '../../stores/settings-store'; - -interface KeyRecord { - id: string; - email: string; - subject: string; - issuer: string; - notAfter: string; - algorithm: string; - unlocked?: boolean; -} - -interface PublicCert { - id: string; - email: string; - subject: string; - issuer: string; - notAfter: string; - source: string; -} - -const MOCK_KEYS: KeyRecord[] = []; -const MOCK_CERTS: PublicCert[] = []; +import { useLocaleStore } from '../../stores/locale-store'; +/** + * S/MIME needs a native crypto path (key import into the keystore, CMS + * sign/encrypt/decrypt) that the app does not have yet. The tab is marked + * `implemented: false` in SettingsScreen; this explainer is what a deep link + * lands on. + */ 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); - - useEffect(() => { - if (!hydrated) void hydrate(); - }, [hydrated, hydrate]); - - const isExpired = (d: string) => new Date(d) < new Date(); - const formatDate = (d: string) => { - try { return new Date(d).toLocaleDateString(); } catch { return d; } - }; - - const toggleUnlock = (id: string) => { - setKeys((ks) => ks.map((k) => k.id === id ? { ...k, unlocked: !k.unlocked } : k)); - }; - - const deleteKey = (id: string) => setKeys((ks) => ks.filter((k) => k.id !== id)); - const deleteCert = (id: string) => setCerts((cs) => cs.filter((c) => c.id !== id)); + const t = useLocaleStore((s) => s.t); return ( - - - - {keys.map((k) => { - const expired = isExpired(k.notAfter); - return ( - - - - {expired ? ( - - ) : ( - - )} - - - {k.email || k.subject} - - {k.issuer} · Expires {formatDate(k.notAfter)} - {expired && (Expired)} - - - - - toggleUnlock(k.id)}> - {k.unlocked ? ( - - ) : ( - - )} - - - - - - - - deleteKey(k.id)}> - - - - - ); - })} - - {keys.length === 0 && ( - No certificates imported. + + + + + {t( + 'settings.smime.mobile_explainer', + 'S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here.', )} - - - - - - - - - - {certs.map((cert) => { - const expired = isExpired(cert.notAfter); - return ( - - - - - - - {cert.email || cert.subject} - - {cert.issuer} · {cert.source} - {expired && (Expired)} - - - - - - - - deleteCert(cert.id)}> - - - - - ); - })} - - {certs.length === 0 && ( - No recipient certificates. - )} - - - - - - - - - - update('smimeDefaultEncrypt', v)} /> - - - - update('smimeRememberUnlocked', v)} /> - - - - update('smimeAutoImport', v)} /> - - - + + + ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ - container: { gap: spacing.xxxl }, - certRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.md, - padding: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - }, - certLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - flex: 1, - minWidth: 0, - }, - certIcon: { - width: 32, - height: 32, - borderRadius: radius.full, - alignItems: 'center', - justifyContent: 'center', - }, - certIconOk: { backgroundColor: c.primaryBg }, - certIconError: { backgroundColor: c.errorBg }, - certIconMuted: { backgroundColor: c.muted }, - certName: { ...typography.bodyMedium, color: c.text }, - certMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, - expiredText: { color: c.error }, - certActions: { flexDirection: 'row', gap: 2 }, - iconBtn: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radius.sm, - }, - empty: { - ...typography.body, - color: c.mutedForeground, - textAlign: 'center', - paddingVertical: spacing.lg, - }, -}); + box: { + flexDirection: 'row', + gap: spacing.md, + alignItems: 'flex-start', + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.muted, + }, + text: { ...typography.body, color: c.mutedForeground, flex: 1 }, + }); } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 8fdae6c7..429ca2c2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -6,7 +6,7 @@ import { Palette, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw, - LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug, Download, + LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download, type LucideIcon, } from 'lucide-react-native'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -58,6 +58,8 @@ interface TabDef { group: TabGroup; experimental?: boolean; implemented: boolean; + // Never listed (still reachable by deep link). + hidden?: boolean; } const GROUP_LABELS: Record = { @@ -94,7 +96,7 @@ const TABS: TabDef[] = [ // Privacy & Security { id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true }, - { id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: true }, + { id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false }, { id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true }, // Apps @@ -106,9 +108,10 @@ const TABS: TabDef[] = [ // Advanced { id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true }, { id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: true }, - { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true }, + // Plugins run in the webmail only; the pane is an explainer reachable by + // deep link, not from the list. Debug logging lives under About & Data. + { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true, hidden: true }, { id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true }, - { id: 'debug', label: 'Debug', icon: Bug, group: 'advanced', implemented: false }, ]; const TAB_COMPONENTS: Partial>> = { @@ -143,7 +146,7 @@ const TAB_COMPONENTS: Partial>> = { // The "Updates" pane drives the sideload installer, which is Android-only - the // current version and build are still shown under "About & Data". const AVAILABLE_TABS: TabDef[] = TABS.filter( - (t) => t.id !== 'updates' || supportsSideloadUpdates, + (t) => !t.hidden && (t.id !== 'updates' || supportsSideloadUpdates), ); function groupTabs() { @@ -220,6 +223,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin setSelectedTab(null)} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} > @@ -248,6 +253,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {onBack ? ( [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} > @@ -275,9 +282,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const unavailable = unavailableTabs.has(tab.id); const disabled = !tab.implemented || unavailable; const badgeLabel = !tab.implemented - ? 'Not implemented' + ? t('settings.badges.not_implemented', 'Not implemented') : unavailable - ? 'Unavailable' + ? t('settings.badges.unavailable', 'Unavailable') : null; return ( {tab.experimental && !disabled && ( - Experimental + {t('settings.badges.experimental', 'Experimental')} )} @@ -387,7 +394,7 @@ function makeStyles(c: ThemePalette) { tabItemLabel: { ...typography.body, color: c.text }, experimentalBadge: { - backgroundColor: 'rgba(202, 138, 4, 0.15)', + backgroundColor: c.warningBg, borderRadius: radius.full, paddingHorizontal: 6, paddingVertical: 2, },