refactor: drop dead mail layout, S/MIME and plugin controls from settings

This commit is contained in:
Linus Rath
2026-08-29 18:04:21 +02:00
parent 8deff66274
commit 3d0d440673
4 changed files with 95 additions and 629 deletions
+38 -237
View File
@@ -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<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(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 (
<SettingsSection
title="Plugins"
description="Manage installed plugins. Upload plugin .zip files to add new functionality."
experimental
experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable. Only install plugins from sources you trust."
>
{plugins.length === 0 ? (
<View style={styles.empty}>
<Puzzle size={48} color={c.mutedForeground} style={{ opacity: 0.3 }} />
<Text style={styles.emptyTitle}>No plugins installed</Text>
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
</View>
) : (
<View style={{ gap: spacing.sm }}>
{plugins.map((plugin) => {
const expanded = expandedId === plugin.id;
const status = statusStyle(c, plugin.status);
return (
<View
key={plugin.id}
style={[
styles.card,
plugin.status === 'error' && { borderColor: c.errorBorder },
]}
>
<View style={styles.cardHeader}>
<Pressable
style={{ flex: 1 }}
onPress={() => setExpandedId(expanded ? null : plugin.id)}
>
<View style={styles.headerRow}>
<Text style={styles.pluginName} numberOfLines={1}>{plugin.name}</Text>
<View style={[styles.statusPill, { backgroundColor: status.bg }]}>
<Text style={[styles.statusText, { color: status.fg }]}>{plugin.status}</Text>
</View>
{plugin.forceEnabled && (
<View style={styles.forcedPill}>
<Lock size={10} color="#fbbf24" />
<Text style={styles.forcedText}>Forced</Text>
</View>
)}
</View>
<View style={styles.metaRow}>
<Text style={styles.meta}>{plugin.author}</Text>
<Text style={styles.metaDot}>v{plugin.version}</Text>
<Text style={styles.metaDot}>{plugin.type}</Text>
</View>
</Pressable>
<ToggleSwitch
checked={plugin.enabled}
onChange={() => togglePlugin(plugin.id)}
disabled={plugin.forceEnabled}
/>
</View>
{expanded && (
<View style={styles.cardBody}>
{plugin.description && (
<Text style={styles.description}>{plugin.description}</Text>
)}
{plugin.error && (
<View style={styles.errorBox}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorText}>{plugin.error}</Text>
</View>
)}
{plugin.permissions.length > 0 && (
<View>
<Text style={styles.sectionLabel}>Permissions</Text>
<View style={styles.permWrap}>
{plugin.permissions.map((p) => (
<View key={p} style={styles.permPill}>
<Text style={styles.permText}>{p}</Text>
</View>
))}
</View>
</View>
)}
<View style={styles.uninstallRow}>
<Button
variant="destructive"
size="sm"
icon={<Trash2 size={14} color="#fff" />}
onPress={() => uninstall(plugin.id)}
disabled={plugin.forceEnabled}
>
Uninstall
</Button>
</View>
</View>
)}
</View>
);
})}
</View>
title={t('settings.tabs.plugins', 'Plugins')}
description={t(
'settings.plugins.mobile_description',
'Plugins run inside the webmail and extend it in the browser.',
)}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Upload .zip
</Button>
</SettingItem>
>
<View style={styles.box}>
<Puzzle size={20} color={c.mutedForeground} />
<Text style={styles.text}>
{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.',
)}
</Text>
</View>
</SettingsSection>
);
}
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 },
});
}
+1 -152
View File
@@ -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 (
<View style={styles.layoutPreview}>
<View>
<Text style={styles.layoutTitle}>{isSplit ? 'Split' : 'Focus'}</Text>
<Text style={styles.layoutDesc}>
{isSplit ? 'List and reader side by side.' : 'Single column for focused reading.'}
</Text>
</View>
<View style={styles.layoutFrame}>
<View style={styles.layoutRail} />
{isSplit ? (
<>
<View style={styles.layoutList}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutRow, i === 2 && styles.layoutRowSel]}
>
<Text style={styles.layoutRowName}>Alice</Text>
<Text style={styles.layoutRowSub}>Subject line</Text>
</View>
))}
</View>
<View style={styles.layoutReader}>
<View style={[styles.layoutBar, { width: 80 }]} />
<View style={[styles.layoutBar, { width: '100%', marginTop: 8 }]} />
<View style={[styles.layoutBar, { width: '85%', marginTop: 6 }]} />
<View style={[styles.layoutBar, { width: '60%', marginTop: 6 }]} />
</View>
</>
) : (
<View style={{ flex: 1, padding: 8 }}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutFocus, i === 2 && styles.layoutFocusSel]}
>
<Text style={styles.layoutFocusText}>
<Text style={{ fontWeight: '500' }}>Alice </Text>
Subject line preview…
</Text>
</View>
))}
</View>
)}
</View>
</View>
);
}
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() {
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
</SettingItem>
<View style={styles.group}>
<SettingItem label="Mail Layout" description="Choose between split view and focus view." noBorder />
<RadioGroup
value={mailLayout}
onChange={(v) => update('mailLayout', v as MailLayout)}
options={[
{ value: 'split', label: 'Split' },
{ value: 'focus', label: 'Focus' },
]}
/>
<MailLayoutPreview value={mailLayout} />
<View style={styles.divider} />
</View>
<SettingItem label="Show Preview" description="Preview text under each subject.">
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
@@ -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,
+40 -231
View File
@@ -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<KeyRecord[]>(MOCK_KEYS);
const [certs, setCerts] = useState<PublicCert[]>(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 (
<View style={styles.container}>
<SettingsSection
title="Your Certificates"
description="Private keys used to sign and decrypt mail."
>
<View style={{ gap: spacing.sm }}>
{keys.map((k) => {
const expired = isExpired(k.notAfter);
return (
<View key={k.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
{expired ? (
<ShieldAlert size={16} color={c.error} />
) : (
<ShieldCheck size={16} color={c.primary} />
)}
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{k.email || k.subject}</Text>
<Text style={styles.certMeta}>
{k.issuer} · Expires {formatDate(k.notAfter)}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
{k.unlocked ? (
<Unlock size={16} color={c.success} />
) : (
<Lock size={16} color={c.text} />
)}
</Pressable>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn}>
<Download size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
</View>
);
})}
{keys.length === 0 && (
<Text style={styles.empty}>No certificates imported.</Text>
<SettingsSection
title={t('settings.tabs.encryption', 'S/MIME Encryption')}
description={t(
'settings.smime.mobile_description',
'Sign and encrypt mail with S/MIME certificates.',
)}
>
<View style={styles.box}>
<KeyRound size={20} color={c.mutedForeground} />
<Text style={styles.text}>
{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.',
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Import PKCS#12
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Recipient Certificates"
description="Public certificates used to encrypt outgoing mail."
>
<View style={{ gap: spacing.sm }}>
{certs.map((cert) => {
const expired = isExpired(cert.notAfter);
return (
<View key={cert.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
<Text style={styles.certMeta}>
{cert.issuer} · {cert.source}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
</View>
);
})}
{certs.length === 0 && (
<Text style={styles.empty}>No recipient certificates.</Text>
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Import Certificate
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Defaults"
description="Defaults for new outgoing messages."
>
<SettingItem
label="Encrypt by default"
description="Encrypt new messages when recipient certificates are available."
>
<ToggleSwitch checked={defaultEncrypt} onChange={(v) => update('smimeDefaultEncrypt', v)} />
</SettingItem>
<SettingItem
label="Remember unlocked keys"
description="Keep private keys unlocked across the session."
>
<ToggleSwitch checked={rememberUnlocked} onChange={(v) => update('smimeRememberUnlocked', v)} />
</SettingItem>
<SettingItem
label="Auto-import signer certs"
description="Save public certificates from signed messages."
>
<ToggleSwitch checked={autoImport} onChange={(v) => update('smimeAutoImport', v)} />
</SettingItem>
</SettingsSection>
</View>
</Text>
</View>
</SettingsSection>
);
}
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 },
});
}
+16 -9
View File
@@ -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<TabGroup, string> = {
@@ -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<Record<Tab, React.ComponentType<any>>> = {
@@ -143,7 +146,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
// 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
<View style={styles.header}>
<Pressable
onPress={() => setSelectedTab(null)}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
@@ -248,6 +253,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{onBack ? (
<Pressable
onPress={onBack}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
@@ -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 (
<Pressable
@@ -302,7 +309,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</Text>
{tab.experimental && !disabled && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
<Text style={styles.experimentalText}>{t('settings.badges.experimental', 'Experimental')}</Text>
</View>
)}
</View>
@@ -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,
},