fix(boot): drop 3-dots splash menu, add 10s fetch timeouts, surface logout on init/start/sync-error per Element Web pattern

This commit is contained in:
heaven 2026-05-09 02:26:16 +03:00
parent cd824e0c90
commit b30704dd96
11 changed files with 141 additions and 149 deletions

View file

@ -6,7 +6,7 @@
- **Fallback language**: `en` (lingua franca for unsupported detected locales; keeps web/SW/Android push surfaces aligned — see §5.27 in docs/plans/dm_calls_techdebt.md) - **Fallback language**: `en` (lingua franca for unsupported detected locales; keeps web/SW/Android push surfaces aligned — see §5.27 in docs/plans/dm_calls_techdebt.md)
- **Supported languages**: `en`, `ru` (set in `supportedLngs`; anything else normalises to `en`) - **Supported languages**: `en`, `ru` (set in `supportedLngs`; anything else normalises to `en`)
- **Locale files**: [`public/locales/en.json`](../../public/locales/en.json), [`public/locales/ru.json`](../../public/locales/ru.json) - **Locale files**: [`public/locales/en.json`](../../public/locales/en.json), [`public/locales/ru.json`](../../public/locales/ru.json)
- **Namespaces** (top-level keys in the JSON files): `Organisms`, `Auth`, `Settings`, `Search`, `Home`, `Direct`, `Room`, `Inbox`, `Explore`, `Create`, `RoomSettings`, `Push` - **Namespaces** (top-level keys in the JSON files): `Organisms`, `Auth`, `Boot`, `Settings`, `Search`, `Home`, `Direct`, `Room`, `Inbox`, `Explore`, `Create`, `RoomSettings`, `Push`
## Usage pattern ## Usage pattern
@ -57,13 +57,14 @@ The developer reviews Russian translations as a native speaker would see them in
- `Create` — Room/space creation - `Create` — Room/space creation
- `RoomSettings` — Room settings (general, members, permissions, emojis/stickers, developer tools, image pack editor, power level tags) - `RoomSettings` — Room settings (general, members, permissions, emojis/stickers, developer tools, image pack editor, power level tags)
- `Organisms` — Complex UI pieces - `Organisms` — Complex UI pieces
- `Boot` — Boot/splash error screens (`ConfigConfigError`, `SpecVersions` error, `ClientRoot` init/start error)
**Still to localise** (snapshot taken 2026-04-14 — verify before acting): **Still to localise** (snapshot taken 2026-04-14 — verify before acting):
- Room features: `MessageEditor`, `MembersDrawer`, `RoomTombstone` - Room features: `MessageEditor`, `MembersDrawer`, `RoomTombstone`
- Lobby: `Lobby.tsx`, `RoomItem.tsx` - Lobby: `Lobby.tsx`, `RoomItem.tsx`
- `AddExisting` feature - `AddExisting` feature
- System pages: `ConfigConfig`, `FeatureCheck`, `SpecVersions`, `WelcomePage`, `ClientRoot` - System pages: `FeatureCheck`, `WelcomePage`
- Auth: `OrDivider` - Auth: `OrDivider`
- Dialogs: `LogoutDialog`, `ManualVerification`, `BackupRestore` - Dialogs: `LogoutDialog`, `ManualVerification`, `BackupRestore`
- UIA stages: `ReCaptchaStage`, `EmailStage`, `RegistrationTokenStage` - UIA stages: `ReCaptchaStage`, `EmailStage`, `RegistrationTokenStage`

View file

