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;
+}