From 7cf6b4e6ae20f8813e054574cff4f8fc912f4e13 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 12 Mar 2026 01:08:16 +1100 Subject: [PATCH] multiuser --- index.html | 2 +- .../account-switcher/AccountSwitcher.tsx | 184 ++++++++++++ src/app/components/account-switcher/index.ts | 1 + .../permissions/Permissions.tsx | 273 +++++++++++++++--- src/app/pages/Router.tsx | 32 +- src/app/pages/auth/AuthLayout.tsx | 4 +- src/app/pages/auth/login/loginUtil.ts | 47 ++- src/app/pages/client/ClientRoot.tsx | 122 +++----- src/app/pages/client/sidebar/SettingsTab.tsx | 88 +++++- src/app/state/sessions.ts | 186 +++++++----- src/client/initMatrix.ts | 242 +++++++++++++--- src/index.css | 5 +- 12 files changed, 939 insertions(+), 247 deletions(-) create mode 100644 src/app/components/account-switcher/AccountSwitcher.tsx create mode 100644 src/app/components/account-switcher/index.ts diff --git a/index.html b/index.html index 0225230..c9f123c 100644 --- a/index.html +++ b/index.html @@ -85,7 +85,7 @@ href="./public/res/apple/apple-touch-icon-180x180.png" /> - + diff --git a/src/app/components/account-switcher/AccountSwitcher.tsx b/src/app/components/account-switcher/AccountSwitcher.tsx new file mode 100644 index 0000000..171e63c --- /dev/null +++ b/src/app/components/account-switcher/AccountSwitcher.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, MenuItem, config } from 'folds'; +import { useAtomValue } from 'jotai'; +import { sessionsAtom, Session } from '../../state/sessions'; +import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage'; +import { getMxIdServer } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; + +type AccountSwitcherProps = { + currentUserId?: string; +}; + +const deleteAllMatrixDatabases = async (): Promise => { + 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((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((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 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 ( + + + {hasMultipleAccounts ? 'Switch Account' : 'Accounts'} + + + {/* Show current account */} + {currentUserId && ( + } + > + + + + + {currentUserId} + + {getMxIdServer(currentUserId) && ( + + {getMxIdServer(currentUserId)} + + )} + + + + )} + + {/* Show other accounts if any */} + {otherSessions.map((session) => { + const server = getMxIdServer(session.userId); + + return ( + handleSwitchAccount(session)} + variant="Surface" + > + + + + + {session.userId} + + {server && ( + + {server} + + )} + + + + ); + })} + + ); +} diff --git a/src/app/components/account-switcher/index.ts b/src/app/components/account-switcher/index.ts new file mode 100644 index 0000000..5eac02f --- /dev/null +++ b/src/app/components/account-switcher/index.ts @@ -0,0 +1 @@ +export { AccountSwitcher } from './AccountSwitcher'; diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index 131bb3a..5697843 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -94,11 +94,79 @@ export function Permissions({ requestClose }: PermissionsProps) { let completed = 0; const total = syncableChildren.length; + const myUserId = mx.getSafeUserId(); for (const childId of syncableChildren) { try { - // Copy the space's power levels to the child - await mx.sendStateEvent(childId, StateEvent.RoomPowerLevels as any, powerLevels); + const childRoom = mx.getRoom(childId); + if (!childRoom) continue; + + // Get current power levels in the child room + const childPowerLevelsEvent = childRoom.currentState.getStateEvents(StateEvent.RoomPowerLevels, ''); + const childPowerLevels = childPowerLevelsEvent?.getContent() || {}; + + // Get my power level in the child room + const myPowerInChild = childPowerLevels.users?.[myUserId] ?? childPowerLevels.users_default ?? 0; + + // Build merged users: only sync users with power <= mine, preserve higher-powered users + const mergedUsers: Record = {}; + + // Copy users from parent where power <= my power in child + if (powerLevels.users) { + Object.entries(powerLevels.users).forEach(([userId, power]) => { + if (power <= myPowerInChild) { + mergedUsers[userId] = power; + } + }); + } + + // Preserve all users in child room who have power >= mine + if (childPowerLevels.users) { + Object.entries(childPowerLevels.users).forEach(([userId, power]) => { + if (power >= myPowerInChild) { + mergedUsers[userId] = power; + } + }); + } + + // Cap all power level values at my power level + const capPowerLevel = (value: any) => { + if (typeof value === 'number') { + return Math.min(value, myPowerInChild); + } + return value; + }; + + // Create the merged power levels with capped values + const mergedPowerLevels: any = { + ...powerLevels, + users: mergedUsers, + ban: capPowerLevel(powerLevels.ban), + kick: capPowerLevel(powerLevels.kick), + redact: capPowerLevel(powerLevels.redact), + invite: capPowerLevel(powerLevels.invite), + events_default: capPowerLevel(powerLevels.events_default), + state_default: capPowerLevel(powerLevels.state_default), + users_default: capPowerLevel(powerLevels.users_default), + }; + + // Cap event-specific power levels + if (powerLevels.events) { + mergedPowerLevels.events = {}; + Object.entries(powerLevels.events).forEach(([eventType, power]) => { + mergedPowerLevels.events[eventType] = capPowerLevel(power); + }); + } + + // Cap notification power levels + if (powerLevels.notifications) { + mergedPowerLevels.notifications = {}; + Object.entries(powerLevels.notifications).forEach(([key, power]) => { + mergedPowerLevels.notifications[key] = capPowerLevel(power); + }); + } + + await mx.sendStateEvent(childId, StateEvent.RoomPowerLevels as any, mergedPowerLevels); // Also copy power level tags if they exist if (powerLevelTags) { @@ -120,6 +188,99 @@ export function Permissions({ requestClose }: PermissionsProps) { }, [mx, syncableChildren, powerLevels, room]) ); + const [syncPowerLevelsState, syncPowerLevelsOnly] = useAsyncCallback( + useCallback(async () => { + let completed = 0; + const total = syncableChildren.length; + const myUserId = mx.getSafeUserId(); + + for (const childId of syncableChildren) { + try { + const childRoom = mx.getRoom(childId); + if (!childRoom) continue; + + // Get current power levels in the child room + const childPowerLevelsEvent = childRoom.currentState.getStateEvents(StateEvent.RoomPowerLevels, ''); + const childPowerLevels = childPowerLevelsEvent?.getContent() || {}; + + // Get my power level in the child room + const myPowerInChild = childPowerLevels.users?.[myUserId] ?? childPowerLevels.users_default ?? 0; + + // Build merged users: only sync users with power <= mine, preserve higher-powered users + const mergedUsers: Record = {}; + + // Copy users from parent where power <= my power in child + if (powerLevels.users) { + Object.entries(powerLevels.users).forEach(([userId, power]) => { + if (power <= myPowerInChild) { + mergedUsers[userId] = power; + } + }); + } + + // Preserve all users in child room who have power >= mine + if (childPowerLevels.users) { + Object.entries(childPowerLevels.users).forEach(([userId, power]) => { + if (power >= myPowerInChild) { + mergedUsers[userId] = power; + } + }); + } + + // Cap all power level values at my power level + const capPowerLevel = (value: any) => { + if (typeof value === 'number') { + return Math.min(value, myPowerInChild); + } + return value; + }; + + // Create the merged power levels with capped values + const mergedPowerLevels: any = { + ...powerLevels, + users: mergedUsers, + ban: capPowerLevel(powerLevels.ban), + kick: capPowerLevel(powerLevels.kick), + redact: capPowerLevel(powerLevels.redact), + invite: capPowerLevel(powerLevels.invite), + events_default: capPowerLevel(powerLevels.events_default), + state_default: capPowerLevel(powerLevels.state_default), + users_default: capPowerLevel(powerLevels.users_default), + }; + + // Cap event-specific power levels + if (powerLevels.events) { + mergedPowerLevels.events = {}; + Object.entries(powerLevels.events).forEach(([eventType, power]) => { + mergedPowerLevels.events[eventType] = capPowerLevel(power); + }); + } + + // Cap notification power levels + if (powerLevels.notifications) { + mergedPowerLevels.notifications = {}; + Object.entries(powerLevels.notifications).forEach(([key, power]) => { + mergedPowerLevels.notifications[key] = capPowerLevel(power); + }); + } + + await mx.sendStateEvent(childId, StateEvent.RoomPowerLevels as any, mergedPowerLevels); + + completed += 1; + + // Add a small delay to avoid rate limiting + if (completed < total) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } catch (error) { + console.error(`Failed to sync power levels to ${childId}:`, error); + } + } + + return { completed, total }; + }, [mx, syncableChildren, powerLevels]) + ); + const handleEditPowers = () => { setPowerEditor(true); }; @@ -128,6 +289,10 @@ export function Permissions({ requestClose }: PermissionsProps) { syncPermissions(); }; + const handleSyncPowerLevelsOnly = () => { + syncPowerLevelsOnly(); + }; + if (canEditPowers && powerEditor) { return setPowerEditor(false)} />; } @@ -136,6 +301,9 @@ export function Permissions({ requestClose }: PermissionsProps) { const isSyncing = syncState.status === AsyncStatus.Loading; const syncSuccess = syncState.status === AsyncStatus.Success; const syncError = syncState.status === AsyncStatus.Error; + const isPowerLevelsSyncing = syncPowerLevelsState.status === AsyncStatus.Loading; + const powerLevelsSyncSuccess = syncPowerLevelsState.status === AsyncStatus.Success; + const powerLevelsSyncError = syncPowerLevelsState.status === AsyncStatus.Error; return ( @@ -157,7 +325,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - {canEditPermissions && hasSyncableChildren && ( + {syncableChildren.length > 0 && ( Sync Permissions @@ -180,38 +348,73 @@ export function Permissions({ requestClose }: PermissionsProps) { )} - - - {syncSuccess && ( - - Successfully synced permissions to {syncState.data.completed} of{' '} - {syncState.data.total} children - - )} - {syncError && ( - - Error syncing permissions: {syncState.error.message} - - )} + + + + {syncSuccess && ( + + Successfully synced permissions to {syncState.data.completed} of{' '} + {syncState.data.total} children + + )} + {syncError && ( + + Error syncing permissions: {syncState.error.message} + + )} + + + + {powerLevelsSyncSuccess && ( + + Successfully synced power levels to {syncPowerLevelsState.data.completed} of{' '} + {syncPowerLevelsState.data.total} children + + )} + {powerLevelsSyncError && ( + + Error syncing power levels: {syncPowerLevelsState.error.message} + + )} + diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index fc9ea1a..dd68f4d 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -67,7 +67,7 @@ import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; -import { getFallbackSession } from '../state/sessions'; +import { getCurrentSession } from '../state/sessions'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -85,15 +85,37 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) { - if (getFallbackSession()) return redirect(getHomePath()); + if (getCurrentSession()) return redirect(getHomePath()); const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href); if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); return redirect(getLoginPath()); }} /> { - if (getFallbackSession()) { + loader={({ request }) => { + const url = new URL(request.url); + const addingAccount = url.searchParams.get('add-account') === 'true'; + const hasSession = !!getCurrentSession(); + + // Log to localStorage for persistence across reloads + const debugInfo = { + timestamp: new Date().toISOString(), + url: url.toString(), + pathname: url.pathname, + search: url.search, + hash: url.hash, + addingAccount, + hasSession, + willRedirect: hasSession && !addingAccount + }; + console.log('[Router] Auth loader:', debugInfo); + const debugLog = JSON.parse(localStorage.getItem('debug-router-logs') || '[]'); + debugLog.push(debugInfo); + if (debugLog.length > 10) debugLog.shift(); // Keep last 10 + localStorage.setItem('debug-router-logs', JSON.stringify(debugLog)); + + if (hasSession && !addingAccount) { + console.log('[Router] Redirecting to home because session exists and not adding account'); return redirect(getHomePath()); } @@ -113,7 +135,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) { - if (!getFallbackSession()) { + if (!getCurrentSession()) { const afterLoginPath = getAppPathFromHref( getOriginBaseUrl(hashRouter), window.location.href diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 6e787c0..44cc588 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -100,7 +100,7 @@ export function AuthLayout() { navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(server), - }), + }) + location.search, { replace: true } ); } @@ -114,7 +114,7 @@ export function AuthLayout() { return; } navigate( - generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) }) + generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) }) + location.search ); }, [navigate, location, discoveryState, server, discoverServer] diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index d03aa20..65fb17c 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -11,6 +11,7 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; +import { getLocalStorageItem, setLocalStorageItem } from '../../../state/utils/atomWithLocalStorage'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -114,17 +115,51 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { useEffect(() => { if (data) { const { response: loginRes, baseUrl: loginBaseUrl } = data; - setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); + + // Add session to sessions list + const sessions = getLocalStorageItem('matrixSessions', []); + + const newSession = { + baseUrl: loginBaseUrl, + userId: loginRes.user_id, + deviceId: loginRes.device_id, + accessToken: loginRes.access_token, + expiresInMs: loginRes.expires_in_ms, + refreshToken: loginRes.refresh_token, + fallbackSdkStores: false, + }; + + // Check if session already exists (by userId AND deviceId to avoid conflicts) + const existingIndex = sessions.findIndex( + (s: any) => s.userId === loginRes.user_id && s.deviceId === loginRes.device_id + ); + if (existingIndex >= 0) { + // Preserve fallbackSdkStores if it was set, but never enable it for new logins + const oldSession = sessions[existingIndex]; + sessions[existingIndex] = { + ...newSession, + fallbackSdkStores: oldSession.fallbackSdkStores === true ? true : false, + }; + } else { + sessions.push(newSession); + } + + setLocalStorageItem('matrixSessions', sessions); + setLocalStorageItem('currentSessionUserId', loginRes.user_id); + const afterLoginRedirectUrl = getAfterLoginRedirectPath(); deleteAfterLoginRedirectPath(); const targetPath = afterLoginRedirectUrl ?? getHomePath(); - console.log('[Login] Navigating after login:', { + console.log('[Login] Login complete, reloading to initialize client:', { targetPath, - afterLoginRedirectUrl, - homePath: getHomePath(), - currentLocation: window.location.href, + userId: loginRes.user_id, + deviceId: loginRes.device_id, + isMultiAccount: sessions.length > 1, }); - navigate(targetPath, { replace: true }); + + // Reload to the target path + // For hash router, we need to use just the origin without /login pathname + window.location.href = window.location.origin + '/#' + targetPath; } }, [data, navigate]); }; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index c2547f8..b116237 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -33,11 +33,13 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; -import { getFallbackSession } from '../../state/sessions'; +import { getCurrentSession } from '../../state/sessions'; import { CallProviderWrapper } from '../../features/call/CallProviderWrapper'; import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri'; import { TitleBar } from '../../components/title-bar'; import { initPaarrotAPI } from '../../paarrot-api'; +import { AccountSwitcher } from '../../components/account-switcher'; +import { getLoginPath, withSearchParam } from '../pathUtils'; function ClientRootLoading() { return ( @@ -50,81 +52,6 @@ function ClientRootLoading() { ); } -function ClientRootOptions({ mx }: { mx?: MatrixClient }) { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleToggle: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - {mx && ( - clearCacheAndReload(mx)} size="300" radii="300"> - - Clear Cache and Reload - - - )} - { - if (mx) { - logoutClient(mx); - return; - } - clearLoginData(); - }} - size="300" - radii="300" - variant="Critical" - fill="None" - > - - Logout - - - - - - } - /> - - ); -} - const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { @@ -230,11 +157,12 @@ type ClientRootProps = { }; export function ClientRoot({ children }: ClientRootProps) { const [loading, setLoading] = useState(true); - const { baseUrl } = getFallbackSession() ?? {}; + const [syncError, setSyncError] = useState(null); + const { baseUrl } = getCurrentSession() ?? {}; const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => { - const session = getFallbackSession(); + const session = getCurrentSession(); if (!session) { throw new Error('No session Found!'); } @@ -269,11 +197,29 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [mx, startMatrix]); + // Timeout if sync takes too long + useEffect(() => { + if (!mx || !loading) return; + + const timeout = setTimeout(() => { + console.error('[ClientRoot] Sync timeout - client failed to reach PREPARED state'); + setSyncError(new Error('Sync timeout - failed to connect to homeserver')); + }, 30000); // 30 second timeout + + return () => clearTimeout(timeout); + }, [mx, loading]); + useSyncState( mx, - useCallback((state) => { + useCallback((state, prevState, data) => { + console.log('[ClientRoot] Sync state changed:', state, 'Error:', data); if (state === 'PREPARED') { setLoading(false); + setSyncError(null); + } else if (state === 'ERROR') { + const error = data instanceof Error ? data : new Error('Sync failed'); + console.error('[ClientRoot] Sync error:', error); + setSyncError(error); } }, []) ); @@ -281,7 +227,6 @@ export function ClientRoot({ children }: ClientRootProps) { return ( - {loading && } {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( @@ -303,6 +248,23 @@ export function ClientRoot({ children }: ClientRootProps) { )} + {syncError && ( + + + + + {`Sync failed: ${syncError.message}`} + Check your internet connection and homeserver status. + + + + + + )} {loading || !mx ? ( ) : ( diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index bb21218..5063c02 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,5 +1,7 @@ -import React, { useState } from 'react'; -import { Text } from 'folds'; +import React, { MouseEventHandler, useState } from 'react'; +import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useNavigate } from 'react-router-dom'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -9,14 +11,20 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; +import { AccountSwitcher } from '../../../components/account-switcher'; +import { stopPropagation } from '../../../utils/keyboard'; +import { getLoginPath, withSearchParam } from '../../pathUtils'; +import { logoutClient } from '../../../../client/initMatrix'; export function SettingsTab() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const userId = mx.getUserId()!; const profile = useUserProfile(userId); + const navigate = useNavigate(); const [settings, setSettings] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(); const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl @@ -26,11 +34,22 @@ export function SettingsTab() { const openSettings = () => setSettings(true); const closeSettings = () => setSettings(false); + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor(cords); + }; + return ( {(triggerRef) => ( - + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + { + setMenuAnchor(undefined); + const loginPath = getLoginPath() + '?add-account=true'; + console.log('[SettingsTab] Navigating to add account:', loginPath); + localStorage.setItem('debug-add-account-nav', JSON.stringify({ + timestamp: new Date().toISOString(), + loginPath, + currentUrl: window.location.href + })); + navigate(loginPath); + }} + size="300" + radii="300" + > + + + + Add Account + + + + { + setMenuAnchor(undefined); + logoutClient(mx); + }} + size="300" + radii="300" + variant="Critical" + fill="None" + > + + Logout + + + + + + } + /> + {settings && ( diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index a9c34ce..b61da40 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -1,9 +1,9 @@ -// import { atom } from 'jotai'; -// import { -// atomWithLocalStorage, -// getLocalStorageItem, -// setLocalStorageItem, -// } from './utils/atomWithLocalStorage'; +import { atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; export type Session = { baseUrl: string; @@ -24,10 +24,10 @@ export type SessionStoreName = { /** * Migration code for old session */ -// const FALLBACK_STORE_NAME: SessionStoreName = { -// sync: 'web-sync-store', -// crypto: 'crypto-store', -// } as const; +const FALLBACK_STORE_NAME: SessionStoreName = { + sync: 'web-sync-store', + crypto: 'crypto-store', +} as const; export function setFallbackSession( accessToken: string, @@ -70,71 +70,111 @@ export const getFallbackSession = (): Session | undefined => { * End of migration code for old session */ -// export const getSessionStoreName = (session: Session): SessionStoreName => { -// if (session.fallbackSdkStores) { -// return FALLBACK_STORE_NAME; -// } +export const getSessionStoreName = (session: Session): SessionStoreName => { + if (session.fallbackSdkStores) { + return FALLBACK_STORE_NAME; + } -// return { -// sync: `sync${session.userId}`, -// crypto: `crypto${session.userId}`, -// }; -// }; + return { + sync: `sync@${session.userId}@${session.deviceId}`, + crypto: `crypto@${session.userId}@${session.deviceId}`, + }; +}; -// export const MATRIX_SESSIONS_KEY = 'matrixSessions'; -// const baseSessionsAtom = atomWithLocalStorage( -// MATRIX_SESSIONS_KEY, -// (key) => { -// const defaultSessions: Sessions = []; -// const sessions = getLocalStorageItem(key, defaultSessions); +export const MATRIX_SESSIONS_KEY = 'matrixSessions'; +const baseSessionsAtom = atomWithLocalStorage( + MATRIX_SESSIONS_KEY, + (key) => { + const defaultSessions: Sessions = []; + const sessions = getLocalStorageItem(key, defaultSessions); -// // Before multi account support session was stored -// // as multiple item in local storage. -// // So we need these migration code. -// const fallbackSession = getFallbackSession(); -// if (fallbackSession) { -// removeFallbackSession(); -// sessions.push(fallbackSession); -// setLocalStorageItem(key, sessions); -// } -// return sessions; -// }, -// (key, value) => { -// setLocalStorageItem(key, value); -// } -// ); + // Before multi account support session was stored + // as multiple item in local storage. + // So we need these migration code. + const fallbackSession = getFallbackSession(); + if (fallbackSession && sessions.length === 0) { + // Only use fallback stores if this is the ONLY session + // This prevents multiple accounts from trying to use the same crypto-store + removeFallbackSession(); + sessions.push(fallbackSession); + setLocalStorageItem(key, sessions); + } else if (fallbackSession && sessions.length > 0) { + // If there are already sessions, add the fallback but don't use fallback stores + removeFallbackSession(); + const fallbackWithoutStoreFlag = { ...fallbackSession, fallbackSdkStores: false }; + // Check if this session already exists (by userId + deviceId) + const exists = sessions.some( + s => s.userId === fallbackSession.userId && s.deviceId === fallbackSession.deviceId + ); + if (!exists) { + sessions.push(fallbackWithoutStoreFlag); + setLocalStorageItem(key, sessions); + } + } + return sessions; + }, + (key, value) => { + setLocalStorageItem(key, value); + } +); -// export type SessionsAction = -// | { -// type: 'PUT'; -// session: Session; -// } -// | { -// type: 'DELETE'; -// session: Session; -// }; +export type SessionsAction = + | { + type: 'PUT'; + session: Session; + } + | { + type: 'DELETE'; + session: Session; + }; -// export const sessionsAtom = atom( -// (get) => get(baseSessionsAtom), -// (get, set, action) => { -// if (action.type === 'PUT') { -// const sessions = [...get(baseSessionsAtom)]; -// const sessionIndex = sessions.findIndex( -// (session) => session.userId === action.session.userId -// ); -// if (sessionIndex === -1) { -// sessions.push(action.session); -// } else { -// sessions.splice(sessionIndex, 1, action.session); -// } -// set(baseSessionsAtom, sessions); -// return; -// } -// if (action.type === 'DELETE') { -// const sessions = get(baseSessionsAtom).filter( -// (session) => session.userId !== action.session.userId -// ); -// set(baseSessionsAtom, sessions); -// } -// } -// ); +export const sessionsAtom = atom( + (get) => get(baseSessionsAtom), + (get, set, action) => { + if (action.type === 'PUT') { + const sessions = [...get(baseSessionsAtom)]; + const sessionIndex = sessions.findIndex( + (session) => session.userId === action.session.userId && session.deviceId === action.session.deviceId + ); + if (sessionIndex === -1) { + sessions.push(action.session); + } else { + sessions.splice(sessionIndex, 1, action.session); + } + set(baseSessionsAtom, sessions); + return; + } + if (action.type === 'DELETE') { + const sessions = get(baseSessionsAtom).filter( + (session) => !(session.userId === action.session.userId && session.deviceId === action.session.deviceId) + ); + set(baseSessionsAtom, sessions); + } + } +); + +// Current selected session +export const CURRENT_SESSION_KEY = 'currentSessionUserId'; +export const currentSessionAtom = atomWithLocalStorage( + CURRENT_SESSION_KEY, + (key) => getLocalStorageItem(key, undefined), + (key, value) => setLocalStorageItem(key, value) +); + +// Helper to get current session from sessions list +export const getCurrentSession = (): Session | undefined => { + const currentUserId = getLocalStorageItem(CURRENT_SESSION_KEY, undefined); + if (!currentUserId) { + // Fallback to old session for migration + return getFallbackSession(); + } + + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + return sessions.find(s => s.userId === currentUserId); +}; + +// Helper to get a session by userId +export const getSessionByUserId = (userId: string): Session | undefined => { + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + return sessions.find(s => s.userId === userId); +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 8cfe09b..95ea9a1 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -2,52 +2,177 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; +import { Session, getSessionStoreName } from '../app/state/sessions'; -type Session = { - baseUrl: string; - accessToken: string; - userId: string; - deviceId: string; +const deleteIndexedDB = (dbName: string): Promise => { + return new Promise((resolve, reject) => { + const request = global.indexedDB.deleteDatabase(dbName); + request.onsuccess = () => { + console.log(`[initMatrix] Deleted IndexedDB database: ${dbName}`); + resolve(); + }; + request.onerror = () => { + console.error(`[initMatrix] Failed to delete IndexedDB database: ${dbName}`, request.error); + reject(request.error); + }; + request.onblocked = () => { + console.warn(`[initMatrix] Delete blocked for IndexedDB database: ${dbName}`); + // Resolve anyway as the delete might succeed later + resolve(); + }; + }); +}; + +const getAllIndexedDBNames = async (): Promise => { + try { + if ('databases' in global.indexedDB) { + // Add timeout to prevent hanging + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn('[initMatrix] indexedDB.databases() timed out after 2s'); + resolve([]); + }, 2000); + }); + + const databasesPromise = (global.indexedDB as any).databases().then((dbs: any[]) => { + return dbs.map((db: any) => db.name).filter(Boolean); + }); + + return await Promise.race([databasesPromise, timeoutPromise]); + } + } catch (error) { + console.warn('[initMatrix] Error getting IndexedDB names:', error); + } + // Fallback for browsers that don't support indexedDB.databases() + return []; +}; + +const deleteAllMatrixDatabases = async (): Promise => { + console.log('[initMatrix] Clearing all Matrix-related IndexedDB databases...'); + + const dbNames = await getAllIndexedDBNames(); + console.log('[initMatrix] Found databases:', dbNames); + + // Delete all Matrix-related databases (with matrix-js-sdk: prefix) + const matrixDbNames = dbNames.filter(name => + name.startsWith('matrix-js-sdk:') || + name.startsWith('crypto@') || + name.startsWith('sync@') || + name === 'crypto-store' || + name === 'web-sync-store' + ); + + console.log('[initMatrix] Deleting Matrix databases:', matrixDbNames); + + for (const dbName of matrixDbNames) { + try { + await deleteIndexedDB(dbName); + } catch (error) { + console.error(`[initMatrix] Failed to delete ${dbName}:`, error); + } + } }; export const initClient = async (session: Session): Promise => { - const indexedDBStore = new IndexedDBStore({ - indexedDB: global.indexedDB, - localStorage: global.localStorage, - dbName: 'web-sync-store', - }); - - const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, 'crypto-store'); - - const mx = createClient({ - baseUrl: session.baseUrl, - accessToken: session.accessToken, - userId: session.userId, - store: indexedDBStore, - cryptoStore: legacyCryptoStore, - deviceId: session.deviceId, - timelineSupport: true, - cryptoCallbacks: cryptoCallbacks as any, - verificationMethods: ['m.sas.v1'], - }); - - await indexedDBStore.startup(); - await mx.initRustCrypto(); - - mx.setMaxListeners(50); + const storeName = getSessionStoreName(session); - // Increase max listeners for User objects to prevent warnings when many components - // subscribe to presence events (e.g., in member lists) - const originalGetUser = mx.getUser.bind(mx); - mx.getUser = (userId: string) => { - const user = originalGetUser(userId); - if (user) { - user.setMaxListeners(50); - } - return user; - }; + console.log('[initMatrix] ========================================'); + console.log('[initMatrix] Initializing Matrix client for session:'); + console.log('[initMatrix] User ID:', session.userId); + console.log('[initMatrix] Device ID:', session.deviceId); + console.log('[initMatrix] Using fallback stores:', session.fallbackSdkStores || false); + console.log('[initMatrix] Sync store name:', storeName.sync); + console.log('[initMatrix] Crypto store name:', storeName.crypto); + console.log('[initMatrix] ========================================'); + + console.log('[initMatrix] Creating Matrix client...'); + + try { + const indexedDBStore = new IndexedDBStore({ + indexedDB: global.indexedDB, + localStorage: global.localStorage, + dbName: storeName.sync, + }); - return mx; + const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, storeName.crypto); + + const mx = createClient({ + baseUrl: session.baseUrl, + accessToken: session.accessToken, + userId: session.userId, + store: indexedDBStore, + cryptoStore: legacyCryptoStore, + deviceId: session.deviceId, + timelineSupport: true, + cryptoCallbacks: cryptoCallbacks as any, + verificationMethods: ['m.sas.v1'], + }); + + await indexedDBStore.startup(); + + // Initialize Rust crypto with a unique store prefix for this account + // This ensures each account has its own isolated crypto database + console.log('[initMatrix] Initializing Rust crypto with prefix:', `${session.userId}@${session.deviceId}`); + await mx.initRustCrypto({ + storagePrefix: `${session.userId}@${session.deviceId}`, + }); + + // List databases after init to verify isolation + try { + const dbsAfterInit = await getAllIndexedDBNames(); + console.log('[initMatrix] 📦 Databases after initialization:', dbsAfterInit); + + // Check if a shared crypto database was created despite storagePrefix + const hasSharedCrypto = dbsAfterInit.includes('matrix-js-sdk::matrix-sdk-crypto'); + if (hasSharedCrypto) { + console.warn('[initMatrix] ⚠️ WARNING: Shared crypto database still exists after init!'); + console.warn('[initMatrix] The storagePrefix option may not be working correctly.'); + } + + // List account-specific databases + const accountDbs = dbsAfterInit.filter(db => + db.includes(session.userId) || db.includes(session.deviceId) + ); + console.log('[initMatrix] 📁 Account-specific databases:', accountDbs); + } catch (e) { + console.log('[initMatrix] Could not list databases after init:', e); + } + + console.log('[initMatrix] ✅ Client initialized successfully!'); + + mx.setMaxListeners(50); + + // Increase max listeners for User objects to prevent warnings when many components + // subscribe to presence events (e.g., in member lists) + const originalGetUser = mx.getUser.bind(mx); + mx.getUser = (userId: string) => { + const user = originalGetUser(userId); + if (user) { + user.setMaxListeners(50); + } + return user; + }; + + return mx; + } catch (error: any) { + // Handle crypto store account mismatch error + if (error && error.message && error.message.includes("account in the store doesn't match")) { + console.error('[initMatrix] ❌ Crypto store account mismatch detected!'); + console.error('[initMatrix] Error details:', error.message); + console.error('[initMatrix] Expected account:', session.userId, session.deviceId); + + // This shouldn't happen anymore since we delete the shared db before init + // If it does happen, it means there's a different issue + throw new Error( + `❌ Crypto store conflict detected.\n\n` + + `This account's crypto store contains data from a different account.\n` + + `Please clear site data in DevTools (F12 → Application → Storage → Clear site data)` + ); + } + + // Re-throw other errors + throw error; + } }; export const startClient = async (mx: MatrixClient) => { @@ -71,7 +196,44 @@ export const logoutClient = async (mx: MatrixClient) => { // ignore if failed to logout } await mx.clearStores(); + + // Import sessions management + const { sessionsAtom, currentSessionAtom, getSessionByUserId, getCurrentSession } = await import('../app/state/sessions'); + const { getLocalStorageItem, setLocalStorageItem } = await import('../app/state/utils/atomWithLocalStorage'); + + const userId = mx.getSafeUserId(); + const allSessions = getLocalStorageItem('matrixSessions', []); + const currentUserId = getLocalStorageItem('currentSessionUserId', undefined); + + // Remove the current session + const remainingSessions = allSessions.filter((s: any) => s.userId !== userId); + setLocalStorageItem('matrixSessions', remainingSessions); + + // If we're logging out the current session and there are other sessions, switch to the first one + if (remainingSessions.length > 0 && currentUserId === userId) { + setLocalStorageItem('currentSessionUserId', remainingSessions[0].userId); + window.location.reload(); + } else if (remainingSessions.length === 0) { + // No more sessions, clear everything + window.localStorage.clear(); + window.location.reload(); + } else { + // We logged out a non-current session, just reload + window.location.reload(); + } +}; + +export const logoutAllSessions = async () => { window.localStorage.clear(); + + // Clear all IndexedDB databases + const dbs = await window.indexedDB.databases(); + for (const idbInfo of dbs) { + if (idbInfo.name) { + window.indexedDB.deleteDatabase(idbInfo.name); + } + } + window.location.reload(); }; diff --git a/src/index.css b/src/index.css index 5a62805..cbc66aa 100644 --- a/src/index.css +++ b/src/index.css @@ -86,7 +86,7 @@ body { font-size: 16px; font-weight: 400; /* Default to dark theme background for safe areas */ - background-color: #1A1A1A; + background-color: #262626; /*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */ font-variant-ligatures: no-contextual; @@ -100,7 +100,7 @@ body.silver-theme { background-color: #DEDEDE; } body.dark-theme { - background-color: #1A1A1A; + background-color: #262626; } body.butter-theme { background-color: #1A1916; @@ -116,6 +116,7 @@ body.discord-darker-theme { height: 100%; display: flex; flex-direction: column; + background-color: #262626; } *,