mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: list logged-in accounts, storage quota and auth method in account settings
This commit is contained in:
@@ -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<MailQuota | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
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 { SettingsSection, SettingItem } 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 { useAuthStore } from '../../stores/auth-store';
|
import { useAuthStore } from '../../stores/auth-store';
|
||||||
import { useAccountStore } from '../../stores/account-store';
|
import { useAccountStore } from '../../stores/account-store';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
interface AccountSettingsProps {
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
displayName?: string;
|
import { fetchMailQuota, type MailQuota } from '../../api/quota';
|
||||||
email?: string;
|
import { MAX_ACCOUNTS } from '../../lib/account-utils';
|
||||||
username?: string;
|
import type { RootStackParamList } from '../../navigation/types';
|
||||||
serverUrl?: string;
|
|
||||||
authMode?: 'oauth' | 'basic';
|
|
||||||
quotaUsed?: number;
|
|
||||||
quotaTotal?: number;
|
|
||||||
isDemoMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (!bytes) return '0 B';
|
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]}`;
|
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings(props: AccountSettingsProps = {}) {
|
export function AccountSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
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 authUsername = useAuthStore((s) => s.username);
|
||||||
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const account = useAccountStore((s) =>
|
const session = useAuthStore((s) => s.session);
|
||||||
activeAccountId ? s.accounts.find((a) => a.id === activeAccountId) : undefined,
|
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 displayName = account?.displayName || authUsername || t('settings.account.unknown', 'Unknown');
|
||||||
const email = props.email ?? account?.email ?? authUsername ?? '';
|
const email = account?.email || authUsername || '';
|
||||||
const username = props.username ?? account?.username ?? authUsername ?? undefined;
|
const username = account?.username || authUsername || undefined;
|
||||||
const serverUrl = props.serverUrl ?? account?.serverUrl ?? authServerUrl ?? '';
|
const serverUrl = account?.serverUrl || authServerUrl || '';
|
||||||
const authMode = props.authMode ?? 'basic';
|
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
||||||
const quotaUsed = props.quotaUsed ?? 0;
|
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||||
const quotaTotal = props.quotaTotal ?? 0;
|
|
||||||
const isDemoMode = props.isDemoMode ?? false;
|
|
||||||
|
|
||||||
const percent = quotaTotal ? Math.round((quotaUsed / quotaTotal) * 100) : 0;
|
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 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 (
|
return (
|
||||||
<SettingsSection title="Account" description="Your account information.">
|
<View style={{ gap: spacing.xxxl }}>
|
||||||
<SettingItem label="Display Name">
|
<SettingsSection
|
||||||
<Text style={styles.value}>{displayName}</Text>
|
title={t('settings.account.title', 'Account')}
|
||||||
</SettingItem>
|
description={t('settings.account.description', 'View your account information')}
|
||||||
|
>
|
||||||
<SettingItem label="Email Address">
|
<SettingItem label={t('settings.account.name_label', 'Display Name')}>
|
||||||
<Text style={styles.value}>{email}</Text>
|
<Text style={styles.value}>{displayName}</Text>
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{username && username !== email && (
|
|
||||||
<SettingItem label="Username">
|
|
||||||
<Text style={styles.value}>{username}</Text>
|
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingItem label="Authentication">
|
<SettingItem label={t('settings.account.email.label', 'Email Address')}>
|
||||||
<Text style={styles.value}>
|
<Text style={styles.value}>{email}</Text>
|
||||||
{authMode === 'oauth' ? 'OAuth' : 'Basic'}
|
</SettingItem>
|
||||||
</Text>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Server">
|
{username && username !== email && (
|
||||||
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
|
<SettingItem label={t('settings.account.username_label', 'Username')}>
|
||||||
</SettingItem>
|
<Text style={styles.value}>{username}</Text>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{quotaTotal > 0 && (
|
<SettingItem label={t('settings.account.auth_method_label', 'Authentication')}>
|
||||||
<SettingItem
|
<Text style={styles.value}>
|
||||||
label="Storage"
|
{authMode === 'oauth'
|
||||||
description={`${formatFileSize(quotaUsed)} of ${formatFileSize(quotaTotal)} used`}
|
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
||||||
>
|
: t('settings.account.auth_method_basic', 'Password')}
|
||||||
<View style={styles.storage}>
|
</Text>
|
||||||
<Text style={styles.value}>{percent}%</Text>
|
</SettingItem>
|
||||||
<View style={styles.bar}>
|
|
||||||
<View style={[styles.barFill, { width: `${percent}%` }]} />
|
<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>
|
</View>
|
||||||
</View>
|
</SettingItem>
|
||||||
</SettingItem>
|
)}
|
||||||
)}
|
</SettingsSection>
|
||||||
|
|
||||||
{isDemoMode && (
|
<SettingsSection
|
||||||
<SettingItem label="Account Type">
|
title={t('settings.account.accounts.title', 'Logged-in accounts')}
|
||||||
<View style={styles.demoRow}>
|
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.demoDot} />
|
>
|
||||||
<Text style={styles.demoText}>Demo account</Text>
|
<View style={styles.list}>
|
||||||
|
{accounts.map((a, index) => {
|
||||||
|
const isActive = a.id === activeAccountId;
|
||||||
|
const isDefault = a.id === defaultAccountId;
|
||||||
|
const label = a.email || a.username;
|
||||||
|
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}>{a.displayName || a.username}</Text>
|
||||||
|
{isActive && <Check size={14} color={c.success} />}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowSub} numberOfLines={1}>{label}</Text>
|
||||||
|
{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, label)}
|
||||||
|
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_mobile', 'Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail.')}
|
||||||
|
>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{sharedAccounts.map((s, index) => (
|
||||||
|
<View key={s.id} style={[styles.row, index > 0 && styles.rowBorder]}>
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</SettingItem>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,20 +288,36 @@ function makeStyles(c: ThemePalette) {
|
|||||||
backgroundColor: c.primary,
|
backgroundColor: c.primary,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
demoRow: {
|
barFillWarn: { backgroundColor: c.warning },
|
||||||
|
list: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm + 2,
|
||||||
},
|
},
|
||||||
demoDot: {
|
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
|
||||||
width: 8,
|
avatar: {
|
||||||
height: 8,
|
width: 32,
|
||||||
borderRadius: 4,
|
height: 32,
|
||||||
backgroundColor: '#f59e0b',
|
borderRadius: radius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
demoText: {
|
avatarText: { ...typography.bodyMedium, color: '#ffffff' },
|
||||||
...typography.bodyMedium,
|
rowTitle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
color: '#fbbf24',
|
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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ interface AccountState {
|
|||||||
setActiveAccount: (accountId: string) => void;
|
setActiveAccount: (accountId: string) => void;
|
||||||
setDefaultAccount: (accountId: string) => void;
|
setDefaultAccount: (accountId: string) => void;
|
||||||
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
|
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => 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;
|
getAccountById: (accountId: string) => AccountEntry | undefined;
|
||||||
getActiveAccount: () => AccountEntry | null;
|
getActiveAccount: () => AccountEntry | null;
|
||||||
getDefaultAccount: () => AccountEntry | null;
|
getDefaultAccount: () => AccountEntry | null;
|
||||||
@@ -131,6 +134,21 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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),
|
getAccountById: (accountId) => get().accounts.find((a) => a.id === accountId),
|
||||||
getActiveAccount: () => {
|
getActiveAccount: () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user