import { Box, Button, config, Dialog, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Spinner, Text, } from 'folds'; import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { clearCacheAndReload, clearLoginData, initClient, logoutClient, startClient, } from '../../../client/initMatrix'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { MediaConfigProvider } from '../../hooks/useMediaConfig'; import { MatrixClientProvider } from '../../hooks/useMatrixClient'; import { PluginLoader } from '../../features/settings/plugins'; import { SpecVersions } from './SpecVersions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; 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 { AccountSwitcher } from '../../components/account-switcher'; import { getLoginPath, withSearchParam } from '../pathUtils'; import { useViewTransitions } from '../../hooks/useViewTransitions'; function ClientRootLoading() { return ( Heating up ); } const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { mx?.stopClient(); await mx?.clearStores(); window.localStorage.clear(); window.location.reload(); }; mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout); return () => { mx?.removeListener(HttpApiEvent.SessionLoggedOut, handleLogout); }; }, [mx]); }; /** * Hook to handle visibility changes and restart sync on mobile * When the app goes to background and comes back, sync may be stale/stopped */ const useVisibilitySync = (mx?: MatrixClient) => { useEffect(() => { if (!mx || !isTauriMobile()) return; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { console.log('[useVisibilitySync] App became visible, checking sync state'); const syncState = mx.getSyncState(); console.log('[useVisibilitySync] Current sync state:', syncState); // If sync is not running or errored, restart it if (!mx.clientRunning || syncState === SyncState.Error || syncState === SyncState.Stopped || syncState === null) { console.log('[useVisibilitySync] Restarting sync...'); startClient(mx).catch((err) => { console.error('[useVisibilitySync] Failed to restart sync:', err); }); } else { // Force a sync even if client is running (to catch up quickly) console.log('[useVisibilitySync] Triggering immediate sync'); mx.retryImmediately(); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); // Also handle resume event for mobile (if available) window.addEventListener('resume', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('resume', handleVisibilityChange); }; }, [mx]); }; /** * Hook to start/stop native Rust background sync on mobile * This allows notifications to work even when the app is backgrounded */ const useBackgroundSync = (mx?: MatrixClient) => { useEffect(() => { if (!mx || !isTauriMobile()) return; // Start background sync with credentials from the Matrix client const startSync = async () => { try { const homeserverUrl = mx.getHomeserverUrl(); const userId = mx.getUserId(); const accessToken = mx.getAccessToken(); const deviceId = mx.getDeviceId(); if (!homeserverUrl || !userId || !accessToken || !deviceId) { console.warn('[useBackgroundSync] Missing credentials, cannot start background sync'); return; } await startBackgroundSync({ homeserverUrl, userId, accessToken, deviceId, }); console.log('[useBackgroundSync] Background sync started'); } catch (err) { console.error('[useBackgroundSync] Failed to start background sync:', err); } }; startSync(); // Stop background sync on cleanup return () => { stopBackgroundSync().catch((err) => { console.error('[useBackgroundSync] Failed to stop background sync:', err); }); }; }, [mx]); }; type ClientRootProps = { children: ReactNode; }; const MAX_SYNC_RETRIES = 3; export function ClientRoot({ children }: ClientRootProps) { const [loading, setLoading] = useState(true); const [syncError, setSyncError] = useState(null); const syncRetryCount = useRef(0); const mxRef = useRef(undefined); const { baseUrl } = getCurrentSession() ?? {}; // Enable view transitions for smooth navigation useViewTransitions(); const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => { const session = getCurrentSession(); if (!session) { throw new Error('No session Found!'); } return initClient(session); }, []) ); const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined; useEffect(() => { mxRef.current = mx; }, [mx]); const [startState, startMatrix] = useAsyncCallback( useCallback((m) => startClient(m), []) ); useLogoutListener(mx); useVisibilitySync(mx); useBackgroundSync(mx); useEffect(() => { if (loadState.status === AsyncStatus.Idle) { loadMatrix(); } }, [loadState, loadMatrix]); useEffect(() => { if (mx && !mx.clientRunning) { startMatrix(mx); } }, [mx, startMatrix]); // Timeout if sync takes too long - nudge the client rather than immediately erroring useEffect(() => { if (!mx || !loading) return; const timeout = setTimeout(() => { console.warn('[ClientRoot] Sync taking too long, nudging client...'); mx.retryImmediately(); }, 30000); return () => clearTimeout(timeout); }, [mx, loading]); useSyncState( mx, useCallback((state, prevState, data) => { const client = mxRef.current; if (state === 'PREPARED') { setLoading(false); setSyncError(null); syncRetryCount.current = 0; } else if (state === 'ERROR') { if (syncRetryCount.current < MAX_SYNC_RETRIES) { syncRetryCount.current += 1; const attempt = syncRetryCount.current; console.warn(`[ClientRoot] Sync error, auto-retrying (${attempt}/${MAX_SYNC_RETRIES})...`); setTimeout(() => { if (client) startClient(client).catch(() => {}); }, 2000 * attempt); } else { const error = data instanceof Error ? data : new Error('Sync failed'); console.error('[ClientRoot] Sync error after max retries:', error); setSyncError(error); } } }, []) ); return ( {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( {loadState.status === AsyncStatus.Error && ( {`Failed to load. ${loadState.error.message}`} )} {startState.status === AsyncStatus.Error && ( {`Failed to start. ${startState.error.message}`} )} )} {syncError && ( {`Sync failed: ${syncError.message}`} Check your internet connection and homeserver status. )} {loading || !mx ? ( ) : ( {(serverConfigs) => ( {children} )} )} ); }