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:
v.lagerev 2026-05-09 02:26:16 +03:00
parent 9e42508902
commit 20cfff21fd
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)
- **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)
- **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
@ -57,13 +57,14 @@ The developer reviews Russian translations as a native speaker would see them in
- `Create` — Room/space creation
- `RoomSettings` — Room settings (general, members, permissions, emojis/stickers, developer tools, image pack editor, power level tags)
- `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):
- Room features: `MessageEditor`, `MembersDrawer`, `RoomTombstone`
- Lobby: `Lobby.tsx`, `RoomItem.tsx`
- `AddExisting` feature
- System pages: `ConfigConfig`, `FeatureCheck`, `SpecVersions`, `WelcomePage`, `ClientRoot`
- System pages: `FeatureCheck`, `WelcomePage`
- Auth: `OrDivider`
- Dialogs: `LogoutDialog`, `ManualVerification`, `BackupRestore`
- UIA stages: `ReCaptchaStage`, `EmailStage`, `RegistrationTokenStage`

View file

@ -7,6 +7,14 @@
"App": {
"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": {
"title_login": "Log In",
"title_register": "Register",

View file

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

View file

@ -3,9 +3,18 @@ import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ClientConfig } from '../hooks/useClientConfig';
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 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();
};

View file

@ -29,8 +29,11 @@ export function SpecVersionsLoader({
return fallback?.();
}
if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
// When `error` is omitted, fall through to children with the empty-versions
// 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(

View file

@ -110,11 +110,20 @@ export type SpecVersions = {
versions: string[];
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 (
request: typeof fetch,
baseUrl: string
): 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;

View file

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

View file

@ -1,24 +1,30 @@
import { Box, Button, Dialog, Text, color, config } from 'folds';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AuthSplashScreen } from './auth/AuthSplashScreen';
export function ConfigConfigLoading() {
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 = {
error: unknown;
retry: () => void;
ignore: () => void;
};
export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) {
export function ConfigConfigError({ error, retry }: ConfigConfigErrorProps) {
const { t } = useTranslation();
return (
<AuthSplashScreen>
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>Failed to load client configuration file.</Text>
<Text>{t('Boot.config_load_failed')}</Text>
{typeof error === 'object' &&
error &&
'message' in error &&
@ -30,12 +36,7 @@ export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorPro
</Box>
<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
{t('Boot.retry')}
</Text>
</Button>
</Box>

View file

@ -1,25 +1,10 @@
import {
Box,
Button,
config,
Dialog,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
} from 'folds';
import { Box, Button, config, Dialog, Text } from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearCacheAndReload,
clearLoginData,
clearLocalSessionAndReload,
initClient,
logoutClient,
startClient,
} from '../../../client/initMatrix';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
@ -29,7 +14,6 @@ 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 { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
@ -41,81 +25,6 @@ function ClientRootLoading() {
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) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
@ -140,7 +49,14 @@ 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, []>(
@ -200,18 +116,35 @@ export function ClientRoot({ children }: ClientRootProps) {
useSyncState(
mx,
useCallback((state) => {
if (state === 'PREPARED') {
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} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
(loading && syncErrored)) && (
<AuthSplashScreen>
<Box
direction="Column"
@ -223,14 +156,35 @@ export function ClientRoot({ children }: ClientRootProps) {
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{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 && (
<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">
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>
</Button>
</Box>

View file

@ -1,39 +1,17 @@
import React, { ReactNode } from 'react';
import { Box, Dialog, config, Text, Button } from 'folds';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
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 }) {
return (
<SpecVersionsLoader
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>
)}
>
<SpecVersionsLoader baseUrl={baseUrl} fallback={() => <AuthSplashScreen />}>
{(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
</SpecVersionsLoader>
);

View file

@ -118,3 +118,26 @@ export const clearLoginData = async () => {
window.localStorage.clear();
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();
};