246 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|