diff --git a/docs/ai/i18n.md b/docs/ai/i18n.md index 3bdea44f..a01f3939 100644 --- a/docs/ai/i18n.md +++ b/docs/ai/i18n.md @@ -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` diff --git a/public/locales/en.json b/public/locales/en.json index 82433816..bce5a85f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index edaf42f0..ef573486 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Регистрация", diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx index 72d367c0..6ea1fd95 100644 --- a/src/app/components/ClientConfigLoader.tsx +++ b/src/app/components/ClientConfigLoader.tsx @@ -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 => { 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(); }; diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index 5ee47bdf..b55935f5 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -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( diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts index 95a131a8..7cabd4f3 100644 --- a/src/app/cs-api.ts +++ b/src/app/cs-api.ts @@ -110,11 +110,20 @@ export type SpecVersions = { versions: string[]; unstable_features?: Record; }; +// 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 => { - 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; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index c8d129f2..c02c00ee 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -40,9 +40,7 @@ function App() { } - error={(err, retry, ignore) => ( - - )} + error={(err, retry) => } > {(clientConfig) => ( diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ConfigConfig.tsx index 38a06167..337e4466 100644 --- a/src/app/pages/ConfigConfig.tsx +++ b/src/app/pages/ConfigConfig.tsx @@ -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 ; } +// 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 ( - Failed to load client configuration file. + {t('Boot.config_load_failed')} {typeof error === 'object' && error && 'message' in error && @@ -30,12 +36,7 @@ export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorPro - diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 62b9c13c..f4a94fc9 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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 ; } -function ClientRootOptions({ mx }: { mx?: MatrixClient }) { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleToggle: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - {mx && ( - clearCacheAndReload(mx)} size="300" radii="300"> - - Clear Cache and Reload - - - )} - { - if (mx) { - logoutClient(mx); - return; - } - clearLoginData(); - }} - size="300" - radii="300" - variant="Critical" - fill="None" - > - - Logout - - - - - - } - /> - - ); -} - 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( @@ -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 ( {mx && } - {loading && } - {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( + {(loadState.status === AsyncStatus.Error || + startState.status === AsyncStatus.Error || + (loading && syncErrored)) && ( {loadState.status === AsyncStatus.Error && ( - {`Failed to load. ${loadState.error.message}`} + + {t('Boot.client_load_failed', { message: loadState.error.message })} + )} {startState.status === AsyncStatus.Error && ( - {`Failed to start. ${startState.error.message}`} + + {t('Boot.client_start_failed', { message: startState.error.message })} + )} - + {/* 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. */} + diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx index 2c3f0088..a2b49394 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -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 ( - } - error={(err, retry, ignore) => ( - - - - - - Unable to connect to the homeserver. The homeserver or your internet connection - may be down. - - - - - - - - )} - > + }> {(versions) => {children}} ); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index e7b9adca..030160a6 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -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(); +};