diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index 893389a7..f99f01fa 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -8,10 +8,10 @@ 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, + useEffect, useState, } from "react"; import { BrowserRouter } from "react-router-dom"; @@ -27,6 +27,8 @@ import { UnknownCallError, } from "../utils/errors.ts"; import { mockConfig } from "../utils/test.ts"; +import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; +import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx"; test.each([ { @@ -210,3 +212,30 @@ describe("Rageshake button", () => { ).not.toBeInTheDocument(); }); }); + +test("should show async error with useElementCallErrorContext", async () => { + // const error = new MatrixRTCFocusMissingError("example.com"); + const TestComponent = (): ReactNode => { + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); + useEffect(() => { + setTimeout(() => { + showGroupCallErrorBoundary(new ConnectionLostError()); + }); + }, [showGroupCallErrorBoundary]); + + return
Hello
; + }; + + const onErrorMock = vi.fn(); + render( + + + + + + + , + ); + + await screen.findByText("Connection lost"); +}); diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 67b95733..a85bee9d 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -105,6 +105,30 @@ interface BoundaryProps { onError?: (error: unknown) => void; } +/** + * An ErrorBoundary component that handles ElementCalls errors that can occur during a group call. + * It is based on the sentry ErrorBoundary component, that will log the error to sentry. + * + * The error fallback will show an error page with: + * - a description of the error + * - a button to go back the home screen + * - optional call-to-action buttons (ex: reconnect for connection lost) + * - A rageshake button for unknown errors + * + * For async errors the `useCallErrorBoundary` hook should be used to show the error page + * ``` + * const { showGroupCallErrorBoundary } = useCallErrorBoundary(); + * ... some async code + * catch(error) { + * showGroupCallErrorBoundary(error); + * } + * ... + * ``` + * @param recoveryActionHandler + * @param onError + * @param children + * @constructor + */ export const GroupCallErrorBoundary = ({ recoveryActionHandler, onError, diff --git a/src/room/GroupCallErrorBoundaryContext.tsx b/src/room/GroupCallErrorBoundaryContext.tsx new file mode 100644 index 00000000..f1dcf461 --- /dev/null +++ b/src/room/GroupCallErrorBoundaryContext.tsx @@ -0,0 +1,18 @@ +/* +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 } from "react"; + +import { type ElementCallError } from "../utils/errors.ts"; + +export type GroupCallErrorBoundaryContextType = { + subscribe: (cb: (error: ElementCallError) => void) => () => void; + notifyHandled: (error: ElementCallError) => void; +}; + +export const GroupCallErrorBoundaryContext = + createContext(null); diff --git a/src/room/GroupCallErrorBoundaryContextProvider.test.tsx b/src/room/GroupCallErrorBoundaryContextProvider.test.tsx new file mode 100644 index 00000000..128e6ae8 --- /dev/null +++ b/src/room/GroupCallErrorBoundaryContextProvider.test.tsx @@ -0,0 +1,54 @@ +/* +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 { it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type ReactElement, useCallback } from "react"; +import userEvent from "@testing-library/user-event"; +import { BrowserRouter } from "react-router-dom"; + +import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx"; +import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; +import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; +import { ConnectionLostError } from "../utils/errors.ts"; + +it("should show async error", async () => { + const user = userEvent.setup(); + + const TestComponent = (): ReactElement => { + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); + + const onClick = useCallback((): void => { + showGroupCallErrorBoundary(new ConnectionLostError()); + }, [showGroupCallErrorBoundary]); + + return ( +
+

HELLO

+ +
+ ); + }; + + render( + + + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Click me" })); + + await screen.findByText("Connection lost"); + + await user.click(screen.getByRole("button", { name: "Reconnect" })); + + await screen.findByText("HELLO"); +}); diff --git a/src/room/GroupCallErrorBoundaryContextProvider.tsx b/src/room/GroupCallErrorBoundaryContextProvider.tsx new file mode 100644 index 00000000..b7292624 --- /dev/null +++ b/src/room/GroupCallErrorBoundaryContextProvider.tsx @@ -0,0 +1,54 @@ +/* +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 FC, + type PropsWithChildren, + useCallback, + useMemo, + useRef, +} from "react"; + +import type { ElementCallError } from "../utils/errors.ts"; +import { + GroupCallErrorBoundaryContext, + type GroupCallErrorBoundaryContextType, +} from "./GroupCallErrorBoundaryContext.tsx"; + +export const GroupCallErrorBoundaryContextProvider: FC = ({ + children, +}) => { + const subscribers = useRef void>>(new Set()); + + // Register a component for updates + const subscribe = useCallback( + (cb: (error: ElementCallError) => void): (() => void) => { + subscribers.current.add(cb); + return (): boolean => subscribers.current.delete(cb); // Unsubscribe function + }, + [], + ); + + // Notify all subscribers + const notify = useCallback((error: ElementCallError) => { + subscribers.current.forEach((callback) => callback(error)); + }, []); + + const context: GroupCallErrorBoundaryContextType = useMemo( + () => ({ + notifyHandled: notify, + subscribe, + }), + [subscribe, notify], + ); + + return ( + + {children} + + ); +}; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 226fb9f5..3adffba5 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -57,6 +57,8 @@ import { UnknownCallError, } from "../utils/errors.ts"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; +import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx"; +import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; declare global { interface Window { @@ -77,7 +79,15 @@ interface Props { widget: WidgetHelpers | null; } -export const GroupCallView: FC = ({ +export const GroupCallView: FC = (props) => { + return ( + + + + ); +}; + +export const GroupCallViewInner: FC = ({ client, isPasswordlessUser, confineToRoom, @@ -156,25 +166,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 UnknownCallError( - e instanceof Error ? e : new Error("Unknown error", { cause: e }), - ); - setEnterRTCError(error); + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); + + const enterRTCSessionOrError = useCallback( + async ( + rtcSession: MatrixRTCSession, + perParticipantE2EE: boolean, + ): Promise => { + try { + await enterRTCSession(rtcSession, perParticipantE2EE); + } catch (e) { + if (e instanceof ElementCallError) { + showGroupCallErrorBoundary(e); + } else { + logger.error(`Unknown Error while entering RTC session`, e); + const error = new UnknownCallError( + e instanceof Error ? e : new Error("Unknown error", { cause: e }), + ); + showGroupCallErrorBoundary(error); + } } - } - }; + }, + [showGroupCallErrorBoundary], + ); useEffect(() => { const defaultDeviceSetup = async ({ @@ -255,12 +269,11 @@ export const GroupCallView: FC = ({ perParticipantE2EE, latestDevices, latestMuteStates, + enterRTCSessionOrError, ]); const [left, setLeft] = useState(false); - const [enterRTCError, setEnterRTCError] = useState( - null, - ); + const navigate = useNavigate(); const onLeave = useCallback( @@ -378,14 +391,7 @@ 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 - // the error. This will then be handled by the ErrorBoundary component. - const ErrorComponent = (): ReactNode => { - throw enterRTCError; - }; - body = ; - } else if (isJoined) { + if (isJoined) { body = ( <> {shareModal} diff --git a/src/room/useCallErrorBoundary.ts b/src/room/useCallErrorBoundary.ts new file mode 100644 index 00000000..b8b0a034 --- /dev/null +++ b/src/room/useCallErrorBoundary.ts @@ -0,0 +1,58 @@ +/* +Copyright 2023, 2024 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 { useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import type { ElementCallError } from "../utils/errors.ts"; +import { GroupCallErrorBoundaryContext } from "./GroupCallErrorBoundaryContext.tsx"; + +export type UseErrorBoundaryApi = { + showGroupCallErrorBoundary: (error: ElementCallError) => void; +}; + +export function useGroupCallErrorBoundary(): UseErrorBoundaryApi { + const context = useContext(GroupCallErrorBoundaryContext); + + if (!context) + throw new Error( + "useGroupCallErrorBoundary must be used within an GoupCallErrorBoundary", + ); + + const [error, setError] = useState(null); + + const resetErrorIfNeeded = useCallback( + (handled: ElementCallError): void => { + // There might be several useGroupCallErrorBoundary in the tree, + // so only clear our state if it's the one we're handling? + if (error && handled === error) { + // reset current state + setError(null); + } + }, + [error], + ); + + useEffect(() => { + // return a function to unsubscribe + return context.subscribe((error: ElementCallError): void => { + resetErrorIfNeeded(error); + }); + }, [resetErrorIfNeeded, context]); + + const memoized: UseErrorBoundaryApi = useMemo( + () => ({ + showGroupCallErrorBoundary: (error: ElementCallError) => setError(error), + }), + [], + ); + + if (error) { + throw error; + } + + return memoized; +}