From 28c7c9dd380c20ee59c6950cd61900672bdfc311 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 5 Mar 2025 20:50:19 +0100 Subject: [PATCH] refactor: Centralize group call errors in custom GroupCallErrorBoundary --- src/RichError.tsx | 70 +- src/livekit/useECConnectionState.test.tsx | 7 +- src/livekit/useECConnectionState.ts | 14 +- src/room/GroupCallErrorBoundary.test.tsx | 170 +++ src/room/GroupCallErrorBoundary.tsx | 137 +++ src/room/GroupCallView.tsx | 99 +- .../GroupCallErrorBoundary.test.tsx.snap | 1051 +++++++++++++++++ src/utils/errors.ts | 55 +- 8 files changed, 1450 insertions(+), 153 deletions(-) create mode 100644 src/room/GroupCallErrorBoundary.test.tsx create mode 100644 src/room/GroupCallErrorBoundary.tsx create mode 100644 src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap diff --git a/src/RichError.tsx b/src/RichError.tsx index 1525f153..abacf0b3 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -5,16 +5,11 @@ 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"; /** * An error consisting of a terse message to be logged to the console and a @@ -51,62 +46,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/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 1314ce81..6ee63c3b 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -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..94c96794 --- /dev/null +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -0,0 +1,170 @@ +/* +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 ReactElement, type ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; + +import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; +import { + ConnectionLostError, + E2EENotSupportedError, + type ElementCallError, + InsufficientCapacityError, + MatrixRTCFocusMissingError, + UnknownCallError, +} from "../utils/errors.ts"; +import { mockConfig } from "../utils/test.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("should have a reconnect button for ConnectionLostError", async () => { + const user = userEvent.setup(); + + const reconnectCallback = vi.fn(); + + const TestComponent = (): ReactNode => { + throw new ConnectionLostError(); + }; + + const { asFragment } = render( + + + + + , + ); + + 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" })); + + expect(reconnectCallback).toHaveBeenCalledOnce(); + expect(reconnectCallback).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(); + }); +}); diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx new file mode 100644 index 00000000..758016b2 --- /dev/null +++ b/src/room/GroupCallErrorBoundary.tsx @@ -0,0 +1,137 @@ +/* +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"; + +export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; + +export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void; + +interface ErrorPageProps { + error: ElementCallError; + recoveryActionHandler?: RecoveryActionHandler; + resetError: () => void; +} + +const ErrorPage: FC = ({ + error, + recoveryActionHandler, +}: ErrorPageProps): ReactElement => { + const { t } = useTranslation(); + + // let title: string; + 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; +} + +export const GroupCallErrorBoundary = ({ + recoveryActionHandler, + onError, + children, +}: BoundaryProps): ReactElement => { + const fallbackRenderer: FallbackRender = useCallback( + ({ error, resetError }): ReactElement => { + const callError = + error instanceof ElementCallError + ? error + : new UnknownCallError(error instanceof Error ? error : new Error()); + return ( + + ); + }, + [recoveryActionHandler], + ); + + return ( + onError?.(error)} + children={children} + /> + ); +}; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 95d1d12c..226fb9f5 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, @@ -22,14 +21,7 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { JoinRule } 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 { @@ -37,7 +29,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"; @@ -60,14 +51,12 @@ 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, + UnknownCallError, } from "../utils/errors.ts"; -import { ElementCallRichError } from "../RichError.tsx"; +import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; declare global { interface Window { @@ -75,11 +64,6 @@ declare global { } } -interface GroupCallErrorPageProps { - error: Error | unknown; - resetError: () => void; -} - interface Props { client: MatrixClient; isPasswordlessUser: boolean; @@ -184,10 +168,8 @@ export const GroupCallView: FC = ({ setEnterRTCError(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 }), ); setEnterRTCError(error); } @@ -365,54 +347,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).catch((e) => { - logger.error("Error re-entering RTC session", e); - }); - }, [resetError]); - - return error instanceof ConnectionLostError ? ( - - -

{t("error.connection_lost_description")}

- -
-
- ) : ( - - ); - } - return GroupCallErrorPage; - }, [onLeave, rtcSession, perParticipantE2EE, t]); - 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 = ( @@ -443,9 +380,9 @@ export const GroupCallView: FC = ({ let body: ReactNode; if (enterRTCError) { // 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(enterRTCError); + throw enterRTCError; }; body = ; } else if (isJoined) { @@ -504,5 +441,23 @@ export const GroupCallView: FC = ({ body = lobbyView; } - return {body}; + return ( + { + if (action == "reconnect") { + setLeft(false); + enterRTCSessionOrError(rtcSession, perParticipantE2EE).catch((e) => { + logger.error("Error re-entering RTC session", e); + }); + } + }} + onError={ + (/**error*/) => { + if (rtcSession.isJoined()) onLeave("error"); + } + } + > + {body} + + ); }; diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap new file mode 100644 index 00000000..f5814114 --- /dev/null +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -0,0 +1,1051 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should have a reconnect button for ConnectionLostError 1`] = ` + +
+
+ +
+
+
+
+
+ + + +
+

+ Connection lost +

+

+ You were disconnected from the call. +

+ + +
+
+
+
+
+`; + +exports[`should render the error page 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 c87bdee7..91943fb4 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,6 +13,9 @@ export enum ErrorCode { */ MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS", CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", + /** 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", } @@ -20,6 +23,7 @@ export enum ErrorCategory { /** Calling is not supported, server misconfigured (JWT service missing, no MSC support ...)*/ CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE", NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY", + CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION", UNKNOWN = "UNKNOWN", // SYSTEM_FAILURE / FEDERATION_FAILURE .. } @@ -31,14 +35,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; @@ -50,7 +57,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", { @@ -66,9 +73,45 @@ 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"), + ); + } +} + +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"), ); } }