diff --git a/README.md b/README.md index 7731d6fe..83b1df2b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ For prior version of the Element Call that relied solely on full-mesh logic, che ![A demo of Element Call with six people](demo.jpg) -To try it out, visit our hosted version at [call.element.io](https://call.element.io). You can also find the latest development version continuously deployed to [element-call.netlify.app](https://element-call.netlify.app). +To try it out, visit our hosted version at [call.element.io](https://call.element.io). You can also find the latest development version continuously deployed to [call.element.dev](https://call.element.dev/). ## Host it yourself diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 5fe1ffa8..d697a70d 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -25,6 +25,7 @@ "Change layout": "Change layout", "Close": "Close", "Confirm password": "Confirm password", + "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 71cc9710..e0a18c07 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"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -60,6 +61,7 @@ export default function App({ history }: AppProps) { + diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 9e87e1a2..3e471eb8 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -25,9 +25,10 @@ import { useMemo, } 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 { @@ -56,6 +57,9 @@ export type ClientState = ValidClientState | ErrorState; export type ValidClientState = { state: "valid"; authenticated?: AuthenticatedClient; + // 'Disconnected' rather than 'connected' because it tracks specifically + // whether the client is supposed to be connected but is not + disconnected: boolean; setClient: (params?: SetClientParams) => void; }; @@ -258,6 +262,8 @@ export const ClientProvider: FC = ({ children }) => { }, [initClientState?.client, setAlreadyOpenedErr, t]) ); + const [isDisconnected, setIsDisconnected] = useState(false); + const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { return { state: "error", error: alreadyOpenedErr }; @@ -275,8 +281,27 @@ export const ClientProvider: FC = ({ children }) => { logout, }; - return { state: "valid", authenticated, setClient }; - }, [alreadyOpenedErr, changePassword, initClientState, logout, setClient]); + return { + state: "valid", + authenticated, + setClient, + disconnected: isDisconnected, + }; + }, [ + alreadyOpenedErr, + changePassword, + initClientState, + logout, + setClient, + isDisconnected, + ]); + + const onSync = useCallback( + (state: SyncState, _old: SyncState | null, data?: ISyncStateData) => { + setIsDisconnected(clientIsDisconnected(state, data)); + }, + [] + ); useEffect(() => { if (!initClientState) { @@ -288,7 +313,17 @@ export const ClientProvider: FC = ({ children }) => { if (PosthogAnalytics.hasInstance()) PosthogAnalytics.instance.onLoginStatusChanged(); - }, [initClientState]); + + if (initClientState.client) { + initClientState.client.on(ClientEvent.Sync, onSync); + } + + return () => { + if (initClientState.client) { + initClientState.client.removeListener(ClientEvent.Sync, onSync); + } + }; + }, [initClientState, onSync]); if (alreadyOpenedErr) { return ; @@ -384,3 +419,8 @@ const loadSession = (): Session | undefined => { return JSON.parse(data); }; + +const clientIsDisconnected = ( + syncState: SyncState, + syncData?: ISyncStateData +) => syncState === "ERROR" && syncData?.error?.name === "ConnectionError"; 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..6ec5d5ac --- /dev/null +++ b/src/DisconnectedBanner.tsx @@ -0,0 +1,53 @@ +/* +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 { HTMLAttributes, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import styles from "./DisconnectedBanner.module.css"; +import { ValidClientState, useClientState } from "./ClientContext"; + +interface DisconnectedBannerProps extends HTMLAttributes { + children?: ReactNode; + className?: string; +} + +export function DisconnectedBanner({ + children, + className, + ...rest +}: DisconnectedBannerProps) { + const { t } = useTranslation(); + const clientState = useClientState(); + let shouldShowBanner = false; + + if (clientState?.state === "valid") { + const validClientState = clientState as ValidClientState; + shouldShowBanner = validClientState.disconnected; + } + + return ( + <> + {shouldShowBanner && ( +
+ {children} + {t("Connectivity to the server has been lost.")} +
+ )} + + ); +} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 95a8a7ae..cdbc0297 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -18,6 +18,7 @@ import { ReactNode, useCallback, useEffect } from "react"; import { useLocation } from "react-router-dom"; import classNames from "classnames"; import { Trans, useTranslation } from "react-i18next"; +import * as Sentry from "@sentry/react"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { LinkButton, Button } from "./button"; @@ -58,6 +59,7 @@ export function ErrorView({ error }: ErrorViewProps) { useEffect(() => { console.error(error); + Sentry.captureException(error); }, [error]); const onReload = useCallback(() => { diff --git a/src/initializer.tsx b/src/initializer.tsx index 850de125..94d99b43 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 @@ -227,6 +233,7 @@ export class Initializer { if (this.loadStates.allDepsAreLoaded()) { // resolve if there is no dependency that is not loaded resolve(); + this.isInitialized = true; } } diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 6dff2de3..5f672c57 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -60,11 +60,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); diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 63e19443..b0cbf76f 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { useCallback, useEffect, useReducer, useState } from "react"; +import * as Sentry from "@sentry/react"; import { GroupCallEvent, GroupCallState, @@ -331,6 +332,7 @@ export function useGroupCall( } function onError(e: GroupCallError): void { + Sentry.captureException(e); if (e.code === GroupCallErrorCode.UnknownDevice) { const unknownDeviceError = e as GroupCallUnknownDeviceError; addUnencryptedEventUser(unknownDeviceError.userId); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 8508af25..44f10d96 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -262,7 +262,7 @@ export const SettingsModal = (props: Props) => { ); const tabs: JSX.Element[] = []; - tabs.push(audioTab, videoTab); + if (devices) tabs.push(audioTab, videoTab); if (!isEmbedded) tabs.push(profileTab); tabs.push(feedbackTab, moreTab); if (developerSettingsTab) tabs.push(developerTab);