Files
cinny/src/app/pages/client/ClientRoot.tsx
T
jared 21276a47fc
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 14s
fix(audit): low-tail cleanup — session/logout/unread/presence/forward
Clears the clean 🟡 remainders from the feature audit (gate-green, 677 tests):
- F3: getFallbackSession prefers the session-blob/legacy source with the later
  expiresAt (a downgrade→upgrade could boot on a stale blob's dead token).
- F6: server-forced logout (SessionLoggedOut) now mirrors logoutClient —
  pushSessionToSW() + best-effort revokeOidcTokens for OIDC sessions (the search
  plaintext wipe was already added).
- N5: deleteUnreadInfo parent fallback `?? roomId` → `?? []` (latently spread the
  roomId string into chars).
- P10: useUserPresence re-seeds when the User object appears after first render.
- forward: strip m.mentions so forwarding doesn't re-ping the original mentions.

Left open: F5 (OIDC expiry not reachable in persistTokens), N6/H10/D7 (minor /
runtime-verify). See LOTUS_TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:57:09 -04:00

319 lines
11 KiB
TypeScript

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';
import {
clearCacheAndReload,
clearLoginData,
IDB_VERSION_CONFLICT,
initClient,
logoutClient,
startClient,
} from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
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';
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 { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
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 }) {
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"
>
<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 () => {
// Clear the SW's cached bearer token so it stops attaching the now-revoked
// token to media fetches (mirrors the manual logoutClient path).
pushSessionToSW();
mx?.stopClient();
// Best-effort issuer revocation for OIDC sessions (the token is already
// server-revoked here, but revoke the refresh token too). Before we drop
// the stored session below.
const loggedOutSession = getFallbackSession();
if (loggedOutSession?.oidc) {
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
}
await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
// on server-forced logout too (token expiry / remote sign-out / password
// change) — the manual logout path already does, but this path didn't, so
// the plaintext survived on disk (and persist() makes it non-evictable).
await deleteSearchCacheDatabase();
// 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();
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() ?? {};
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);
// 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();
useEffect(() => {
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);
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);
}
}, []),
);
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>
);
}