feat: implement account switcher component and state management
- Add AccountSwitcher component for managing user accounts with UI for switching, adding, and logging out. - Create account state manager to handle snapshots of account-specific states for efficient switching. - Introduce utility functions for account management, including ID generation and avatar color assignment. - Implement Zustand store for account management, supporting addition, removal, and state retrieval of accounts.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Manages per-account state snapshots for fast switching.
|
||||
* When user switches from Account A → B, we snapshot A's store state
|
||||
* into memory, clear stores, then restore B's cached state.
|
||||
*/
|
||||
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
|
||||
// Minimal snapshot shapes — we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type StoreSnapshot = Record<string, any>;
|
||||
|
||||
interface AccountSnapshot {
|
||||
email: StoreSnapshot;
|
||||
contact: StoreSnapshot;
|
||||
calendar: StoreSnapshot;
|
||||
filter: StoreSnapshot;
|
||||
identity: StoreSnapshot;
|
||||
vacation: StoreSnapshot;
|
||||
}
|
||||
|
||||
const cache = new Map<string, AccountSnapshot>();
|
||||
|
||||
/** Capture current store states for the given account */
|
||||
export function snapshotAccount(accountId: string): void {
|
||||
const emailState = useEmailStore.getState();
|
||||
const contactState = useContactStore.getState();
|
||||
const calendarState = useCalendarStore.getState();
|
||||
const filterState = useFilterStore.getState();
|
||||
const identityState = useIdentityStore.getState();
|
||||
const vacationState = useVacationStore.getState();
|
||||
|
||||
cache.set(accountId, {
|
||||
email: {
|
||||
emails: emailState.emails,
|
||||
mailboxes: emailState.mailboxes,
|
||||
selectedEmail: emailState.selectedEmail,
|
||||
selectedMailbox: emailState.selectedMailbox,
|
||||
searchQuery: emailState.searchQuery,
|
||||
quota: emailState.quota,
|
||||
},
|
||||
contact: {
|
||||
contacts: contactState.contacts,
|
||||
addressBooks: contactState.addressBooks,
|
||||
supportsSync: contactState.supportsSync,
|
||||
},
|
||||
calendar: {
|
||||
calendars: calendarState.calendars,
|
||||
events: calendarState.events,
|
||||
selectedCalendarIds: calendarState.selectedCalendarIds,
|
||||
viewMode: calendarState.viewMode,
|
||||
supportsCalendar: calendarState.supportsCalendar,
|
||||
},
|
||||
filter: {
|
||||
rules: filterState.rules,
|
||||
isSupported: filterState.isSupported,
|
||||
},
|
||||
identity: {
|
||||
identities: identityState.identities,
|
||||
preferredPrimaryId: identityState.preferredPrimaryId,
|
||||
},
|
||||
vacation: {
|
||||
isEnabled: vacationState.isEnabled,
|
||||
isSupported: vacationState.isSupported,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore cached store states for the given account. Returns false if no cache exists. */
|
||||
export function restoreAccount(accountId: string): boolean {
|
||||
const snapshot = cache.get(accountId);
|
||||
if (!snapshot) return false;
|
||||
|
||||
useEmailStore.setState(snapshot.email);
|
||||
useContactStore.setState(snapshot.contact);
|
||||
useCalendarStore.setState(snapshot.calendar);
|
||||
useFilterStore.setState(snapshot.filter);
|
||||
useIdentityStore.setState(snapshot.identity);
|
||||
useVacationStore.setState(snapshot.vacation);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Clear all stores (used before restoring a different account) */
|
||||
export function clearAllStores(): void {
|
||||
useEmailStore.setState({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
quota: null,
|
||||
});
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
useContactStore.getState().clearContacts();
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
export function evictAccount(accountId: string): void {
|
||||
cache.delete(accountId);
|
||||
}
|
||||
|
||||
/** Evict all cached states */
|
||||
export function evictAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Utilities for multi-account support:
|
||||
* - Account ID generation
|
||||
* - Deterministic avatar colors
|
||||
* - Account-scoped localStorage keys
|
||||
*/
|
||||
|
||||
/** Generate a unique, deterministic account ID from username and server URL */
|
||||
export function generateAccountId(username: string, serverUrl: string): string {
|
||||
const host = new URL(serverUrl).hostname;
|
||||
return `${username}@${host}`;
|
||||
}
|
||||
|
||||
/** Deterministic avatar/accent color from an email string */
|
||||
export function generateAvatarColor(email: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < email.length; i++) {
|
||||
hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
|
||||
}
|
||||
// 12 distinct, accessible hues
|
||||
const colors = [
|
||||
'#2563eb', // blue
|
||||
'#7c3aed', // violet
|
||||
'#db2777', // pink
|
||||
'#dc2626', // red
|
||||
'#ea580c', // orange
|
||||
'#d97706', // amber
|
||||
'#65a30d', // lime
|
||||
'#16a34a', // green
|
||||
'#0d9488', // teal
|
||||
'#0891b2', // cyan
|
||||
'#6366f1', // indigo
|
||||
'#9333ea', // purple
|
||||
];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/** Get initials for an avatar from a display name or email */
|
||||
export function getInitials(name: string, email?: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0][0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
if (email) {
|
||||
return email[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
/** Build an account-scoped localStorage key */
|
||||
export function getAccountScopedKey(baseKey: string, accountId: string): string {
|
||||
return `${baseKey}::${accountId}`;
|
||||
}
|
||||
|
||||
/** Maximum number of accounts allowed */
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
@@ -1,2 +1,7 @@
|
||||
export const SESSION_COOKIE = 'jmap_session';
|
||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||
|
||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||
export function sessionCookieName(slot: number): string {
|
||||
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export const OAUTH_SCOPES = 'openid email profile';
|
||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||
|
||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||
export function refreshTokenCookieName(slot: number): string {
|
||||
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user