Files
cinny/src/app/components/account-switcher/AccountSwitcher.tsx

246 lines
8.3 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Avatar, Box, MenuItem, Text, config } from 'folds';
import { Icon, Icons } from '../icons';
import { useAtomValue } from 'jotai';
import { sessionsAtom, Session } from '../../state/sessions';
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../user-avatar';
type AccountSwitcherProps = {
currentUserId?: string;
};
/**
* Loads and displays an avatar for a session that is not the active client.
* Fetches the profile directly via the homeserver REST API using stored credentials.
*/
function OtherSessionAvatar({ session }: { session: Session }) {
const [src, setSrc] = useState<string | undefined>();
useEffect(() => {
const load = async () => {
try {
const res = await fetch(
`${session.baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.userId)}`,
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
);
if (!res.ok) return;
const data = await res.json();
if (data.avatar_url) {
const match = (data.avatar_url as string).match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (match) {
setSrc(
`${session.baseUrl}/_matrix/media/v3/thumbnail/${match[1]}/${match[2]}?width=32&height=32&method=crop`
);
}
}
} catch {
// ignore — avatar just won't show
}
};
load();
}, [session.userId, session.baseUrl, session.accessToken]);
return (
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={session.userId}
src={src}
alt={session.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
);
}
const deleteAllMatrixDatabases = async (): Promise<void> => {
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
// List of known Matrix database names/patterns to delete
const dbsToDelete = [
'matrix-js-sdk::matrix-sdk-crypto', // Shared crypto store (causes conflicts)
];
// Try to get all databases if the API is available
try {
if ('databases' in indexedDB) {
const allDbs = await (indexedDB as any).databases();
const matrixDbs = allDbs
.map((db: any) => db.name)
.filter((name: string) =>
name && (
name.startsWith('matrix-js-sdk') ||
name.includes('@@') ||
name.startsWith('sync@@') ||
name.startsWith('crypto@@')
)
);
dbsToDelete.push(...matrixDbs);
console.log('[AccountSwitcher] Found Matrix databases:', matrixDbs);
}
} catch (e) {
console.warn('[AccountSwitcher] Could not enumerate databases:', e);
}
// Delete each database with timeout
const deletePromises = dbsToDelete.map(async (dbName) => {
try {
const deletePromise = new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => {
console.log(`[AccountSwitcher] ✅ Deleted database: ${dbName}`);
resolve();
};
request.onerror = () => {
console.warn(`[AccountSwitcher] ⚠️ Failed to delete: ${dbName}`, request.error);
resolve(); // Don't reject, just continue
};
request.onblocked = () => {
console.warn(`[AccountSwitcher] ⚠️ Delete blocked: ${dbName}`);
resolve(); // Don't reject, reload will handle it
};
});
// Add 1-second timeout per database
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
console.warn(`[AccountSwitcher] ⏱️ Delete timeout: ${dbName}`);
resolve();
}, 1000);
});
await Promise.race([deletePromise, timeoutPromise]);
} catch (error) {
console.warn(`[AccountSwitcher] Error deleting ${dbName}:`, error);
}
});
await Promise.all(deletePromises);
console.log('[AccountSwitcher] ✅ Database cleanup complete');
};
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
const sessions = useAtomValue(sessionsAtom);
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const currentProfile = useUserProfile(currentUserId ?? '');
const currentAvatarUrl = currentProfile.avatarUrl
? mxcUrlToHttp(mx, currentProfile.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const handleSwitchAccount = async (session: Session) => {
if (session.userId === currentUserId) return;
console.log('[AccountSwitcher] Switching account from', currentUserId, 'to', session.userId);
// Stop the current client before switching
if (mx) {
console.log('[AccountSwitcher] Stopping current Matrix client...');
try {
mx.stopClient();
console.log('[AccountSwitcher] Matrix client stopped');
} catch (error) {
console.error('[AccountSwitcher] Error stopping client:', error);
}
}
// Set the new current session
setLocalStorageItem('currentSessionUserId', session.userId);
// Delete all Matrix databases to prevent conflicts
await deleteAllMatrixDatabases();
// Small delay to ensure cleanup is complete
await new Promise(resolve => setTimeout(resolve, 100));
// Reload the page to initialize the new session
console.log('[AccountSwitcher] Reloading page...');
window.location.reload();
};
// If no current user, don't show anything
if (!currentUserId) {
return null;
}
// Filter out other sessions (not current)
const otherSessions = sessions.filter(s => s.userId !== currentUserId);
const hasMultipleAccounts = sessions.length > 1;
return (
<Box direction="Column" gap="200">
<Text size="L400" style={{ paddingInline: config.space.S200, paddingBlock: config.space.S100 }}>
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
</Text>
{/* Show current account */}
{currentUserId && (
<MenuItem
key={currentUserId}
size="300"
radii="300"
variant="SurfaceVariant"
disabled
style={{ paddingInline: config.space.S200 }}
after={<Icon size="100" src={Icons.Check} filled />}
>
<Box gap="200" alignItems="Center">
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={currentUserId}
src={currentAvatarUrl}
alt={currentUserId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{currentUserId}
</Text>
{getMxIdServer(currentUserId) && (
<Text size="T200" priority="300" truncate>
{getMxIdServer(currentUserId)}
</Text>
)}
</Box>
</Box>
</MenuItem>
)}
{/* Show other accounts if any */}
{otherSessions.map((session) => {
const server = getMxIdServer(session.userId);
return (
<MenuItem
key={session.userId}
size="300"
radii="300"
onClick={() => handleSwitchAccount(session)}
variant="Surface"
style={{ paddingInline: config.space.S200 }}
>
<Box gap="200" alignItems="Center">
<OtherSessionAvatar session={session} />
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{session.userId}
</Text>
{server && (
<Text size="T200" priority="300" truncate>
{server}
</Text>
)}
</Box>
</Box>
</MenuItem>
);
})}
</Box>
);
}