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 (
+
+ );
+ })}
+
+ );
+}
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) {
>
)}
-
-
- ) : (
-
- )
- }
- >
-
- {isSyncing
- ? 'Syncing...'
- : `Sync to ${syncableChildren.length} ${
- syncableChildren.length === 1 ? 'Child' : 'Children'
- }${nonSyncableChildren.length > 0 ? ` (${nonSyncableChildren.length} skipped)` : ''}`}
-
-
- {syncSuccess && (
-
- Successfully synced permissions to {syncState.data.completed} of{' '}
- {syncState.data.total} children
-
- )}
- {syncError && (
-
- Error syncing permissions: {syncState.error.message}
-
- )}
+
+
+
+ ) : (
+
+ )
+ }
+ >
+
+ {isSyncing
+ ? 'Syncing All...'
+ : `Sync All to ${syncableChildren.length} ${
+ syncableChildren.length === 1 ? 'Child' : 'Children'
+ }${nonSyncableChildren.length > 0 ? ` (${nonSyncableChildren.length} skipped)` : ''}`}
+
+
+ {syncSuccess && (
+
+ Successfully synced permissions to {syncState.data.completed} of{' '}
+ {syncState.data.total} children
+
+ )}
+ {syncError && (
+
+ Error syncing permissions: {syncState.error.message}
+
+ )}
+
+
+
+ ) : (
+
+ )
+ }
+ >
+
+ {isPowerLevelsSyncing
+ ? 'Syncing Power Levels...'
+ : `Sync Power Levels Only to ${syncableChildren.length} ${
+ syncableChildren.length === 1 ? 'Child' : 'Children'
+ }${nonSyncableChildren.length > 0 ? ` (${nonSyncableChildren.length} skipped)` : ''}`}
+
+
+ {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,
- }}
- >
-
-
- }
- />
-
- );
-}
-
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 && (
+
+
+
+
+
+ )}
{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,
+ }}
+ >
+
+
+ }
+ />
+
{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;
}
*,