- Added PluginLoader component to dynamically load and initialize plugins. - Created Plugins component for managing installed and marketplace plugins. - Introduced PluginAPI for plugin interaction and settings management. - Defined types for plugin metadata, installed plugins, and plugin index. - Implemented settings rendering for plugins based on their schema. - Integrated marketplace plugin fetching and installation logic. - Added support for enabling/disabling and uninstalling plugins.
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
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 (
|
|
<SplashScreen>
|
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
|
<Spinner variant="Secondary" size="600" />
|
|
<Text>Heating up</Text>
|
|
</Box>
|
|
</SplashScreen>
|
|
);
|
|
}
|
|
|
|
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<Error | null>(null);
|
|
const syncRetryCount = useRef(0);
|
|
const mxRef = useRef<MatrixClient | undefined>(undefined);
|
|
const { baseUrl } = getCurrentSession() ?? {};
|
|
|
|
// Enable view transitions for smooth navigation
|
|
useViewTransitions();
|
|
|
|
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
|
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<void, Error, [MatrixClient]>(
|
|
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 (
|
|
<SpecVersions baseUrl={baseUrl!}>
|
|
<TitleBar mx={mx} />
|
|
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
|
<SplashScreen>
|
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
|
<Dialog>
|
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
|
{loadState.status === AsyncStatus.Error && (
|
|
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
|
|
)}
|
|
{startState.status === AsyncStatus.Error && (
|
|
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
|
)}
|
|
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
|
<Text as="span" size="B400">
|
|
Retry
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</Box>
|
|
</SplashScreen>
|
|
)}
|
|
{syncError && (
|
|
<SplashScreen>
|
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
|
<Dialog>
|
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
|
<Text>{`Sync failed: ${syncError.message}`}</Text>
|
|
<Text size="T300">Check your internet connection and homeserver status.</Text>
|
|
<Button variant="Critical" onClick={() => window.location.reload()}>
|
|
<Text as="span" size="B400">
|
|
Retry
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</Box>
|
|
</SplashScreen>
|
|
)}
|
|
{loading || !mx ? (
|
|
<ClientRootLoading />
|
|
) : (
|
|
<MatrixClientProvider value={mx}>
|
|
<PluginLoader matrixClient={mx}>
|
|
<CallProviderWrapper>
|
|
<ServerConfigsLoader>
|
|
{(serverConfigs) => (
|
|
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
|
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
|
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
|
{children}
|
|
</AuthMetadataProvider>
|
|
</MediaConfigProvider>
|
|
</CapabilitiesProvider>
|
|
)}
|
|
</ServerConfigsLoader>
|
|
</CallProviderWrapper>
|
|
</PluginLoader>
|
|
</MatrixClientProvider>
|
|
)}
|
|
</SpecVersions>
|
|
);
|
|
}
|