@ -7,6 +7,14 @@
"App": { "App": {
"back_to_exit": "Press back again to exit" "back_to_exit": "Press back again to exit"
}, },
"Boot": {
"retry": "Retry",
"config_load_failed": "Failed to load client configuration file.",
"client_load_failed": "Failed to load. {{message}}",
"client_start_failed": "Failed to start. {{message}}",
"sync_failed": "Connection lost. The homeserver or your network may be down.",
"logout": "Logout"
},
"Auth": { "Auth": {
"title_login": "Log In", "title_login": "Log In",
"title_register": "Register", "title_register": "Register",

View file

@ -7,6 +7,14 @@
"App": { "App": {
"back_to_exit": "Нажмите «назад» ещё раз, чтобы выйти" "back_to_exit": "Нажмите «назад» ещё раз, чтобы выйти"
}, },
"Boot": {
"retry": "Повторить",
"config_load_failed": "Не удалось загрузить файл конфигурации клиента.",
"client_load_failed": "Не удалось загрузить. {{message}}",
"client_start_failed": "Не удалось запустить. {{message}}",
"sync_failed": "Связь потеряна. Сервер или ваше подключение могут быть недоступны.",
"logout": "Выйти"
},
"Auth": { "Auth": {
"title_login": "Войти", "title_login": "Войти",
"title_register": "Регистрация", "title_register": "Регистрация",

View file

@ -3,9 +3,18 @@ import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ClientConfig } from '../hooks/useClientConfig'; import { ClientConfig } from '../hooks/useClientConfig';
import { trimTrailingSlash } from '../utils/common'; import { trimTrailingSlash } from '../utils/common';
// Same-origin static asset that arrives in <2s under any sane network. 10s
// converts hung TCP / silent CDN limbo into a real AsyncStatus.Error so
// ConfigConfigError's Retry/Continue can recover the boot — without the
// timeout the user sits on an unmovable splash forever.
const CONFIG_FETCH_TIMEOUT_MS = 10_000;
const getClientConfig = async (): Promise<ClientConfig> => { const getClientConfig = async (): Promise<ClientConfig> => {
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
const config = await fetch(url, { method: 'GET' }); const config = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(CONFIG_FETCH_TIMEOUT_MS),
});
return config.json(); return config.json();
}; };

View file

@ -29,8 +29,11 @@ export function SpecVersionsLoader({
return fallback?.(); return fallback?.();
} }
if (!ignoreError && state.status === AsyncStatus.Error) { // When `error` is omitted, fall through to children with the empty-versions
return error?.(state.error, load, ignoreCallback); // fallback below — Element Web's pattern: spec-versions probe failure is a
// soft-degrade, not a blocker.
if (!ignoreError && state.status === AsyncStatus.Error && error) {
return error(state.error, load, ignoreCallback);
} }
return children( return children(

View file

@ -110,11 +110,20 @@ export type SpecVersions = {
versions: string[]; versions: string[];
unstable_features?: Record<string, boolean>; unstable_features?: Record<string, boolean>;
}; };
// Boot-blocking probe — homeserver must respond before ClientRoot renders
// its children. Without a cap, a homeserver that DNS-resolves but never
// answers leaves the user on the splash forever. 10s gives slow homeservers
// room while still surfacing SpecVersions error UI (Retry/Continue) for
// real outages.
const SPEC_VERSIONS_TIMEOUT_MS = 10_000;
export const specVersions = async ( export const specVersions = async (
request: typeof fetch, request: typeof fetch,
baseUrl: string baseUrl: string
): Promise<SpecVersions> => { ): Promise<SpecVersions> => {
const res = await request(`${trimTrailingSlash(baseUrl)}/_matrix/client/versions`); const res = await request(`${trimTrailingSlash(baseUrl)}/_matrix/client/versions`, {
signal: AbortSignal.timeout(SPEC_VERSIONS_TIMEOUT_MS),
});
const data = (await res.json()) as unknown; const data = (await res.json()) as unknown;

View file

@ -40,9 +40,7 @@ function App() {
<FeatureCheck> <FeatureCheck>
<ClientConfigLoader <ClientConfigLoader
fallback={() => <ConfigConfigLoading />} fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => ( error={(err, retry) => <ConfigConfigError error={err} retry={retry} />}
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
> >
{(clientConfig) => ( {(clientConfig) => (
<ClientConfigProvider value={clientConfig}> <ClientConfigProvider value={clientConfig}>

View file

@ -1,24 +1,30 @@
import { Box, Button, Dialog, Text, color, config } from 'folds'; import { Box, Button, Dialog, Text, color, config } from 'folds';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { AuthSplashScreen } from './auth/AuthSplashScreen'; import { AuthSplashScreen } from './auth/AuthSplashScreen';
export function ConfigConfigLoading() { export function ConfigConfigLoading() {
return <AuthSplashScreen />; return <AuthSplashScreen />;
} }
// Element Web's pattern: blocking ErrorView with retry-only on config failure.
// Continuing past a missing config produces a half-broken login (empty
// homeserverList → no server allowed) and is worse UX than asking the user
// to retry. The 10s fetch timeout in ClientConfigLoader already filters out
// transient network blips so this modal only fires on real outages.
type ConfigConfigErrorProps = { type ConfigConfigErrorProps = {
error: unknown; error: unknown;
retry: () => void; retry: () => void;
ignore: () => void;
}; };
export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) { export function ConfigConfigError({ error, retry }: ConfigConfigErrorProps) {
const { t } = useTranslation();
return ( return (
<AuthSplashScreen> <AuthSplashScreen>
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center"> <Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
<Dialog> <Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text>Failed to load client configuration file.</Text> <Text>{t('Boot.config_load_failed')}</Text>
{typeof error === 'object' && {typeof error === 'object' &&
error && error &&
'message' in error && 'message' in error &&
@ -30,12 +36,7 @@ export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorPro
</Box> </Box>
<Button variant="Critical" onClick={retry}> <Button variant="Critical" onClick={retry}>
<Text as="span" size="B400"> <Text as="span" size="B400">
Retry {t('Boot.retry')}
</Text>
</Button>
<Button variant="Critical" onClick={ignore} fill="Soft">
<Text as="span" size="B400">
Continue
</Text> </Text>
</Button> </Button>
</Box> </Box>

View file

@ -1,25 +1,10 @@
import { import { Box, Button, config, Dialog, Text } from 'folds';
Box,
Button,
config,
Dialog,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
} from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk'; import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next';
import { import {
clearCacheAndReload, clearLocalSessionAndReload,
clearLoginData,
initClient, initClient,
logoutClient,
startClient, startClient,
} from '../../../client/initMatrix'; } from '../../../client/initMatrix';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
@ -29,7 +14,6 @@ import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions'; import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncIndicator } from './SyncIndicator'; import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession } from '../../state/sessions';
@ -41,81 +25,6 @@ function ClientRootLoading() {
return <AuthSplashScreen />; return <AuthSplashScreen />;
} }
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) => { const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => { useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
@ -140,7 +49,14 @@ type ClientRootProps = {
children: ReactNode; children: ReactNode;
}; };
export function ClientRoot({ children }: ClientRootProps) { export function ClientRoot({ children }: ClientRootProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true); 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 { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>( const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
@ -200,18 +116,35 @@ export function ClientRoot({ children }: ClientRootProps) {
useSyncState( useSyncState(
mx, mx,
useCallback((state) => { useCallback((state) => {
if (state === 'PREPARED') { if (state === SyncState.Prepared) {
setLoading(false); 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 ( return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}> <AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}> <SpecVersions baseUrl={baseUrl!}>
{mx && <SyncIndicator mx={mx} />} {mx && <SyncIndicator mx={mx} />}
{loading && <ClientRootOptions mx={mx} />} {(loadState.status === AsyncStatus.Error ||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( startState.status === AsyncStatus.Error ||
(loading && syncErrored)) && (
<AuthSplashScreen> <AuthSplashScreen>
<Box <Box
direction="Column" direction="Column"
@ -223,14 +156,35 @@ export function ClientRoot({ children }: ClientRootProps) {
<Dialog> <Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}> <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{loadState.status === AsyncStatus.Error && ( {loadState.status === AsyncStatus.Error && (
<Text>{`Failed to load. ${loadState.error.message}`}</Text> <Text>
{t('Boot.client_load_failed', { message: loadState.error.message })}
</Text>
)} )}
{startState.status === AsyncStatus.Error && ( {startState.status === AsyncStatus.Error && (
<Text>{`Failed to start. ${startState.error.message}`}</Text> <Text>
{t('Boot.client_start_failed', { message: startState.error.message })}
</Text>
)} )}
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}> {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"> <Text as="span" size="B400">
Retry {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> </Text>
</Button> </Button>
</Box> </Box>

View file

@ -1,39 +1,17 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Box, Dialog, config, Text, Button } from 'folds';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions'; import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
import { AuthSplashScreen } from '../auth/AuthSplashScreen'; import { AuthSplashScreen } from '../auth/AuthSplashScreen';
// Element Web's pattern: a homeserver `/_matrix/client/versions` probe failure
// degrades silently to an empty versions list — feature-detection consumers
// (`useMutualRooms`, `useReportRoomSupported`, `useMediaAuthentication`) all
// treat empty as "not supported" and fall back to legacy behaviour. Surfacing
// a blocking error modal here would just make a transient outage feel like a
// dead client.
export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
return ( return (
<SpecVersionsLoader <SpecVersionsLoader baseUrl={baseUrl} fallback={() => <AuthSplashScreen />}>
baseUrl={baseUrl}
fallback={() => <AuthSplashScreen />}
error={(err, retry, ignore) => (
<AuthSplashScreen>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Unable to connect to the homeserver. The homeserver or your internet connection
may be down.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" onClick={ignore} fill="Soft">
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
</Box>
</AuthSplashScreen>
)}
>
{(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>} {(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
</SpecVersionsLoader> </SpecVersionsLoader>
); );

View file

@ -118,3 +118,26 @@ export const clearLoginData = async () => {
window.localStorage.clear(); window.localStorage.clear();
window.location.reload(); window.location.reload();
}; };
// Boot-error logout: full LOCAL cleanup with no network calls — useful when the
// network is the reason we're stuck (sync error, init/start failure). Wipes
// the native session bridge so CallDeclineReceiver can't resurrect a dead
// token, clears push state so a re-login doesn't double-register, and nukes
// IndexedDB + localStorage. Server-side logout is skipped — the homeserver
// will time out the session naturally.
export const clearLocalSessionAndReload = async () => {
await clearSessionBridge();
clearPusherIds();
setPushEnabled(false);
const dbs = await window.indexedDB.databases();
dbs.forEach((idbInfo) => {
const { name } = idbInfo;
if (name) {
window.indexedDB.deleteDatabase(name);
}
});
window.localStorage.clear();
window.location.reload();
};