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
cd824e0c90
commit
b30704dd96
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)
|
- **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`
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Регистрация",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue