mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
refactor: drop dead mail layout, S/MIME and plugin controls from settings
This commit is contained in:
@@ -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,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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user