diff --git a/src/RichError.tsx b/src/RichError.tsx index 1525f153..8424734e 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -5,16 +5,14 @@ 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 { useTranslation } from "react-i18next"; import { - ErrorIcon, HostIcon, 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 @@ -67,46 +65,3 @@ export class InsufficientCapacityError extends RichError { 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/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 0a57d081..37207967 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -5,16 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest"; -import { render, waitFor, screen } from "@testing-library/react"; +import { + vi, + beforeEach, + expect, + type MockedFunction, + test, + vitest, + describe, +} from "vitest"; +import { render, waitFor, screen, fireEvent } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { of } from "rxjs"; -import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix"; +import { + AutoDiscovery, + JoinRule, + type RoomState, +} from "matrix-js-sdk/src/matrix"; import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; import { type MuteStates } from "./MuteStates"; import { prefetchSounds } from "../soundUtils"; @@ -22,6 +35,7 @@ import { useAudioContext } from "../useAudioContext"; import { ActiveCall } from "./InCallView"; import { flushPromises, + mockConfig, mockMatrixRoom, mockMatrixRoomMember, mockRtcMembership, @@ -31,6 +45,8 @@ import { GroupCallView } from "./GroupCallView"; import { leaveRTCSession } from "../rtcSessionHelpers"; import { type WidgetHelpers } from "../widget"; import { LazyEventEmitter } from "../LazyEventEmitter"; +import { useCallErrorDisplay } from "../utils/useCallErrorDisplay.tsx"; +import { ConnectionLostError } from "../utils/errors.ts"; vitest.mock("../soundUtils"); vitest.mock("../useAudioContext"); @@ -198,6 +214,7 @@ test("GroupCallView leaves the session when an error occurs", async () => { }); const user = userEvent.setup(); const { rtcSession } = createGroupCallView(null); + rtcSession.withJoined(); await user.click(screen.getByRole("button", { name: "Panic!" })); screen.getByText("Something went wrong"); expect(leaveRTCSession).toHaveBeenCalledWith( @@ -210,3 +227,171 @@ test("GroupCallView leaves the session when an error occurs", async () => { // impacting the results of other tests await waitFor(() => expect(leaveRTCSession).toHaveResolved()); }); + +describe("GroupCallView call error handling", () => { + function setup(): { + client: MatrixClient; + rtcSession: MatrixRTCSession; + muteState: MuteStates; + } { + const muteState = { + audio: { enabled: false }, + video: { enabled: false }, + } as MuteStates; + + const client = { + getUser: () => null, + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + getRoom: (rId) => (rId === roomId ? room : null), + getDomain(): string | null { + return "example.org"; + }, + } as Partial as MatrixClient; + + const room = mockMatrixRoom({ + relations: { + getChildEventsForEvent: () => + vitest.mocked({ + getRelations: () => [], + }), + } as unknown as RelationsContainer, + client, + roomId, + getMember: (userId) => roomMembers.get(userId) ?? null, + getMxcAvatarUrl: () => null, + getCanonicalAlias: () => null, + currentState: { + getJoinRule: () => JoinRule.Invite, + } as Partial as RoomState, + }); + + const rtcSession = vi.mocked({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + room: room, + memberships: [], + getFocusInUse: vi.fn().mockReturnValue(undefined), + isJoined: vi.fn().mockReturnValue(false), + joinRoomSession: vi.fn().mockReturnValue(Promise.resolve()), + }) as unknown as MatrixRTCSession; + + // if not VideoPreview fails to render due to [Error: This browser does not support ResizeObserver out of the box.] + global.ResizeObserver = class { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} + }; + window.ResizeObserver = global.ResizeObserver; + + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn().mockReturnValue(new Promise(() => {})), + }, + }); + return { client, rtcSession, muteState }; + } + + test("Missing RTC Focus error handling", async () => { + const { client, rtcSession, muteState } = setup(); + + const { asFragment } = render( + + // router needed because GroupCallView uses useLocation() + + + + , + ); + + // Ensure no focus config in well-known + vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation( + async (domain) => { + return Promise.resolve({}); + }, + ); + // As well as no default + mockConfig({}); + + vi.spyOn(rtcSession, "getFocusInUse").mockReturnValue(undefined); + const joinButton = screen.getByTestId("lobby_joinCall"); + expect(joinButton).toBeInTheDocument(); + + fireEvent.click(joinButton); + + const element = await screen.findByText("Call is not supported"); // waits for the element + expect(element).toBeInTheDocument(); + + expect(screen.getByText(/Domain: example.org/i)).toBeInTheDocument(); + expect( + screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i), + ).toBeInTheDocument(); + + expect(asFragment()).toMatchSnapshot(); + }); + + test("Connection Failed error", async () => { + const { client, rtcSession, muteState } = setup(); + const user = userEvent.setup(); + const reconnect = vi.fn(); + (ActiveCall as MockedFunction).mockImplementation(() => { + const { setCallErrorState } = useCallErrorDisplay(); + useEffect(() => { + setTimeout(() => { + setCallErrorState({ + cause: new ConnectionLostError(), + actions: [ + { + labelKey: "call_ended_view.reconnect_button", + onClick: reconnect, + }, + ], + }); + }); + }, [setCallErrorState]); + return
Active call
; + }); + + const { asFragment } = render( + + // router needed because GroupCallView uses useLocation() + + + + , + ); + + await screen.findByText("Connection lost"); // waits for the element + expect( + screen.getByText(/You were disconnected from the call./i), + ).toBeInTheDocument(); + + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Reconnect" })); + expect(reconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 95d1d12c..c0cf5ac5 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -22,14 +22,8 @@ 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 +31,7 @@ import { type JoinCallData, type WidgetHelpers, } from "../widget"; -import { ErrorPage, FullScreenView } from "../FullScreenView"; +import { ErrorPage } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; import { type MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; @@ -60,14 +54,18 @@ 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, } from "../utils/errors.ts"; -import { ElementCallRichError } from "../RichError.tsx"; +import { + CallErrorStateProvider, + useCallErrorDisplay, +} from "../utils/useCallErrorDisplay.tsx"; +import { CallErrorDisplay } from "../utils/CallErrorDisplay.tsx"; declare global { interface Window { @@ -75,11 +73,6 @@ declare global { } } -interface GroupCallErrorPageProps { - error: Error | unknown; - resetError: () => void; -} - interface Props { client: MatrixClient; isPasswordlessUser: boolean; @@ -93,7 +86,16 @@ interface Props { widget: WidgetHelpers | null; } -export const GroupCallView: FC = ({ +export const GroupCallView: FC = (pr) => { + return ( + + + + + ); +}; + +const GroupCallViewInner: FC = ({ client, isPasswordlessUser, confineToRoom, @@ -106,6 +108,8 @@ export const GroupCallView: FC = ({ widget, }) => { const memberships = useMatrixRTCSessionMemberships(rtcSession); + const { callErrorState, setCallErrorState } = useCallErrorDisplay(); + const leaveSoundContext = useLatest( useAudioContext({ sounds: callEventAudioSounds, @@ -172,27 +176,29 @@ export const GroupCallView: FC = ({ const latestDevices = useLatest(deviceContext); const latestMuteStates = useLatest(muteStates); - const enterRTCSessionOrError = async ( - rtcSession: MatrixRTCSession, - perParticipantE2EE: boolean, - ): Promise => { - try { - await enterRTCSession(rtcSession, perParticipantE2EE); - } catch (e) { - if (e instanceof ElementCallError) { - // e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL) - 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, - ); - setEnterRTCError(error); + const enterRTCSessionOrError = useCallback( + async ( + rtcSession: MatrixRTCSession, + perParticipantE2EE: boolean, + ): Promise => { + try { + await enterRTCSession(rtcSession, perParticipantE2EE); + } catch (e) { + if (e instanceof ElementCallError) { + setCallErrorState({ cause: e }); + } else { + // logger.error(`ABDE Unknown Error while entering RTC session`, e); + const error = new ElementCallError( + e instanceof Error ? e.message : "Unknown error", + ErrorCode.UNKNOWN_ERROR, + ErrorCategory.UNKNOWN, + ); + setCallErrorState({ cause: error }); + } } - } - }; + }, + [setCallErrorState], + ); useEffect(() => { const defaultDeviceSetup = async ({ @@ -247,6 +253,16 @@ export const GroupCallView: FC = ({ widget.api.transport.reply(ev.detail, {}); })().catch((e) => { logger.error("Error joining RTC session", e); + setCallErrorState({ + cause: + e instanceof ElementCallError + ? e + : new ElementCallError( + e.message, + ErrorCode.UNKNOWN_ERROR, + ErrorCategory.UNKNOWN, + ), + }); }); }; widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); @@ -255,11 +271,7 @@ export const GroupCallView: FC = ({ }; } else { // No lobby and no preload: we enter the rtc session right away - (async (): Promise => { - await enterRTCSessionOrError(rtcSession, perParticipantE2EE); - })().catch((e) => { - logger.error("Error joining RTC session", e); - }); + void enterRTCSessionOrError(rtcSession, perParticipantE2EE); } } else { void enterRTCSessionOrError(rtcSession, perParticipantE2EE); @@ -273,12 +285,11 @@ export const GroupCallView: FC = ({ perParticipantE2EE, latestDevices, latestMuteStates, + setCallErrorState, + enterRTCSessionOrError, ]); const [left, setLeft] = useState(false); - const [enterRTCError, setEnterRTCError] = useState( - null, - ); const navigate = useNavigate(); const onLeave = useCallback( @@ -299,7 +310,6 @@ export const GroupCallView: FC = ({ ); window.setTimeout(resolve, 10); }); - leaveRTCSession( rtcSession, cause, @@ -365,54 +375,36 @@ export const GroupCallView: FC = ({ ); const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null; - const { t } = useTranslation(); + const onDisconnected = useCallback(() => { + if (rtcSession.isJoined()) onLeave("error"); - const errorPage = useMemo(() => { - function GroupCallErrorPage({ - error, - resetError, - }: GroupCallErrorPageProps): ReactElement { - useEffect(() => { - if (rtcSession.isJoined()) onLeave("error"); - }, [error]); + const reconnect = async (): void => { + setLeft(false); + await enterRTCSessionOrError(rtcSession, perParticipantE2EE); + }; - 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]); + setCallErrorState({ + cause: new ConnectionLostError(), + actions: [ + { + labelKey: "call_ended_view.reconnect_button", + onClick: reconnect, + }, + ], + }); + }, [ + rtcSession, + setLeft, + enterRTCSessionOrError, + perParticipantE2EE, + onLeave, + setCallErrorState, + ]); 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")}

-
-
- ); + setCallErrorState({ cause: new E2EENotSupportedError() }); + return <>; } const shareModal = ( @@ -441,13 +433,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. - const ErrorComponent = (): ReactNode => { - throw new ElementCallRichError(enterRTCError); - }; - body = ; + if (callErrorState) { + // There is an error, nothing to render + body = <>; } else if (isJoined) { body = ( <> @@ -458,6 +446,7 @@ export const GroupCallView: FC = ({ rtcSession={rtcSession as MatrixRTCSession} participantCount={participantCount} onLeave={onLeave} + onDisconnected={onDisconnected} hideHeader={hideHeader} muteStates={muteStates} e2eeSystem={e2eeSystem} @@ -504,5 +493,11 @@ export const GroupCallView: FC = ({ body = lobbyView; } - return {body}; + const RenderErrorFallback = (error: Error | unknown): ReactElement => { + useEffect(() => { + if (rtcSession.isJoined()) onLeave("error"); + }, []); + return ; + }; + return {body}; }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 17ca63bb..7d4c1bf3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -97,7 +97,6 @@ import { useSetting, } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; -import { ConnectionLostError } from "../utils/errors.ts"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -176,6 +175,8 @@ export interface InCallViewProps { participantCount: number; /** Function to call when the user explicitly ends the call */ onLeave: () => void; + /** Callback for when the call is terminated due to connectivity issue */ + onDisconnected: (() => void) | null; hideHeader: boolean; otelGroupCallMembership?: OTelGroupCallMembership; connState: ECConnectionState; @@ -191,6 +192,7 @@ export const InCallView: FC = ({ muteStates, participantCount, onLeave, + onDisconnected, hideHeader, connState, onShareClick, @@ -198,12 +200,13 @@ export const InCallView: FC = ({ const { supportsReactions, sendReaction, toggleRaisedHand } = useReactionsSender(); - useWakeLock(); - // annoyingly we don't get the disconnection reason this way, // only by listening for the emitted event - if (connState === ConnectionState.Disconnected) - throw new ConnectionLostError(); + if (connState === ConnectionState.Disconnected) { + onDisconnected?.(); + } + + useWakeLock(); const containerRef1 = useRef(null); const [containerRef2, bounds] = useMeasure(); diff --git a/src/room/__snapshots__/GroupCallView.test.tsx.snap b/src/room/__snapshots__/GroupCallView.test.tsx.snap new file mode 100644 index 00000000..e452934e --- /dev/null +++ b/src/room/__snapshots__/GroupCallView.test.tsx.snap @@ -0,0 +1,305 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GroupCallView call error handling > Connection Failed error 1`] = ` + + // router needed because GroupCallView uses useLocation() +
+
+ +
+
+
+
+
+ + + +
+

+ Connection lost +

+

+ You were disconnected from the call. +

+ + +
+
+
+
+
+`; + +exports[`GroupCallView call error handling > Missing RTC Focus error handling 1`] = ` + + // router needed because GroupCallView uses useLocation() +
+
+ +
+
+
+
+
+ + + + +
+

+ Call is not supported +

+

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

+ +
+
+
+
+
+`; diff --git a/src/utils/CallErrorDisplay.test.tsx b/src/utils/CallErrorDisplay.test.tsx new file mode 100644 index 00000000..ef9aa276 --- /dev/null +++ b/src/utils/CallErrorDisplay.test.tsx @@ -0,0 +1,183 @@ +/* +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 { expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode, useEffect } from "react"; +import { BrowserRouter } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import { captureException } from "@sentry/react"; + +import { CallErrorDisplay } from "./CallErrorDisplay.tsx"; +import { + CallErrorStateProvider, + useCallErrorDisplay, +} from "./useCallErrorDisplay.tsx"; +import { + ConnectionLostError, + E2EENotSupportedError, + ElementCallError, + ErrorCategory, + ErrorCode, + MatrixRTCFocusMissingError, +} from "./errors.ts"; + +test("should expect to be wrapped in a CallErrorStateProvider", () => { + expect(() => render()).toThrow( + "useCallErrorDisplay must be used within an CallErrorStateProvider", + ); + + render( + + + , + ); +}); + +test.each([ + { + error: new MatrixRTCFocusMissingError("example.com"), + expectedTitle: "Call is not supported", + }, + { error: new ConnectionLostError(), expectedTitle: "Connection lost" }, + { error: new E2EENotSupportedError(), expectedTitle: "Incompatible browser" }, +])( + "should report correct error for $expectedTitle", + async ({ error, expectedTitle }) => { + const TestComponent = (): ReactNode => { + const { setCallErrorState } = useCallErrorDisplay(); + useEffect(() => { + setCallErrorState({ + cause: error, + }); + }, [setCallErrorState]); + return
Hello
; + }; + + const { asFragment } = render( + + + + + + , + ); + + await screen.findByText(expectedTitle); + + expect(asFragment()).toMatchSnapshot(); + }, +); + +test("should render the error page", async () => { + const TestComponent = (): ReactNode => { + const { setCallErrorState } = useCallErrorDisplay(); + useEffect(() => { + setCallErrorState({ + cause: new MatrixRTCFocusMissingError("example.com"), + }); + }, [setCallErrorState]); + return
Hello
; + }; + + 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", "Return to home screen"); + + expect(asFragment()).toMatchSnapshot(); +}); + +test("should render the actions", async () => { + const user = userEvent.setup(); + + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const TestComponent = (): ReactNode => { + const { setCallErrorState } = useCallErrorDisplay(); + useEffect(() => { + setCallErrorState({ + cause: new MatrixRTCFocusMissingError("example.com"), + actions: [ + { labelKey: "action_key1", onClick: callback1 }, + { labelKey: "action_key2", onClick: callback2 }, + ], + }); + }, [setCallErrorState]); + return
Hello
; + }; + + const { asFragment } = render( + + + + + + , + ); + + await screen.findByText("Call is not supported"); + await screen.findByRole("button", { name: "action_key1" }); + await screen.findByRole("button", { name: "action_key2" }); + await screen.findByRole("button", { name: "Return to home screen" }); + + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "action_key2" })); + + expect(callback2).toHaveBeenCalledOnce(); + expect(callback1).not.toHaveBeenCalled(); + + // should have reset the error + expect(screen.queryByText("Call is not supported")).not.toBeInTheDocument(); +}); + +test("should report to sentry on error", async () => { + vi.mock("@sentry/react", { spy: true }); + + const error = new ElementCallError( + "FOO", + ErrorCode.UNKNOWN_ERROR, + ErrorCategory.UNKNOWN, + ); + // const sentrySpy = vi.spyOn(Sentry, "captureException"); + const TestComponent = (): ReactNode => { + const { setCallErrorState } = useCallErrorDisplay(); + useEffect(() => { + setCallErrorState({ + cause: error, + }); + }, [setCallErrorState]); + return
Hello
; + }; + + render( + + + + + + , + ); + + await screen.findByText("Something went wrong"); + + expect(captureException).toHaveBeenCalledWith(error); +}); diff --git a/src/utils/CallErrorDisplay.tsx b/src/utils/CallErrorDisplay.tsx new file mode 100644 index 00000000..bac11cf6 --- /dev/null +++ b/src/utils/CallErrorDisplay.tsx @@ -0,0 +1,144 @@ +/* +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 { + type ComponentType, + type FC, + type ReactElement, + type ReactNode, + type SVGAttributes, + useEffect, +} from "react"; +import { Trans, useTranslation } from "react-i18next"; +import * as Sentry from "@sentry/react"; +import { + ErrorIcon, + HostIcon, + OfflineIcon, + WebBrowserIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { + type ErrorAction, + useCallErrorDisplay, +} from "./useCallErrorDisplay.tsx"; +import { ErrorView } from "../ErrorView.tsx"; +import { FullScreenView } from "../FullScreenView.tsx"; +import { type ElementCallError, ErrorCategory, ErrorCode } from "./errors.ts"; + +interface ErrorPageProps { + error: ElementCallError; + actions?: ErrorAction[]; +} + +const ErrorPage: FC = ({ + error, + actions, +}: ErrorPageProps): ReactElement => { + const { t } = useTranslation(); + const { setCallErrorState } = useCallErrorDisplay(); + + useEffect(() => { + if (error) { + logger.error(error); + Sentry.captureException(error); + } + }, [error]); + + let title: string; + let icon: ComponentType>; + switch (error.category) { + case ErrorCategory.CONFIGURATION_ISSUE: + title = t("error.call_is_not_supported"); + icon = HostIcon; + break; + case ErrorCategory.NETWORK_CONNECTIVITY: + title = t("error.connection_lost"); + icon = OfflineIcon; + break; + case ErrorCategory.CLIENT_CONFIGURATION: + title = t("error.e2ee_unsupported"); // not the best name for that, but currently the only error in this category + icon = WebBrowserIcon; + break; + default: + title = t("error.generic"); + icon = ErrorIcon; + } + + const actionCallbacks = actions?.map((action) => { + return (): void => { + setCallErrorState(null); + action.onClick(); + }; + }); + return ( + + +

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

+ {actions && + actions.map((action, index) => ( + + ))} +
+
+ ); +}; + +/** + * Display the call error if there is one. + * + * Should be surrounded by a `CallErrorStateProvider`. + * + * Any component in the hierarchy can access the call error context using `useCallErrorDisplay`. + * If the error is set then the `ErrorPage` will be displayed. + * A component can set up custom buttons/actions that will be displayed on the error page. + * ``` + * const { setCallErrorState } = useCallErrorDisplay(); + * // ... + * setCallErrorState({ + * reason: new ConnectionLostError(), + * actions: [ + * { + * labelKey: "call_ended_view.reconnect_button", + * onClick: reconnect, + * }, + * ] + * } + * // ... + * + * ``` + * @constructor + */ +export const CallErrorDisplay: () => ReactNode = () => { + const { callErrorState } = useCallErrorDisplay(); + + if (callErrorState) { + return ( + + ); + } + + return <>; +}; diff --git a/src/utils/__snapshots__/CallErrorDisplay.test.tsx.snap b/src/utils/__snapshots__/CallErrorDisplay.test.tsx.snap new file mode 100644 index 00000000..0ca43257 --- /dev/null +++ b/src/utils/__snapshots__/CallErrorDisplay.test.tsx.snap @@ -0,0 +1,772 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should render the actions 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). +

+ + + +
+
+
+
+
+ Hello +
+
+`; + +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). +

+ +
+
+
+
+
+ Hello +
+
+`; + +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). +

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

+ Connection lost +

+

+ You were disconnected from the call. +

+ +
+
+
+
+
+ Hello +
+
+`; + +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+. +

+ +
+
+
+
+
+ Hello +
+
+`; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index c87bdee7..6fec01a6 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,6 +13,7 @@ export enum ErrorCode { */ MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS", CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", + E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", UNKNOWN_ERROR = "UNKNOWN_ERROR", } @@ -20,6 +21,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 .. } @@ -66,9 +68,21 @@ export class MatrixRTCFocusMissingError extends ElementCallError { export class ConnectionLostError extends ElementCallError { public constructor() { super( - "Connection lost", + "ConnectionLostError", ErrorCode.CONNECTION_LOST_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, + t("error.connection_lost_description"), + ); + } +} + +export class E2EENotSupportedError extends ElementCallError { + public constructor() { + super( + "E2EENotSupportedError", + ErrorCode.E2EE_NOT_SUPPORTED, + ErrorCategory.CLIENT_CONFIGURATION, + t("error.e2ee_unsupported_description"), ); } } diff --git a/src/utils/test.ts b/src/utils/test.ts index b6f0ecc3..ebcc302b 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -274,7 +274,18 @@ export class MockRTCSession extends TypedEventEmitter< counters: {}, }; - public leaveRoomSession = vitest.fn().mockResolvedValue(undefined); + private _isJoined: boolean = false; + + public leaveRoomSession = vitest.fn().mockImplementation(() => { + this._isJoined = false; + }); + + public joinRoomSession = vitest.fn().mockImplementation(() => { + if (this.isJoined()) { + return; + } + this._isJoined = true; + }); public constructor( public readonly room: Room, @@ -284,8 +295,8 @@ export class MockRTCSession extends TypedEventEmitter< super(); } - public isJoined(): true { - return true; + public isJoined(): boolean { + return this._isJoined; } public withMemberships( @@ -301,4 +312,9 @@ export class MockRTCSession extends TypedEventEmitter< return this; } + + public withJoined(): MockRTCSession { + this._isJoined = true; + return this; + } } diff --git a/src/utils/useCallErrorDisplay.tsx b/src/utils/useCallErrorDisplay.tsx new file mode 100644 index 00000000..e01283af --- /dev/null +++ b/src/utils/useCallErrorDisplay.tsx @@ -0,0 +1,53 @@ +/* +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 { createContext, type ReactNode, useContext, useState } from "react"; + +import { type ElementCallError } from "./errors.ts"; + +export type ErrorAction = { + labelKey: string; + onClick: () => void; +}; + +export type ErrorState = { + cause: ElementCallError; + actions?: ErrorAction[]; // Optional list of actions (buttons) +}; + +type GlobalErrorContextType = { + callErrorState: ErrorState | null; + setCallErrorState: (error: ErrorState | null) => void; + // subscribe: (callback: (isErrorActive: boolean) => void) => () => void; +}; + +const ErrorContext = createContext( + undefined, +); + +export const CallErrorStateProvider = ({ + children, +}: { + children: ReactNode; +}): ReactNode => { + const [callErrorState, setCallErrorState] = useState(null); + + return ( + + {children} + + ); +}; + +export const useCallErrorDisplay: () => GlobalErrorContextType = () => { + const context = useContext(ErrorContext); + if (!context) + throw new Error( + "useCallErrorDisplay must be used within an CallErrorStateProvider", + ); + return context; +};