diff --git a/src/App.tsx b/src/App.tsx index 62b3cccb..5b74b4dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,7 +72,11 @@ export const App: FC = () => { - + ( + + )} + > } /> diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 7d7542a0..0fbff564 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -351,7 +351,7 @@ export const ClientProvider: FC = ({ children }) => { }, [initClientState, onSync]); if (alreadyOpenedErr) { - return ; + return ; } return ( diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index da6165ca..8da84ba9 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -12,13 +12,16 @@ import { type FC, type ReactNode, type SVGAttributes, + type ReactElement, } from "react"; import { useTranslation } from "react-i18next"; +import { logger } from "matrix-js-sdk/src/logger"; import { RageshakeButton } from "./settings/RageshakeButton"; import styles from "./ErrorView.module.css"; import { useUrlParams } from "./UrlParams"; import { LinkButton } from "./button"; +import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts"; interface Props { Icon: ComponentType>; @@ -35,6 +38,7 @@ interface Props { */ fatal?: boolean; children: ReactNode; + widget: WidgetHelpers | null; } export const ErrorView: FC = ({ @@ -43,6 +47,7 @@ export const ErrorView: FC = ({ rageshake, fatal, children, + widget, }) => { const { t } = useTranslation(); const { confineToRoom } = useUrlParams(); @@ -51,6 +56,46 @@ export const ErrorView: FC = ({ window.location.href = "/"; }, []); + const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({ + widget, + }): ReactElement => { + // in widget mode we don't want to show the return home button but a close button + const closeWidget = (): void => { + widget.api.transport + .send(ElementWidgetActions.Close, {}) + .catch((e) => { + // What to do here? + logger.error("Failed to send close action", e); + }) + .finally(() => { + widget.api.transport.stop(); + }); + }; + return ( + + ); + }; + + // Whether the error is considered fatal or pathname is `/` then reload the all app. + // If not then navigate to home page. + const ReturnToHomeButton = (): ReactElement => { + if (fatal || location.pathname === "/") { + return ( + + ); + } else { + return ( + + {t("return_home_button")} + + ); + } + }; + return (
@@ -63,20 +108,11 @@ export const ErrorView: FC = ({ {rageshake && ( )} - {!confineToRoom && - (fatal || location.pathname === "/" ? ( - - ) : ( - - {t("return_home_button")} - - ))} + {widget ? ( + + ) : ( + !confineToRoom && + )}
); }; diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index fb2d4609..c8655229 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -17,6 +17,7 @@ import styles from "./FullScreenView.module.css"; import { useUrlParams } from "./UrlParams"; import { RichError } from "./RichError"; import { ErrorView } from "./ErrorView"; +import { type WidgetHelpers } from "./widget.ts"; interface FullScreenViewProps { className?: string; @@ -47,11 +48,12 @@ export const FullScreenView: FC = ({ interface ErrorPageProps { error: Error | unknown; + widget: WidgetHelpers | null; } // Due to this component being used as the crash fallback for Sentry, which has // weird type requirements, we can't just give this a type of FC -export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { +export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); useEffect(() => { logger.error(error); @@ -63,7 +65,13 @@ export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { {error instanceof RichError ? ( error.richMessage ) : ( - +

{t("error.generic_description")}

)} diff --git a/src/RichError.tsx b/src/RichError.tsx index 1525f153..699486e2 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -5,16 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { Trans, useTranslation } from "react-i18next"; -import { - ErrorIcon, - HostIcon, - PopOutIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useTranslation } from "react-i18next"; +import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import type { ComponentType, FC, ReactNode, SVGAttributes } from "react"; +import type { FC, ReactNode } from "react"; import { ErrorView } from "./ErrorView"; -import { type ElementCallError, ErrorCategory } from "./utils/errors.ts"; +import { widget } from "./widget.ts"; /** * An error consisting of a terse message to be logged to the console and a @@ -36,7 +32,11 @@ const OpenElsewhere: FC = () => { const { t } = useTranslation(); return ( - +

{t("error.open_elsewhere_description", { brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", @@ -51,62 +51,3 @@ export class OpenElsewhereError extends RichError { super("App opened in another tab", ); } } - -const InsufficientCapacity: FC = () => { - const { t } = useTranslation(); - - return ( - -

{t("error.insufficient_capacity_description")}

-
- ); -}; - -export class InsufficientCapacityError extends RichError { - public constructor() { - super("Insufficient server capacity", ); - } -} - -type ECErrorProps = { - error: ElementCallError; -}; - -const GenericECError: FC<{ error: ElementCallError }> = ({ - error, -}: ECErrorProps) => { - const { t } = useTranslation(); - - let title: string; - let icon: ComponentType>; - switch (error.category) { - case ErrorCategory.CONFIGURATION_ISSUE: - title = t("error.call_is_not_supported"); - icon = HostIcon; - break; - default: - title = t("error.generic"); - icon = ErrorIcon; - } - return ( - -

- {error.localisedMessage ?? ( - , ]} - values={{ errorCode: error.code }} - /> - )} -

-
- ); -}; - -export class ElementCallRichError extends RichError { - public ecError: ElementCallError; - public constructor(ecError: ElementCallError) { - super(ecError.message, ); - this.ecError = ecError; - } -} diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index e6136855..ca1f0ea8 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -13,6 +13,7 @@ import { ErrorPage, LoadingPage } from "../FullScreenView"; import { UnauthenticatedView } from "./UnauthenticatedView"; import { RegisteredView } from "./RegisteredView"; import { usePageTitle } from "../usePageTitle"; +import { widget } from "../widget.ts"; export const HomePage: FC = () => { const { t } = useTranslation(); @@ -23,7 +24,7 @@ export const HomePage: FC = () => { if (!clientState) { return ; } else if (clientState.state === "error") { - return ; + return ; } else { return clientState.authenticated ? ( diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 1314ce81..287ee4dc 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { type FC, useCallback, useState } from "react"; -import { test } from "vitest"; +import { test, vi } from "vitest"; import { ConnectionError, ConnectionErrorReason, @@ -14,12 +14,11 @@ import { } from "livekit-client"; import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; -import { ErrorBoundary } from "@sentry/react"; import { MemoryRouter } from "react-router-dom"; -import { ErrorPage } from "../FullScreenView"; import { useECConnectionState } from "./useECConnectionState"; import { type SFUConfig } from "./openIDSFU"; +import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx"; test.each<[string, ConnectionError]>([ [ @@ -61,9 +60,9 @@ test.each<[string, ConnectionError]>([ const user = userEvent.setup(); render( - + - + , ); await user.click(screen.getByRole("button", { name: "Connect" })); diff --git a/src/livekit/useECConnectionState.ts b/src/livekit/useECConnectionState.ts index 8cd5f87e..e575abef 100644 --- a/src/livekit/useECConnectionState.ts +++ b/src/livekit/useECConnectionState.ts @@ -20,7 +20,11 @@ import * as Sentry from "@sentry/react"; import { type SFUConfig, sfuConfigEquals } from "./openIDSFU"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; -import { InsufficientCapacityError, RichError } from "../RichError"; +import { + ElementCallError, + InsufficientCapacityError, + UnknownCallError, +} from "../utils/errors.ts"; declare global { interface Window { @@ -188,7 +192,7 @@ export function useECConnectionState( const [isSwitchingFocus, setSwitchingFocus] = useState(false); const [isInDoConnect, setIsInDoConnect] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); if (error !== null) throw error; const onConnStateChanged = useCallback((state: ConnectionState) => { @@ -271,9 +275,11 @@ export function useECConnectionState( initialAudioOptions, ) .catch((e) => { - if (e instanceof RichError) + if (e instanceof ElementCallError) { setError(e); // Bubble up any error screens to React - else logger.error("Failed to connect to SFU", e); + } else if (e instanceof Error) { + setError(new UnknownCallError(e)); + } else logger.error("Failed to connect to SFU", e); }) .finally(() => setIsInDoConnect(false)); } diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx new file mode 100644 index 00000000..f2a10bc2 --- /dev/null +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -0,0 +1,253 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + type FC, + type ReactElement, + type ReactNode, + useCallback, + useState, +} from "react"; +import { BrowserRouter } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; + +import { + type CallErrorRecoveryAction, + GroupCallErrorBoundary, +} from "./GroupCallErrorBoundary.tsx"; +import { + ConnectionLostError, + E2EENotSupportedError, + type ElementCallError, + InsufficientCapacityError, + MatrixRTCFocusMissingError, + UnknownCallError, +} from "../utils/errors.ts"; +import { mockConfig } from "../utils/test.ts"; +import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts"; + +test.each([ + { + error: new MatrixRTCFocusMissingError("example.com"), + expectedTitle: "Call is not supported", + }, + { + error: new ConnectionLostError(), + expectedTitle: "Connection lost", + expectedDescription: "You were disconnected from the call.", + }, + { + error: new E2EENotSupportedError(), + expectedTitle: "Incompatible browser", + expectedDescription: + "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.", + }, + { + error: new InsufficientCapacityError(), + expectedTitle: "Insufficient capacity", + expectedDescription: + "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.", + }, +])( + "should report correct error for $expectedTitle", + async ({ error, expectedTitle, expectedDescription }) => { + const TestComponent = (): ReactNode => { + throw error; + }; + + const onErrorMock = vi.fn(); + const { asFragment } = render( + + + + + , + ); + + await screen.findByText(expectedTitle); + if (expectedDescription) { + expect(screen.queryByText(expectedDescription)).toBeInTheDocument(); + } + expect(onErrorMock).toHaveBeenCalledWith(error); + + expect(asFragment()).toMatchSnapshot(); + }, +); + +test("should render the error page with link back to home", async () => { + const error = new MatrixRTCFocusMissingError("example.com"); + const TestComponent = (): ReactNode => { + throw error; + }; + + const onErrorMock = vi.fn(); + const { asFragment } = render( + + + + + , + ); + + await screen.findByText("Call is not supported"); + expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument(); + expect( + screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i), + ).toBeInTheDocument(); + + await screen.findByRole("button", { name: "Return to home screen" }); + + expect(onErrorMock).toHaveBeenCalledOnce(); + expect(onErrorMock).toHaveBeenCalledWith(error); + + expect(asFragment()).toMatchSnapshot(); +}); + +test("ConnectionLostError: Action handling should reset error state", async () => { + const user = userEvent.setup(); + + const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => { + if (fail) { + throw new ConnectionLostError(); + } + return
HELLO
; + }; + + const reconnectCallbackSpy = vi.fn(); + + const WrapComponent = (): ReactNode => { + const [failState, setFailState] = useState(true); + const reconnectCallback = useCallback( + (action: CallErrorRecoveryAction) => { + reconnectCallbackSpy(action); + setFailState(false); + }, + [setFailState], + ); + + return ( + + + + + + ); + }; + + const { asFragment } = render(); + + // Should fail first + await screen.findByText("Connection lost"); + await screen.findByRole("button", { name: "Reconnect" }); + await screen.findByRole("button", { name: "Return to home screen" }); + + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Reconnect" })); + + // reconnect should have reset the error, thus rendering should be ok + await screen.findByText("HELLO"); + + expect(reconnectCallbackSpy).toHaveBeenCalledOnce(); + expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect"); +}); + +describe("Rageshake button", () => { + function setupTest(testError: ElementCallError): void { + mockConfig({ + rageshake: { + submit_url: "https://rageshake.example.com.localhost", + }, + }); + + const TestComponent = (): ReactElement => { + throw testError; + }; + + render( + + + + + , + ); + } + + test("should show send rageshake button for unknown errors", () => { + setupTest(new UnknownCallError(new Error("FOO"))); + + expect( + screen.queryByRole("button", { name: "Send debug logs" }), + ).toBeInTheDocument(); + }); + + test("should not show send rageshake button for call errors", () => { + setupTest(new E2EENotSupportedError()); + + expect( + screen.queryByRole("button", { name: "Send debug logs" }), + ).not.toBeInTheDocument(); + }); +}); + +test("should have a close button in widget mode", async () => { + const error = new MatrixRTCFocusMissingError("example.com"); + const TestComponent = (): ReactNode => { + throw error; + }; + + const mockWidget = { + api: { + transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() }, + }, + } as unknown as WidgetHelpers; + + const user = userEvent.setup(); + const onErrorMock = vi.fn(); + const { asFragment } = render( + + + + + , + ); + + await screen.findByText("Call is not supported"); + + await screen.findByRole("button", { name: "Close" }); + + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(mockWidget.api.transport.send).toHaveBeenCalledWith( + ElementWidgetActions.Close, + expect.anything(), + ); + expect(mockWidget.api.transport.stop).toHaveBeenCalled(); +}); diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx new file mode 100644 index 00000000..170718d6 --- /dev/null +++ b/src/room/GroupCallErrorBoundary.tsx @@ -0,0 +1,146 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { ErrorBoundary, type FallbackRender } from "@sentry/react"; +import { + type ComponentType, + type FC, + type ReactElement, + type ReactNode, + type SVGAttributes, + useCallback, +} from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + ErrorIcon, + HostIcon, + OfflineIcon, + WebBrowserIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { + ConnectionLostError, + ElementCallError, + ErrorCategory, + ErrorCode, + UnknownCallError, +} from "../utils/errors.ts"; +import { FullScreenView } from "../FullScreenView.tsx"; +import { ErrorView } from "../ErrorView.tsx"; +import { type WidgetHelpers } from "../widget.ts"; + +export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; + +export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void; + +interface ErrorPageProps { + error: ElementCallError; + recoveryActionHandler: RecoveryActionHandler; + resetError: () => void; + widget: WidgetHelpers | null; +} + +const ErrorPage: FC = ({ + error, + recoveryActionHandler, + widget, +}: ErrorPageProps): ReactElement => { + const { t } = useTranslation(); + + let icon: ComponentType>; + switch (error.category) { + case ErrorCategory.CONFIGURATION_ISSUE: + icon = HostIcon; + break; + case ErrorCategory.NETWORK_CONNECTIVITY: + icon = OfflineIcon; + break; + case ErrorCategory.CLIENT_CONFIGURATION: + icon = WebBrowserIcon; + break; + default: + icon = ErrorIcon; + } + + const actions: { label: string; onClick: () => void }[] = []; + if (error instanceof ConnectionLostError) { + actions.push({ + label: t("call_ended_view.reconnect_button"), + onClick: () => recoveryActionHandler("reconnect"), + }); + } + + return ( + + +

+ {error.localisedMessage ?? ( + , ]} + values={{ errorCode: error.code }} + /> + )} +

+ {actions && + actions.map((action, index) => ( + + ))} +
+
+ ); +}; + +interface BoundaryProps { + children: ReactNode | (() => ReactNode); + recoveryActionHandler: RecoveryActionHandler; + onError?: (error: unknown) => void; + widget: WidgetHelpers | null; +} + +export const GroupCallErrorBoundary = ({ + recoveryActionHandler, + onError, + children, + widget, +}: BoundaryProps): ReactElement => { + const fallbackRenderer: FallbackRender = useCallback( + ({ error, resetError }): ReactElement => { + const callError = + error instanceof ElementCallError + ? error + : new UnknownCallError(error instanceof Error ? error : new Error()); + return ( + { + resetError(); + recoveryActionHandler(action); + }} + /> + ); + }, + [recoveryActionHandler, widget], + ); + + return ( + onError?.(error)} + children={children} + /> + ); +}; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 6ae2b415..9ebd7e3b 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import { type FC, - type ReactElement, type ReactNode, useCallback, useEffect, @@ -25,14 +24,7 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; -import { - OfflineIcon, - WebBrowserIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { ErrorBoundary } from "@sentry/react"; -import { Button } from "@vector-im/compound-web"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { @@ -40,7 +32,6 @@ import { type JoinCallData, type WidgetHelpers, } from "../widget"; -import { ErrorPage, FullScreenView } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; import { type MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; @@ -63,15 +54,14 @@ import { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { useLatest } from "../useLatest"; import { usePageTitle } from "../usePageTitle"; -import { ErrorView } from "../ErrorView"; import { - ConnectionLostError, + E2EENotSupportedError, ElementCallError, - ErrorCategory, ErrorCode, RTCSessionError, + UnknownCallError, } from "../utils/errors.ts"; -import { ElementCallRichError } from "../RichError.tsx"; +import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { useNewMembershipManagerSetting as useNewMembershipManagerSetting, useSetting, @@ -84,11 +74,6 @@ declare global { } } -interface GroupCallErrorPageProps { - error: Error | unknown; - resetError: () => void; -} - interface Props { client: MatrixClient; isPasswordlessUser: boolean; @@ -205,10 +190,8 @@ export const GroupCallView: FC = ({ setError(e); } else { logger.error(`Unknown Error while entering RTC session`, e); - const error = new ElementCallError( - e instanceof Error ? e.message : "Unknown error", - ErrorCode.UNKNOWN_ERROR, - ErrorCategory.UNKNOWN, + const error = new UnknownCallError( + e instanceof Error ? e : new Error("Unknown error", { cause: e }), ); setError(error); } @@ -398,58 +381,9 @@ export const GroupCallView: FC = ({ ); const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null; - const { t } = useTranslation(); - - const errorPage = useMemo(() => { - function GroupCallErrorPage({ - error, - resetError, - }: GroupCallErrorPageProps): ReactElement { - useEffect(() => { - if (rtcSession.isJoined()) onLeave("error"); - }, [error]); - - const onReconnect = useCallback(() => { - setLeft(false); - resetError(); - enterRTCSessionOrError( - rtcSession, - perParticipantE2EE, - useNewMembershipManager, - ).catch((e) => { - logger.error("Error re-entering RTC session on reconnect", e); - }); - }, [resetError]); - - return error instanceof ConnectionLostError ? ( - - -

{t("error.connection_lost_description")}

- -
-
- ) : ( - - ); - } - return GroupCallErrorPage; - }, [t, rtcSession, onLeave, perParticipantE2EE, useNewMembershipManager]); - if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) { // If we have a encryption system but the browser does not support it. - return ( - - -

{t("error.e2ee_unsupported_description")}

-
-
- ); + throw new E2EENotSupportedError(); } const shareModal = ( @@ -484,9 +418,9 @@ export const GroupCallView: FC = ({ let body: ReactNode; if (error) { // If an ElementCallError was recorded, then create a component that will fail to render and throw - // an ElementCallRichError error. This will then be handled by the ErrorBoundary component. + // the error. This will then be handled by the ErrorBoundary component. const ErrorComponent = (): ReactNode => { - throw new ElementCallRichError(error); + throw error; }; body = ; } else if (isJoined) { @@ -543,5 +477,28 @@ export const GroupCallView: FC = ({ body = lobbyView; } - return {body}; + return ( + { + if (action == "reconnect") { + setLeft(false); + enterRTCSessionOrError( + rtcSession, + perParticipantE2EE, + useNewMembershipManager, + ).catch((e) => { + logger.error("Error re-entering RTC session", e); + }); + } + }} + onError={ + (/**error*/) => { + if (rtcSession.isJoined()) onLeave("error"); + } + } + > + {body} + + ); }; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 59e56aac..e96790c5 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -182,6 +182,7 @@ export const RoomPage: FC = () => {

@@ -199,6 +200,7 @@ export const RoomPage: FC = () => {

{groupCallState.error.messageBody}

{groupCallState.error.reason && ( @@ -212,7 +214,7 @@ export const RoomPage: FC = () => { ); } else { - return ; + return ; } default: return <> ; @@ -223,7 +225,7 @@ export const RoomPage: FC = () => { if (loading || isRegistering) { content = ; } else if (error) { - content = ; + content = ; } else if (!client) { content = ; } else if (!roomIdOrAlias) { diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap new file mode 100644 index 00000000..5aab22a2 --- /dev/null +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -0,0 +1,1051 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ConnectionLostError: Action handling should reset error state 1`] = ` + +
+
+ +
+
+
+
+
+ + + +
+

+ Connection lost +

+

+ You were disconnected from the call. +

+ + +
+
+
+
+
+`; + +exports[`should have a close button in widget mode 1`] = ` + +
+
+ +
+
+
+
+
+ + + + +
+

+ Call is not supported +

+

+ The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). +

+ +
+
+
+
+
+`; + +exports[`should render the error page with link back to home 1`] = ` + +
+
+ +
+
+
+
+
+ + + + +
+

+ Call is not supported +

+

+ The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). +

+ +
+
+
+
+
+`; + +exports[`should report correct error for 'Call is not supported' 1`] = ` + +
+
+ +
+
+
+
+
+ + + + +
+

+ Call is not supported +

+

+ The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). +

+ +
+
+
+
+
+`; + +exports[`should report correct error for 'Connection lost' 1`] = ` + +
+
+ +
+
+
+
+
+ + + +
+

+ Connection lost +

+

+ You were disconnected from the call. +

+ + +
+
+
+
+
+`; + +exports[`should report correct error for 'Incompatible browser' 1`] = ` + +
+
+ +
+
+
+
+
+ + + +
+

+ Incompatible browser +

+

+ Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+. +

+ +
+
+
+
+
+`; + +exports[`should report correct error for 'Insufficient capacity' 1`] = ` + +
+
+ +
+
+
+
+
+ + + +
+

+ Insufficient capacity +

+

+ The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists. +

+ +
+
+
+
+
+`; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 05b7dedc..f8afda81 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -14,6 +14,9 @@ export enum ErrorCode { MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS", CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", MEMBERSHIP_MANAGER_UNRECOVERABLE = "MEMBERSHIP_MANAGER_UNRECOVERABLE", + /** LiveKit indicates that the server has hit its track limits */ + INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", + E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", UNKNOWN_ERROR = "UNKNOWN_ERROR", } @@ -22,6 +25,7 @@ export enum ErrorCategory { CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE", NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY", RTC_SESSION_FAILURE = "RTC_SESSION_FAILURE", + CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION", UNKNOWN = "UNKNOWN", // SYSTEM_FAILURE / FEDERATION_FAILURE .. } @@ -33,14 +37,17 @@ export class ElementCallError extends Error { public code: ErrorCode; public category: ErrorCategory; public localisedMessage?: string; + public localisedTitle: string; - public constructor( - name: string, + protected constructor( + localisedTitle: string, code: ErrorCode, category: ErrorCategory, - localisedMessage?: string, + localisedMessage: string, + cause?: Error, ) { - super(name); + super(localisedTitle, { cause }); + this.localisedTitle = localisedTitle; this.localisedMessage = localisedMessage; this.category = category; this.code = code; @@ -52,7 +59,7 @@ export class MatrixRTCFocusMissingError extends ElementCallError { public constructor(domain: string) { super( - "MatrixRTCFocusMissingError", + t("error.call_is_not_supported"), ErrorCode.MISSING_MATRIX_RTC_FOCUS, ErrorCategory.CONFIGURATION_ISSUE, t("error.matrix_rtc_focus_missing", { @@ -68,9 +75,10 @@ export class MatrixRTCFocusMissingError extends ElementCallError { export class ConnectionLostError extends ElementCallError { public constructor() { super( - "Connection lost", + t("error.connection_lost"), ErrorCode.CONNECTION_LOST_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, + t("error.connection_lost_description"), ); } } @@ -80,3 +88,38 @@ export class RTCSessionError extends ElementCallError { super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message); } } + +export class E2EENotSupportedError extends ElementCallError { + public constructor() { + super( + t("error.e2ee_unsupported"), + ErrorCode.E2EE_NOT_SUPPORTED, + ErrorCategory.CLIENT_CONFIGURATION, + t("error.e2ee_unsupported_description"), + ); + } +} + +export class UnknownCallError extends ElementCallError { + public constructor(error: Error) { + super( + t("error.generic"), + ErrorCode.UNKNOWN_ERROR, + ErrorCategory.UNKNOWN, + error.message, + // Properly set it as a cause for a better reporting on sentry + error, + ); + } +} + +export class InsufficientCapacityError extends ElementCallError { + public constructor() { + super( + t("error.insufficient_capacity"), + ErrorCode.INSUFFICIENT_CAPACITY_ERROR, + ErrorCategory.UNKNOWN, + t("error.insufficient_capacity_description"), + ); + } +}