diff --git a/src/api/quota.ts b/src/api/quota.ts new file mode 100644 index 00000000..6eb6d870 --- /dev/null +++ b/src/api/quota.ts @@ -0,0 +1,55 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; + +// RFC 9425 Quota object (Stalwart reports storage as resourceType "octets" +// with scope "account"); older servers used a pre-RFC "mail" shape. +interface JMAPQuota { + id: string; + resourceType?: string; + used?: number; + hardLimit?: number; + warnLimit?: number; + softLimit?: number; + // pre-RFC shape + limit?: number; + scope?: string; + types?: string[]; + name?: string; +} + +export interface MailQuota { + used: number; + total: number; +} + +export function serverSupportsQuota(): boolean { + return Boolean(jmapClient.currentSession?.capabilities?.[CAPABILITIES.QUOTA]); +} + +/** + * Storage usage for the primary account, or null when the server does not + * advertise urn:ietf:params:jmap:quota or reports no mail storage quota. + * Mirrors the webmail's client.getQuota(). + */ +export async function fetchMailQuota(): Promise { + if (!serverSupportsQuota()) return null; + try { + const res = await jmapClient.request( + [['Quota/get', { accountId: jmapClient.accountId }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.QUOTA], + ); + const [name, body] = res.methodResponses[0] ?? []; + if (name !== 'Quota/get') return null; + const quotas = (body?.list ?? []) as JMAPQuota[]; + const coversMail = (q: JMAPQuota) => + !q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail'); + const mailQuota = + quotas.find((q) => q.resourceType === 'octets' && coversMail(q)) + || quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail'); + if (!mailQuota) return null; + const total = mailQuota.hardLimit ?? mailQuota.limit ?? 0; + return { used: mailQuota.used ?? 0, total }; + } catch { + return null; + } +} diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx index c1c4f578..7b6820d2 100644 --- a/src/components/settings/AccountSettings.tsx +++ b/src/components/settings/AccountSettings.tsx @@ -1,21 +1,19 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +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, Plus, Star, Trash2, Users } from 'lucide-react-native'; import { SettingsSection, SettingItem } from './settings-section'; +import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useAuthStore } from '../../stores/auth-store'; import { useAccountStore } from '../../stores/account-store'; - -interface AccountSettingsProps { - displayName?: string; - email?: string; - username?: string; - serverUrl?: string; - authMode?: 'oauth' | 'basic'; - quotaUsed?: number; - quotaTotal?: number; - isDemoMode?: boolean; -} +import { useLocaleStore } from '../../stores/locale-store'; +import { jmapClient } from '../../api/jmap-client'; +import { fetchMailQuota, type MailQuota } from '../../api/quota'; +import { MAX_ACCOUNTS } from '../../lib/account-utils'; +import type { RootStackParamList } from '../../navigation/types'; function formatFileSize(bytes: number): string { if (!bytes) return '0 B'; @@ -29,76 +27,241 @@ function formatFileSize(bytes: number): string { return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`; } -export function AccountSettings(props: AccountSettingsProps = {}) { +export function AccountSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const navigation = useNavigation>(); const authUsername = useAuthStore((s) => s.username); const authServerUrl = useAuthStore((s) => s.serverUrl); const activeAccountId = useAuthStore((s) => s.activeAccountId); - const account = useAccountStore((s) => - activeAccountId ? s.accounts.find((a) => a.id === activeAccountId) : undefined, - ); + 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 = props.displayName ?? account?.displayName ?? authUsername ?? 'Unknown'; - const email = props.email ?? account?.email ?? authUsername ?? ''; - const username = props.username ?? account?.username ?? authUsername ?? undefined; - const serverUrl = props.serverUrl ?? account?.serverUrl ?? authServerUrl ?? ''; - const authMode = props.authMode ?? 'basic'; - const quotaUsed = props.quotaUsed ?? 0; - const quotaTotal = props.quotaTotal ?? 0; - const isDemoMode = props.isDemoMode ?? false; + 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' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic'; - const percent = quotaTotal ? Math.round((quotaUsed / quotaTotal) * 100) : 0; + const [quota, setQuota] = useState(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(null); + + 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 ( - - - {displayName} - - - - {email} - - - {username && username !== email && ( - - {username} + + + + {displayName} - )} - - - {authMode === 'oauth' ? 'OAuth' : 'Basic'} - - + + {email} + - - {serverUrl} - + {username && username !== email && ( + + {username} + + )} - {quotaTotal > 0 && ( - - - {percent}% - - + + + {authMode === 'oauth' + ? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)') + : t('settings.account.auth_method_basic', 'Password')} + + + + + {serverUrl} + + + {quota && quota.total > 0 && ( + + + + {t('settings.account.storage.percentage', '{percent}% used', { percent })} + + + = 90 && styles.barFillWarn]} /> + - - - )} + + )} + - {isDemoMode && ( - - - - Demo account + + + {accounts.map((a, index) => { + const isActive = a.id === activeAccountId; + const isDefault = a.id === defaultAccountId; + const label = a.email || a.username; + return ( + 0 && styles.rowBorder]}> + + {(a.displayName || a.username).slice(0, 1).toUpperCase()} + + { 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')} + > + + {a.displayName || a.username} + {isActive && } + + {label} + {isDefault && ( + + {t('settings.account.accounts.default_badge', 'Default account')} + + )} + {a.hasError && a.errorMessage ? ( + {a.errorMessage} + ) : null} + + + move(a.id, -1)} + disabled={index === 0} + accessibilityRole="button" + accessibilityLabel={t('settings.account.accounts.move_up', 'Move up')} + > + + + move(a.id, 1)} + disabled={index === accounts.length - 1} + accessibilityRole="button" + accessibilityLabel={t('settings.account.accounts.move_down', 'Move down')} + > + + + setDefaultAccount(a.id)} + disabled={isDefault} + accessibilityRole="button" + accessibilityLabel={t('settings.account.accounts.set_default', 'Set as default')} + > + + + confirmRemove(a.id, label)} + disabled={busyId !== null} + accessibilityRole="button" + accessibilityLabel={t('common.remove', 'Remove')} + > + + + + + ); + })} + + + + {accounts.length >= MAX_ACCOUNTS && ( + + {t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS })} + + )} + + + + {sharedAccounts.length > 0 && ( + + + {sharedAccounts.map((s, index) => ( + 0 && styles.rowBorder]}> + + + + + {s.name} + {t('settings.account.shared_accounts.shared_label', 'Shared account')} + + + ))} - + )} - + ); } @@ -125,20 +288,36 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.primary, borderRadius: radius.full, }, - demoRow: { + barFillWarn: { backgroundColor: c.warning }, + list: { + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + }, + row: { flexDirection: 'row', alignItems: 'center', - gap: 6, + gap: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, }, - demoDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#f59e0b', + rowBorder: { borderTopWidth: 1, borderTopColor: c.border }, + avatar: { + width: 32, + height: 32, + borderRadius: radius.full, + alignItems: 'center', + justifyContent: 'center', }, - demoText: { - ...typography.bodyMedium, - color: '#fbbf24', - }, -}); + 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: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + addRow: { gap: spacing.sm, alignItems: 'flex-start' }, + hint: { ...typography.caption, color: c.mutedForeground }, + }); } diff --git a/src/stores/account-store.ts b/src/stores/account-store.ts index b89e5e5e..c0bb07e6 100644 --- a/src/stores/account-store.ts +++ b/src/stores/account-store.ts @@ -31,6 +31,9 @@ interface AccountState { setActiveAccount: (accountId: string) => void; setDefaultAccount: (accountId: string) => void; updateAccount: (accountId: string, updates: Partial) => void; + // Persist a new display order (ids in the desired order; unknown ids are + // ignored, missing ones keep their relative order at the end). + reorderAccounts: (orderedIds: string[]) => void; getAccountById: (accountId: string) => AccountEntry | undefined; getActiveAccount: () => AccountEntry | null; getDefaultAccount: () => AccountEntry | null; @@ -131,6 +134,21 @@ export const useAccountStore = create()( })); }, + reorderAccounts: (orderedIds) => { + set((s) => { + const byId = new Map(s.accounts.map((a) => [a.id, a])); + const next: AccountEntry[] = []; + for (const id of orderedIds) { + const entry = byId.get(id); + if (entry && !next.includes(entry)) next.push(entry); + } + for (const a of s.accounts) { + if (!next.includes(a)) next.push(a); + } + return { accounts: next }; + }); + }, + getAccountById: (accountId) => get().accounts.find((a) => a.id === accountId), getActiveAccount: () => { const s = get();