vojo/src/app/pages/client/ClientRoot.tsx

215 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Box, Button, config, Dialog, Text } from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearLocalSessionAndReload,
initClient,
startClient,
} from '../../../client/initMatrix';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
function ClientRootLoading() {
return <AuthSplashScreen />;
}
const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
// Wipe the native session bridge before the reload — otherwise the dead
// access_token lingers in shared_prefs and CallDeclineReceiver spends
// the next login cycle posting 401s until writeSessionBridge overwrites.
await clearSessionBridge();
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
window.location.reload();
};
mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout);
return () => {
mx?.removeListener(HttpApiEvent.SessionLoggedOut, handleLogout);
};
}, [mx]);
};
type ClientRootProps = {
children: ReactNode;
};
export function ClientRoot({ children }: ClientRootProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
// Latches when the SDK reports SyncState.Error before the first PREPARED
// arrives — at that point matrix-js-sdk has already burned through its 3-try
// keep-alive backoff (~15-30s), so it's a definitive "I gave up" signal, not
// a transient blip. Cleared on Syncing in case the network recovers and the
// SDK pulls itself out before PREPARED.
const [syncErrored, setSyncErrored] = useState(false);
const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
useCallback(() => {
const session = getFallbackSession();
if (!session) {
throw new Error('No session Found!');
}
return initClient(session);
}, [])
);
const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined;
const [startState, startMatrix] = useAsyncCallback<void, Error, [MatrixClient]>(
useCallback((m) => startClient(m), [])
);
useLogoutListener(mx);
useEffect(() => {
if (loadState.status === AsyncStatus.Idle) {
loadMatrix();
}
}, [loadState, loadMatrix]);
useEffect(() => {
if (mx && !mx.clientRunning) {
startMatrix(mx);
}
}, [mx, startMatrix]);
// Mirror {accessToken, baseUrl, userId} into native SharedPreferences so
// CallDeclineReceiver can send m.call.decline without booting the WebView.
// No-op on web.
useEffect(() => {
if (!mx) return;
writeSessionBridge(mx);
}, [mx]);
// When the OS reports the network is back, prod the sync loop instead of
// waiting for matrix-js-sdk's internal keep-alive jitter (510s backoff
// per `sync.js`). Only acts when the SDK is genuinely paused on a failed
// connection — Error or Reconnecting — so a healthy Syncing client just
// ignores the event. `retryImmediately` is itself idempotent, so spurious
// duplicate `online` events from flaky NICs are harmless.
useEffect(() => {
if (!mx) return undefined;
const onOnline = () => {
const state = mx.getSyncState();
if (state === SyncState.Error || state === SyncState.Reconnecting) {
mx.retryImmediately();
}
};
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, [mx]);
useSyncState(
mx,
useCallback((state) => {
if (state === SyncState.Prepared) {
setLoading(false);
setSyncErrored(false);
} else if (state === SyncState.Error) {
setSyncErrored(true);
} else if (state === SyncState.Syncing) {
setSyncErrored(false);
}
}, [])
);
// Sync-error case: SDK is wired up correctly, just stuck — poke the next
// /sync. Init/start failures need to re-run the failed step from scratch.
let onRetry: () => void;
if (loading && syncErrored && mx) {
onRetry = () => mx.retryImmediately();
} else if (mx) {
onRetry = () => startMatrix(mx);
} else {
onRetry = loadMatrix;
}
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <SyncIndicator mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
(loading && syncErrored)) && (
<AuthSplashScreen>
<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>
{t('Boot.client_load_failed', { message: loadState.error.message })}
</Text>
)}
{startState.status === AsyncStatus.Error && (
<Text>
{t('Boot.client_start_failed', { message: startState.error.message })}
</Text>
)}
{loading &&
syncErrored &&
loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && (
<Text>{t('Boot.sync_failed')}</Text>
)}
<Button variant="Critical" onClick={onRetry}>
<Text as="span" size="B400">
{t('Boot.retry')}
</Text>
</Button>
{/* Init/start failures (corrupt IndexedDB, dead crypto store)
and pre-PREPARED sync errors share the same recovery —
wipe local session state and reload. Network calls are
skipped: in every case here the network is the suspect or
irrelevant, and the homeserver will time out the session
naturally. */}
<Button variant="Secondary" fill="Soft" onClick={clearLocalSessionAndReload}>
<Text as="span" size="B400">
{t('Boot.logout')}
</Text>
</Button>
</Box>
</Dialog>
</Box>
</AuthSplashScreen>
)}
{loading || !mx ? (
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
<AuthMetadataProvider value={serverConfigs.authMetadata}>
{children}
</AuthMetadataProvider>
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</ServerConfigsLoader>
</MatrixClientProvider>
)}
</SpecVersions>
</AutoDiscovery>
);
}