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 6ae2b415..d1086730 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 enterRTCError; }; body = ; } else if (isJoined) { @@ -543,5 +477,27 @@ 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/__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 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"), + ); + } +}