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:
parent
9e42508902
commit
20cfff21fd
11 changed files with 141 additions and 149 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Регистрация",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue