diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 987402a0..47bdbdb5 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -36,6 +36,7 @@ "Close": "Close", "Confirm password": "Confirm password", "Connection lost": "Connection lost", + "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Copied!": "Copied!", "Copy": "Copy", "Copy and share this call link": "Copy and share this call link", diff --git a/src/App.tsx b/src/App.tsx index 2ae27b73..655e3789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { usePageFocusStyle } from "./usePageFocusStyle"; import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage"; import { InspectorContextProvider } from "./room/GroupCallInspector"; import { CrashView, LoadingView } from "./FullScreenView"; +import { DisconnectedBanner } from "./DisconnectedBanner"; import { Initializer } from "./initializer"; import { MediaHandlerProvider } from "./settings/useMediaHandler"; @@ -60,6 +61,7 @@ export default function App({ history }: AppProps) { + diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 08593a4b..ebe84f7e 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -25,9 +25,10 @@ import React, { useRef, } from "react"; import { useHistory } from "react-router-dom"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { useTranslation } from "react-i18next"; +import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ErrorView } from "./FullScreenView"; import { @@ -70,6 +71,8 @@ const loadSession = (): Session => { const saveSession = (session: Session) => localStorage.setItem("matrix-auth-store", JSON.stringify(session)); const clearSession = () => localStorage.removeItem("matrix-auth-store"); +const isDisconnected = (syncState, syncData) => + syncState === "ERROR" && syncData?.error?.name === "ConnectionError"; interface ClientState { loading: boolean; @@ -81,6 +84,7 @@ interface ClientState { logout: () => void; setClient: (client: MatrixClient, session: Session) => void; error?: Error; + disconnected: boolean; } const ClientContext = createContext(null); @@ -98,7 +102,15 @@ export const ClientProvider: FC = ({ children }) => { const history = useHistory(); const initializing = useRef(false); const [ - { loading, isAuthenticated, isPasswordlessUser, client, userName, error }, + { + loading, + isAuthenticated, + isPasswordlessUser, + client, + userName, + error, + disconnected, + }, setState, ] = useState({ loading: true, @@ -107,8 +119,18 @@ export const ClientProvider: FC = ({ children }) => { client: undefined, userName: null, error: undefined, + disconnected: false, }); + const onSync = (state: SyncState, _old: SyncState, data: ISyncStateData) => { + setState((currentState) => { + const disconnected = isDisconnected(state, data); + return disconnected === currentState.disconnected + ? currentState + : { ...currentState, disconnected }; + }); + }; + useEffect(() => { // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize @@ -183,9 +205,10 @@ export const ClientProvider: FC = ({ children }) => { } } }; - + let clientWithListener: MatrixClient; init() .then(({ client, isPasswordlessUser }) => { + clientWithListener = client; setState({ client, loading: false, @@ -193,7 +216,12 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser, userName: client?.getUserIdLocalpart(), error: undefined, + disconnected: isDisconnected( + client?.getSyncState, + client?.getSyncStateData + ), }); + clientWithListener?.on(ClientEvent.Sync, onSync); }) .catch((err) => { logger.error(err); @@ -204,9 +232,13 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser: false, userName: null, error: undefined, + disconnected: false, }); }) .finally(() => (initializing.current = false)); + return () => { + clientWithListener?.removeListener(ClientEvent.Sync, onSync); + }; }, []); const changePassword = useCallback( @@ -235,6 +267,7 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser: false, userName: client.getUserIdLocalpart(), error: undefined, + disconnected: false, }); }, [client] @@ -256,6 +289,10 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser: session.passwordlessUser, userName: newClient.getUserIdLocalpart(), error: undefined, + disconnected: isDisconnected( + newClient.getSyncState(), + newClient.getSyncStateData() + ), }); } else { clearSession(); @@ -267,6 +304,7 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser: false, userName: null, error: undefined, + disconnected: false, }); } }, @@ -284,6 +322,7 @@ export const ClientProvider: FC = ({ children }) => { isPasswordlessUser: true, userName: "", error: undefined, + disconnected: false, }); history.push("/"); PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest); @@ -326,6 +365,7 @@ export const ClientProvider: FC = ({ children }) => { userName, setClient, error: undefined, + disconnected, }), [ loading, @@ -336,6 +376,7 @@ export const ClientProvider: FC = ({ children }) => { logout, userName, setClient, + disconnected, ] ); diff --git a/src/DisconnectedBanner.module.css b/src/DisconnectedBanner.module.css new file mode 100644 index 00000000..5827953d --- /dev/null +++ b/src/DisconnectedBanner.module.css @@ -0,0 +1,27 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.banner { + position: absolute; + padding: 29px; + background-color: var(--quaternary-content); + vertical-align: middle; + font-size: var(--font-size-body); + text-align: center; + z-index: 1; + top: 76px; + width: calc(100% - 58px); +} diff --git a/src/DisconnectedBanner.tsx b/src/DisconnectedBanner.tsx new file mode 100644 index 00000000..b96c0b01 --- /dev/null +++ b/src/DisconnectedBanner.tsx @@ -0,0 +1,47 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classNames from "classnames"; +import React, { HTMLAttributes, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import styles from "./DisconnectedBanner.module.css"; +import { useClient } from "./ClientContext"; + +interface DisconnectedBannerProps extends HTMLAttributes { + children?: ReactNode; + className?: string; +} + +export function DisconnectedBanner({ + children, + className, + ...rest +}: DisconnectedBannerProps) { + const { t } = useTranslation(); + const { disconnected } = useClient(); + + return ( + <> + {disconnected && ( +
+ {children} + {t("Connectivity to the server has been lost.")} +
+ )} + + ); +} diff --git a/src/initializer.tsx b/src/initializer.tsx index 37e659e7..ceea035b 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -45,6 +45,12 @@ class DependencyLoadStates { export class Initializer { private static internalInstance: Initializer; + private isInitialized = false; + + public static isInitialized(): boolean { + return Initializer.internalInstance?.isInitialized; + } + public static initBeforeReact() { // this maybe also needs to return a promise in the future, // if we have to do async inits before showing the loading screen @@ -223,6 +229,7 @@ export class Initializer { if (this.loadStates.allDepsAreLoaded()) { // resolve if there is no dependency that is not loaded resolve(); + this.isInitialized = true; } } private initPromise: Promise | null; diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 8cec21d0..2830b823 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -61,11 +61,11 @@ function waitForSync(client: MatrixClient) { data: ISyncStateData ) => { if (state === "PREPARED") { + client.removeListener(ClientEvent.Sync, onSync); resolve(); - client.removeListener(ClientEvent.Sync, onSync); } else if (state === "ERROR") { - reject(data?.error); client.removeListener(ClientEvent.Sync, onSync); + reject(data?.error); } }; client.on(ClientEvent.Sync, onSync);