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 React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native';
|
import { Puzzle } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import Button from '../Button';
|
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useLocaleStore } from '../../stores/locale-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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function PluginsSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
const t = useLocaleStore((s) => s.t);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Plugins"
|
title={t('settings.tabs.plugins', 'Plugins')}
|
||||||
description="Manage installed plugins. Upload plugin .zip files to add new functionality."
|
description={t(
|
||||||
experimental
|
'settings.plugins.mobile_description',
|
||||||
experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable. Only install plugins from sources you trust."
|
'Plugins run inside the webmail and extend it in the browser.',
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
|
<View style={styles.box}>
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
<Puzzle size={20} color={c.mutedForeground} />
|
||||||
Upload .zip
|
<Text style={styles.text}>
|
||||||
</Button>
|
{t(
|
||||||
</SettingItem>
|
'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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(c: ThemePalette) {
|
function makeStyles(c: ThemePalette) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
empty: {
|
box: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
paddingVertical: spacing.xxxl,
|
gap: spacing.md,
|
||||||
gap: spacing.sm,
|
alignItems: 'flex-start',
|
||||||
},
|
padding: spacing.md,
|
||||||
emptyTitle: { ...typography.body, color: c.mutedForeground },
|
borderRadius: radius.md,
|
||||||
emptyDesc: { ...typography.caption, color: c.mutedForeground },
|
borderWidth: 1,
|
||||||
card: {
|
borderColor: c.border,
|
||||||
borderRadius: radius.md,
|
backgroundColor: c.muted,
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: c.border,
|
text: { ...typography.body, color: c.mutedForeground, flex: 1 },
|
||||||
},
|
});
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||||
import { AlertTriangle, FolderSync, X } from 'lucide-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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
type ArchiveMode,
|
type ArchiveMode,
|
||||||
type DeleteAction,
|
type DeleteAction,
|
||||||
type MailLayout,
|
|
||||||
type MailAttachmentAction,
|
type MailAttachmentAction,
|
||||||
type AttachmentPosition,
|
type AttachmentPosition,
|
||||||
} from '../../stores/settings-store';
|
} from '../../stores/settings-store';
|
||||||
@@ -16,60 +15,6 @@ import { useEmailStore } from '../../stores/email-store';
|
|||||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
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() {
|
export function ReadingSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -82,7 +27,6 @@ export function ReadingSettings() {
|
|||||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||||
const mailLayout = useSettingsStore((s) => s.mailLayout);
|
|
||||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||||
@@ -232,20 +176,6 @@ export function ReadingSettings() {
|
|||||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
||||||
</SettingItem>
|
</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.">
|
<SettingItem label="Show Preview" description="Preview text under each subject.">
|
||||||
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
@@ -469,87 +399,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
...typography.body,
|
...typography.body,
|
||||||
color: c.text,
|
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: {
|
trustedList: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
|
|||||||
@@ -1,246 +1,55 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import {
|
import { KeyRound } from 'lucide-react-native';
|
||||||
Upload, Trash2, Eye, Lock, Unlock, Download,
|
import { SettingsSection } from './settings-section';
|
||||||
ShieldCheck, ShieldAlert, Users,
|
|
||||||
} 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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useLocaleStore } from '../../stores/locale-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[] = [];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function SmimeSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
const t = useLocaleStore((s) => s.t);
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<SettingsSection
|
||||||
<SettingsSection
|
title={t('settings.tabs.encryption', 'S/MIME Encryption')}
|
||||||
title="Your Certificates"
|
description={t(
|
||||||
description="Private keys used to sign and decrypt mail."
|
'settings.smime.mobile_description',
|
||||||
>
|
'Sign and encrypt mail with S/MIME certificates.',
|
||||||
<View style={{ gap: spacing.sm }}>
|
)}
|
||||||
{keys.map((k) => {
|
>
|
||||||
const expired = isExpired(k.notAfter);
|
<View style={styles.box}>
|
||||||
return (
|
<KeyRound size={20} color={c.mutedForeground} />
|
||||||
<View key={k.id} style={styles.certRow}>
|
<Text style={styles.text}>
|
||||||
<View style={styles.certLeft}>
|
{t(
|
||||||
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
|
'settings.smime.mobile_explainer',
|
||||||
{expired ? (
|
'S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here.',
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
</SettingsSection>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(c: ThemePalette) {
|
function makeStyles(c: ThemePalette) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
box: {
|
||||||
certRow: {
|
flexDirection: 'row',
|
||||||
flexDirection: 'row',
|
gap: spacing.md,
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
padding: spacing.md,
|
||||||
gap: spacing.md,
|
borderRadius: radius.md,
|
||||||
padding: spacing.md,
|
borderWidth: 1,
|
||||||
borderRadius: radius.md,
|
borderColor: c.border,
|
||||||
borderWidth: 1,
|
backgroundColor: c.muted,
|
||||||
borderColor: c.border,
|
},
|
||||||
},
|
text: { ...typography.body, color: c.mutedForeground, flex: 1 },
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug, Download,
|
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
@@ -58,6 +58,8 @@ interface TabDef {
|
|||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
experimental?: boolean;
|
experimental?: boolean;
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
// Never listed (still reachable by deep link).
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||||
@@ -94,7 +96,7 @@ const TABS: TabDef[] = [
|
|||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
|
{ 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 },
|
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
@@ -106,9 +108,10 @@ const TABS: TabDef[] = [
|
|||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
|
{ 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: '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: '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>>> = {
|
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
|
// The "Updates" pane drives the sideload installer, which is Android-only - the
|
||||||
// current version and build are still shown under "About & Data".
|
// current version and build are still shown under "About & Data".
|
||||||
const AVAILABLE_TABS: TabDef[] = TABS.filter(
|
const AVAILABLE_TABS: TabDef[] = TABS.filter(
|
||||||
(t) => t.id !== 'updates' || supportsSideloadUpdates,
|
(t) => !t.hidden && (t.id !== 'updates' || supportsSideloadUpdates),
|
||||||
);
|
);
|
||||||
|
|
||||||
function groupTabs() {
|
function groupTabs() {
|
||||||
@@ -220,6 +223,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setSelectedTab(null)}
|
onPress={() => setSelectedTab(null)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} color={c.text} />
|
<ArrowLeft size={20} color={c.text} />
|
||||||
@@ -248,6 +253,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
|||||||
{onBack ? (
|
{onBack ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onBack}
|
onPress={onBack}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} color={c.text} />
|
<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 unavailable = unavailableTabs.has(tab.id);
|
||||||
const disabled = !tab.implemented || unavailable;
|
const disabled = !tab.implemented || unavailable;
|
||||||
const badgeLabel = !tab.implemented
|
const badgeLabel = !tab.implemented
|
||||||
? 'Not implemented'
|
? t('settings.badges.not_implemented', 'Not implemented')
|
||||||
: unavailable
|
: unavailable
|
||||||
? 'Unavailable'
|
? t('settings.badges.unavailable', 'Unavailable')
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -302,7 +309,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
|||||||
</Text>
|
</Text>
|
||||||
{tab.experimental && !disabled && (
|
{tab.experimental && !disabled && (
|
||||||
<View style={styles.experimentalBadge}>
|
<View style={styles.experimentalBadge}>
|
||||||
<Text style={styles.experimentalText}>Experimental</Text>
|
<Text style={styles.experimentalText}>{t('settings.badges.experimental', 'Experimental')}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -387,7 +394,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
tabItemLabel: { ...typography.body, color: c.text },
|
tabItemLabel: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
experimentalBadge: {
|
experimentalBadge: {
|
||||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
backgroundColor: c.warningBg,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
paddingHorizontal: 6, paddingVertical: 2,
|
paddingHorizontal: 6, paddingVertical: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user