Files
cinny/src/app/pages/client/ClientRoot.tsx
T

301 lines
10 KiB
TypeScript
Raw Normal View History

2024-07-22 16:17:19 +05:30
import {
Box,
Button,
config,
Dialog,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
2024-07-22 16:17:19 +05:30
import {
clearCacheAndReload,
clearLoginData,
IDB_VERSION_CONFLICT,
2024-07-22 16:17:19 +05:30
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 { SpecVersions } from './SpecVersions';
2024-07-22 16:17:19 +05:30
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery';
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
// module load so it sees crypto warnings from the very first sync. Idempotent;
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
installCryptoDiagLog();
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>
);
}
function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
2024-07-22 16:17:19 +05:30
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}
aria-label="Account options"
2024-07-22 16:17:19 +05:30
>
<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>
)}
2024-07-22 16:17:19 +05:30
<MenuItem
onClick={() => {
if (mx) {
logoutClient(mx);
return;
}
clearLoginData();
}}
2024-07-22 16:17:19 +05:30
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 () => {
mx?.stopClient();
await mx?.clearStores();
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
removeFallbackSession();
2024-07-22 16:17:19 +05:30
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 [loading, setLoading] = useState(true);
const [syncError, setSyncError] = useState(false);
// Tracks whether the initial sync has ever reached PREPARED. After that,
// transient sync errors are handled by <SyncStatus>'s reconnection banner,
// so we must NOT pop the blocking error splash for them.
const hasPreparedRef = useRef(false);
const { baseUrl, userId } = getFallbackSession() ?? {};
2024-07-22 16:17:19 +05:30
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
2025-08-29 15:04:52 +05:30
useCallback(() => {
const session = getFallbackSession();
if (!session) {
throw new Error('No session Found!');
}
return initClient(session);
}, []),
2024-07-22 16:17:19 +05:30
);
const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined;
const [startState, startMatrix] = useAsyncCallback<void, Error, [MatrixClient]>(
useCallback((m) => startClient(m), []),
2024-07-22 16:17:19 +05:30
);
useLogoutListener(mx);
// Cross-tab session sync: another tab logging out / in (access token changed
// in localStorage) reloads this tab so it never runs with stale credentials.
useSessionSync();
2024-07-22 16:17:19 +05:30
useEffect(() => {
2024-07-22 16:17:19 +05:30
if (loadState.status === AsyncStatus.Idle) {
loadMatrix();
}
}, [loadState, loadMatrix]);
useEffect(() => {
if (mx && !mx.clientRunning) {
startMatrix(mx);
}
}, [mx, startMatrix]);
useSyncState(
mx,
useCallback((state) => {
if (state === 'PREPARED') {
hasPreparedRef.current = true;
setSyncError(false);
2024-07-22 16:17:19 +05:30
setLoading(false);
} else if (state === 'ERROR' || state === 'STOPPED') {
// Only surface the blocking error splash when the INITIAL sync fails
// (offline at startup, homeserver unreachable, non-retryable /sync
// error). After the first PREPARED, <SyncStatus> owns reconnection UX.
if (!hasPreparedRef.current) setSyncError(true);
2024-07-22 16:17:19 +05:30
}
}, []),
2024-07-22 16:17:19 +05:30
);
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && !syncError && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
syncError) && (
<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 && (
<>
{loadState.error.message === IDB_VERSION_CONFLICT ? (
<>
<Text>
Local data is from a newer app version and cannot be read. Clear local
data to continue (you will need to log in again).
</Text>
<Button variant="Critical" onClick={clearLoginData}>
<Text as="span" size="B400">
Clear local data and reload
</Text>
</Button>
</>
) : (
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
)}
</>
)}
{startState.status === AsyncStatus.Error && (
<Text>{`Failed to start. ${startState.error.message}`}</Text>
)}
{syncError &&
loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && (
<Text>
Failed to sync with your homeserver. Check your connection and try again.
</Text>
)}
{('error' in loadState ? (loadState as any).error?.message : undefined) !==
IDB_VERSION_CONFLICT && (
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
)}
</Box>
</Dialog>
</Box>
</SplashScreen>
)}
{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>
);
}