mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from '../icons';
|
|
import { SettingsSection, SettingItem } from './settings-section';
|
|
import Button from '../Button';
|
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
|
import { useColors } from '../../theme/colors';
|
|
import { useAuthStore } from '../../stores/auth-store';
|
|
import { useAccountStore } from '../../stores/account-store';
|
|
import { useLocaleStore } from '../../stores/locale-store';
|
|
import { useManagedAccountStore } from '../../stores/managed-account-store';
|
|
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
|
|
import { sharedAccountSettingsTabs } from '../../lib/capabilities';
|
|
import { jmapClient } from '../../api/jmap-client';
|
|
import { fetchMailQuota, type MailQuota } from '../../api/quota';
|
|
import { MAX_ACCOUNTS, accountRowText, nonDefaultPort, serverHostLabel } from '../../lib/account-utils';
|
|
import { toUnicodeEmail } from '../../lib/idn';
|
|
import type { RootStackParamList } from '../../navigation/types';
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (!bytes) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
let n = bytes;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i++;
|
|
}
|
|
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
|
}
|
|
|
|
export function AccountSettings() {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
|
const authUsername = useAuthStore((s) => s.username);
|
|
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
|
const session = useAuthStore((s) => s.session);
|
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
|
const removeAccountAuth = useAuthStore((s) => s.removeAccount);
|
|
const accounts = useAccountStore((s) => s.accounts);
|
|
const defaultAccountId = useAccountStore((s) => s.defaultAccountId);
|
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
|
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
|
|
const account = accounts.find((a) => a.id === activeAccountId);
|
|
|
|
const displayName = account?.displayName || authUsername || t('settings.account.unknown', 'Unknown');
|
|
const email = account?.email || authUsername || '';
|
|
const username = account?.username || authUsername || undefined;
|
|
const serverUrl = account?.serverUrl || authServerUrl || '';
|
|
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
|
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
|
|
? 'token'
|
|
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
|
|
|
const [quota, setQuota] = useState<MailQuota | null>(null);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setQuota(null);
|
|
if (!session) return;
|
|
void fetchMailQuota().then((q) => {
|
|
if (!cancelled) setQuota(q);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [session, activeAccountId]);
|
|
|
|
const percent = quota && quota.total > 0 ? Math.min(100, Math.round((quota.used / quota.total) * 100)) : 0;
|
|
const sharedAccounts = session ? jmapClient.getSharedMailAccounts() : [];
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
|
|
|
|
// Manage a shared/group account's filters and vacation responder: scope
|
|
// Settings to it and open its first pane (webmail: "Shared with me").
|
|
const manageShared = (account: { id: string; name: string }, firstTab: string) => {
|
|
setManagedAccount(account);
|
|
setPendingSettingsTab(firstTab);
|
|
};
|
|
|
|
const move = (id: string, delta: -1 | 1) => {
|
|
const ids = accounts.map((a) => a.id);
|
|
const idx = ids.indexOf(id);
|
|
const target = idx + delta;
|
|
if (idx === -1 || target < 0 || target >= ids.length) return;
|
|
[ids[idx], ids[target]] = [ids[target], ids[idx]];
|
|
reorderAccounts(ids);
|
|
};
|
|
|
|
const confirmRemove = (id: string, label: string) => {
|
|
Alert.alert(
|
|
t('sidebar.remove_account_title', 'Remove account?'),
|
|
t('sidebar.remove_account_message', 'Sign out of {account} on this device. Your mail stays on the server.', { account: label }),
|
|
[
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
{
|
|
text: t('common.remove', 'Remove'),
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
setBusyId(id);
|
|
void removeAccountAuth(id).finally(() => setBusyId(null));
|
|
},
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={{ gap: spacing.xxxl }}>
|
|
<SettingsSection
|
|
title={t('settings.account.title', 'Account')}
|
|
description={t('settings.account.description', 'View your account information')}
|
|
>
|
|
<SettingItem label={t('settings.account.name_label', 'Display Name')}>
|
|
<Text style={styles.value}>{displayName}</Text>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('settings.account.email.label', 'Email Address')}>
|
|
<Text style={styles.value}>{email}</Text>
|
|
</SettingItem>
|
|
|
|
{username && username !== email && (
|
|
<SettingItem label={t('settings.account.username_label', 'Username')}>
|
|
<Text style={styles.value}>{username}</Text>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem label={t('settings.account.auth_method_label', 'Authentication')}>
|
|
<Text style={styles.value}>
|
|
{authMode === 'oauth'
|
|
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
|
: authMode === 'token'
|
|
? t('settings.account.auth_method_token', 'Access token')
|
|
: t('settings.account.auth_method_basic', 'Password')}
|
|
</Text>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('settings.account.server.label', 'JMAP Server')}>
|
|
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
|
|
</SettingItem>
|
|
|
|
{quota && quota.total > 0 && (
|
|
<SettingItem
|
|
label={t('settings.account.storage.label', 'Storage Usage')}
|
|
description={t('settings.account.storage.used', '{used} of {total} used', {
|
|
used: formatFileSize(quota.used),
|
|
total: formatFileSize(quota.total),
|
|
})}
|
|
>
|
|
<View style={styles.storage}>
|
|
<Text style={styles.value}>
|
|
{t('settings.account.storage.percentage', '{percent}% used', { percent })}
|
|
</Text>
|
|
<View style={styles.bar} accessibilityRole="progressbar">
|
|
<View style={[styles.barFill, { width: `${percent}%` }, percent >= 90 && styles.barFillWarn]} />
|
|
</View>
|
|
</View>
|
|
</SettingItem>
|
|
)}
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t('settings.account.accounts.title', 'Logged-in accounts')}
|
|
description={t('settings.account.accounts.description_mobile', 'Reorder how accounts appear in the account switcher, pick the default, or sign one out.')}
|
|
>
|
|
<View style={styles.list}>
|
|
{accounts.map((a, index) => {
|
|
const isActive = a.id === activeAccountId;
|
|
const isDefault = a.id === defaultAccountId;
|
|
// The address leads when another account has the same name.
|
|
const rowText = accountRowText(a, accounts);
|
|
return (
|
|
<View key={a.id} style={[styles.row, index > 0 && styles.rowBorder]}>
|
|
<View style={[styles.avatar, { backgroundColor: a.avatarColor }]}>
|
|
<Text style={styles.avatarText}>{(a.displayName || a.username).slice(0, 1).toUpperCase()}</Text>
|
|
</View>
|
|
<Pressable
|
|
style={{ flex: 1, minWidth: 0 }}
|
|
onPress={() => { if (!isActive) void switchAccount(a.id); }}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={isActive
|
|
? t('settings.account.accounts.active', 'Currently active account')
|
|
: t('settings.account.accounts.switch_to', 'Switch to this account')}
|
|
>
|
|
<View style={styles.rowTitle}>
|
|
<Text style={styles.rowName} numberOfLines={1}>{toUnicodeEmail(rowText.title)}</Text>
|
|
{isActive && <Check size={14} color={c.success} />}
|
|
</View>
|
|
{rowText.subtitle ? <Text style={styles.rowSub} numberOfLines={1}>{toUnicodeEmail(rowText.subtitle)}</Text> : null}
|
|
{/* Two servers on one host are two accounts with the same address. */}
|
|
{nonDefaultPort(a.serverUrl) ? <Text style={styles.rowSub} numberOfLines={1}>{serverHostLabel(a.serverUrl)}</Text> : null}
|
|
{isDefault && (
|
|
<Text style={styles.defaultBadge}>
|
|
{t('settings.account.accounts.default_badge', 'Default account')}
|
|
</Text>
|
|
)}
|
|
{a.hasError && a.errorMessage ? (
|
|
<Text style={styles.rowError} numberOfLines={1}>{a.errorMessage}</Text>
|
|
) : null}
|
|
</Pressable>
|
|
<View style={styles.rowActions}>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={() => move(a.id, -1)}
|
|
disabled={index === 0}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('settings.account.accounts.move_up', 'Move up')}
|
|
>
|
|
<ArrowUp size={14} color={index === 0 ? c.border : c.mutedForeground} />
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={() => move(a.id, 1)}
|
|
disabled={index === accounts.length - 1}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('settings.account.accounts.move_down', 'Move down')}
|
|
>
|
|
<ArrowDown size={14} color={index === accounts.length - 1 ? c.border : c.mutedForeground} />
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={() => setDefaultAccount(a.id)}
|
|
disabled={isDefault}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('settings.account.accounts.set_default', 'Set as default')}
|
|
>
|
|
<Star size={14} color={isDefault ? c.warning : c.mutedForeground} fill={isDefault ? c.warning : 'transparent'} />
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={() => confirmRemove(a.id, a.email || a.username)}
|
|
disabled={busyId !== null}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.remove', 'Remove')}
|
|
>
|
|
<Trash2 size={14} color={c.error} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
<View style={styles.addRow}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
icon={<Plus size={14} color={c.text} />}
|
|
onPress={() => navigation.navigate('AddAccount')}
|
|
disabled={accounts.length >= MAX_ACCOUNTS}
|
|
>
|
|
{t('settings.account.accounts.add', 'Add account')}
|
|
</Button>
|
|
{accounts.length >= MAX_ACCOUNTS && (
|
|
<Text style={styles.hint}>
|
|
{t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS })}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</SettingsSection>
|
|
|
|
{sharedAccounts.length > 0 && (
|
|
<SettingsSection
|
|
title={t('settings.account.shared_accounts.title', 'Shared with me')}
|
|
description={t('settings.account.shared_accounts.description_manage', 'Group and shared accounts you can read through this account. Select one to manage its filters and vacation responder.')}
|
|
>
|
|
<View style={styles.list}>
|
|
{sharedAccounts.map((s, index) => {
|
|
const firstTab = sharedAccountSettingsTabs(s.id)[0];
|
|
return (
|
|
<Pressable
|
|
key={s.id}
|
|
onPress={firstTab ? () => manageShared(s, firstTab) : undefined}
|
|
disabled={!firstTab}
|
|
accessibilityRole={firstTab ? 'button' : undefined}
|
|
style={({ pressed }) => [
|
|
styles.row,
|
|
index > 0 && styles.rowBorder,
|
|
pressed && firstTab && { backgroundColor: c.muted },
|
|
]}
|
|
>
|
|
<View style={[styles.avatar, { backgroundColor: c.muted }]}>
|
|
<Users size={14} color={c.mutedForeground} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.rowName} numberOfLines={1}>{s.name}</Text>
|
|
<Text style={styles.rowSub}>{t('settings.account.shared_accounts.shared_label', 'Shared account')}</Text>
|
|
</View>
|
|
{firstTab && <ChevronRight size={16} color={c.mutedForeground} />}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</SettingsSection>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function makeStyles(c: ThemePalette) {
|
|
return StyleSheet.create({
|
|
value: {
|
|
...typography.body,
|
|
color: c.text,
|
|
maxWidth: 240,
|
|
},
|
|
storage: {
|
|
alignItems: 'flex-end',
|
|
gap: 6,
|
|
},
|
|
bar: {
|
|
width: 128,
|
|
height: 8,
|
|
backgroundColor: c.muted,
|
|
borderRadius: radius.full,
|
|
overflow: 'hidden',
|
|
},
|
|
barFill: {
|
|
height: '100%',
|
|
backgroundColor: c.primary,
|
|
borderRadius: radius.full,
|
|
},
|
|
barFillWarn: { backgroundColor: c.warning },
|
|
list: {
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
borderRadius: radius.md,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.md,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm + 2,
|
|
},
|
|
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
|
|
avatar: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: radius.full,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
avatarText: { ...typography.bodyMedium, color: '#ffffff' },
|
|
rowTitle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
rowName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
|
|
rowSub: { ...typography.caption, color: c.mutedForeground },
|
|
rowError: { ...typography.caption, color: c.error },
|
|
defaultBadge: { ...typography.caption, color: c.primary, marginTop: 2 },
|
|
rowActions: { flexDirection: 'row', gap: 2 },
|
|
iconBtn: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
|
addRow: { gap: spacing.sm, alignItems: 'flex-start' },
|
|
hint: { ...typography.caption, color: c.mutedForeground },
|
|
});
|
|
}
|