234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
import {
|
||
Box,
|
||
Button,
|
||
config,
|
||
Dialog,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Menu,
|
||
MenuItem,
|
||
PopOut,
|
||
RectCords,
|
||
Text,
|
||
} from 'folds';
|
||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
clearCacheAndReload,
|
||
clearLoginData,
|
||
initClient,
|
||
logoutClient,
|
||
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 { stopPropagation } from '../../utils/keyboard';
|
||
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 ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||
|
||
const handleToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
const cords = evt.currentTarget.getBoundingClientRect();
|
||
setMenuAnchor((currentState) => {
|
||
if (currentState) return undefined;
|
||
return cords;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<IconButton
|
||
style={{
|
||
position: 'absolute',
|
||
top: config.space.S100,
|
||
right: config.space.S100,
|
||
}}
|
||
variant="Background"
|
||
fill="None"
|
||
onClick={handleToggle}
|
||
>
|
||
<Icon size="200" src={Icons.VerticalDots} />
|
||
<PopOut
|
||
anchor={menuAnchor}
|
||
position="Bottom"
|
||
align="End"
|
||
offset={6}
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
returnFocusOnDeactivate: false,
|
||
onDeactivate: () => setMenuAnchor(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{mx && (
|
||
<MenuItem onClick={() => clearCacheAndReload(mx)} size="300" radii="300">
|
||
<Text as="span" size="T300" truncate>
|
||
Clear Cache and Reload
|
||
</Text>
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem
|
||
onClick={() => {
|
||
if (mx) {
|
||
logoutClient(mx);
|
||
return;
|
||
}
|
||
clearLoginData();
|
||
}}
|
||
size="300"
|
||
radii="300"
|
||
variant="Critical"
|
||
fill="None"
|
||
>
|
||
<Text as="span" size="T300" truncate>
|
||
Logout
|
||
</Text>
|
||
</MenuItem>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</IconButton>
|
||
);
|
||
}
|
||
|
||
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 { 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]);
|
||
|
||
const hasError =
|
||
loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error;
|
||
|
||
return (
|
||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||
<SpecVersions baseUrl={baseUrl!}>
|
||
{mx && !hasError && <SyncIndicator mx={mx} />}
|
||
{(hasError || !mx) && <ClientRootOptions mx={mx} />}
|
||
{hasError ? (
|
||
<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>{`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>
|
||
</AuthSplashScreen>
|
||
) : !mx ? (
|
||
// Brief gap (~300–700ms) while initClient runs IndexedDB open +
|
||
// crypto WASM init. Render nothing — the dark body background
|
||
// shows through instead of the mascot splash. SyncIndicator
|
||
// covers the post-mx phase visually.
|
||
null
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|