Skip to content

Commit 4c27551

Browse files
committed
refactor: Centralize group call errors in custom GroupCallErrorBoundary
1 parent 1a692b9 commit 4c27551

File tree

8 files changed

+1454
-156
lines changed

8 files changed

+1454
-156
lines changed

src/RichError.tsx

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { Trans, useTranslation } from "react-i18next";
9-
import {
10-
ErrorIcon,
11-
HostIcon,
12-
PopOutIcon,
13-
} from "@vector-im/compound-design-tokens/assets/web/icons";
8+
import { useTranslation } from "react-i18next";
9+
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
1410

15-
import type { ComponentType, FC, ReactNode, SVGAttributes } from "react";
11+
import type { FC, ReactNode } from "react";
1612
import { ErrorView } from "./ErrorView";
17-
import { type ElementCallError, ErrorCategory } from "./utils/errors.ts";
1813

1914
/**
2015
* An error consisting of a terse message to be logged to the console and a
@@ -51,62 +46,3 @@ export class OpenElsewhereError extends RichError {
5146
super("App opened in another tab", <OpenElsewhere />);
5247
}
5348
}
54-
55-
const InsufficientCapacity: FC = () => {
56-
const { t } = useTranslation();
57-
58-
return (
59-
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
60-
<p>{t("error.insufficient_capacity_description")}</p>
61-
</ErrorView>
62-
);
63-
};
64-
65-
export class InsufficientCapacityError extends RichError {
66-
public constructor() {
67-
super("Insufficient server capacity", <InsufficientCapacity />);
68-
}
69-
}
70-
71-
type ECErrorProps = {
72-
error: ElementCallError;
73-
};
74-
75-
const GenericECError: FC<{ error: ElementCallError }> = ({
76-
error,
77-
}: ECErrorProps) => {
78-
const { t } = useTranslation();
79-
80-
let title: string;
81-
let icon: ComponentType<SVGAttributes<SVGElement>>;
82-
switch (error.category) {
83-
case ErrorCategory.CONFIGURATION_ISSUE:
84-
title = t("error.call_is_not_supported");
85-
icon = HostIcon;
86-
break;
87-
default:
88-
title = t("error.generic");
89-
icon = ErrorIcon;
90-
}
91-
return (
92-
<ErrorView Icon={icon} title={title}>
93-
<p>
94-
{error.localisedMessage ?? (
95-
<Trans
96-
i18nKey="error.unexpected_ec_error"
97-
components={[<b />, <code />]}
98-
values={{ errorCode: error.code }}
99-
/>
100-
)}
101-
</p>
102-
</ErrorView>
103-
);
104-
};
105-
106-
export class ElementCallRichError extends RichError {
107-
public ecError: ElementCallError;
108-
public constructor(ecError: ElementCallError) {
109-
super(ecError.message, <GenericECError error={ecError} />);
110-
this.ecError = ecError;
111-
}
112-
}

src/livekit/useECConnectionState.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ import {
1414
} from "livekit-client";
1515
import userEvent from "@testing-library/user-event";
1616
import { render, screen } from "@testing-library/react";
17-
import { ErrorBoundary } from "@sentry/react";
1817
import { MemoryRouter } from "react-router-dom";
1918

20-
import { ErrorPage } from "../FullScreenView";
2119
import { useECConnectionState } from "./useECConnectionState";
2220
import { type SFUConfig } from "./openIDSFU";
21+
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
2322

2423
test.each<[string, ConnectionError]>([
2524
[
@@ -61,9 +60,9 @@ test.each<[string, ConnectionError]>([
6160
const user = userEvent.setup();
6261
render(
6362
<MemoryRouter>
64-
<ErrorBoundary fallback={ErrorPage}>
63+
<GroupCallErrorBoundary>
6564
<TestComponent />
66-
</ErrorBoundary>
65+
</GroupCallErrorBoundary>
6766
</MemoryRouter>,
6867
);
6968
await user.click(screen.getByRole("button", { name: "Connect" }));

src/livekit/useECConnectionState.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import * as Sentry from "@sentry/react";
2020

2121
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
2222
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
23-
import { InsufficientCapacityError, RichError } from "../RichError";
23+
import {
24+
ElementCallError,
25+
InsufficientCapacityError,
26+
UnknownCallError,
27+
} from "../utils/errors.ts";
2428

2529
declare global {
2630
interface Window {
@@ -188,7 +192,7 @@ export function useECConnectionState(
188192

189193
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
190194
const [isInDoConnect, setIsInDoConnect] = useState(false);
191-
const [error, setError] = useState<RichError | null>(null);
195+
const [error, setError] = useState<ElementCallError | null>(null);
192196
if (error !== null) throw error;
193197

194198
const onConnStateChanged = useCallback((state: ConnectionState) => {
@@ -271,9 +275,11 @@ export function useECConnectionState(
271275
initialAudioOptions,
272276
)
273277
.catch((e) => {
274-
if (e instanceof RichError)
278+
if (e instanceof ElementCallError) {
275279
setError(e); // Bubble up any error screens to React
276-
else logger.error("Failed to connect to SFU", e);
280+
} else if (e instanceof Error) {
281+
setError(new UnknownCallError(e));
282+
} else logger.error("Failed to connect to SFU", e);
277283
})
278284
.finally(() => setIsInDoConnect(false));
279285
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { describe, expect, test, vi } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import { type ReactElement, type ReactNode } from "react";
11+
import { BrowserRouter } from "react-router-dom";
12+
import userEvent from "@testing-library/user-event";
13+
14+
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
15+
import {
16+
ConnectionLostError,
17+
E2EENotSupportedError,
18+
type ElementCallError,
19+
InsufficientCapacityError,
20+
MatrixRTCFocusMissingError,
21+
UnknownCallError,
22+
} from "../utils/errors.ts";
23+
import { mockConfig } from "../utils/test.ts";
24+
25+
test.each([
26+
{
27+
error: new MatrixRTCFocusMissingError("example.com"),
28+
expectedTitle: "Call is not supported",
29+
},
30+
{
31+
error: new ConnectionLostError(),
32+
expectedTitle: "Connection lost",
33+
expectedDescription: "You were disconnected from the call.",
34+
},
35+
{
36+
error: new E2EENotSupportedError(),
37+
expectedTitle: "Incompatible browser",
38+
expectedDescription:
39+
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
40+
},
41+
{
42+
error: new InsufficientCapacityError(),
43+
expectedTitle: "Insufficient capacity",
44+
expectedDescription:
45+
"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.",
46+
},
47+
])(
48+
"should report correct error for $expectedTitle",
49+
async ({ error, expectedTitle, expectedDescription }) => {
50+
const TestComponent = (): ReactNode => {
51+
throw error;
52+
};
53+
54+
const onErrorMock = vi.fn();
55+
const { asFragment } = render(
56+
<BrowserRouter>
57+
<GroupCallErrorBoundary onError={onErrorMock}>
58+
<TestComponent />
59+
</GroupCallErrorBoundary>
60+
</BrowserRouter>,
61+
);
62+
63+
await screen.findByText(expectedTitle);
64+
if (expectedDescription) {
65+
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
66+
}
67+
expect(onErrorMock).toHaveBeenCalledWith(error);
68+
69+
expect(asFragment()).toMatchSnapshot();
70+
},
71+
);
72+
73+
test("should render the error page with link back to home", async () => {
74+
const error = new MatrixRTCFocusMissingError("example.com");
75+
const TestComponent = (): ReactNode => {
76+
throw error;
77+
};
78+
79+
const onErrorMock = vi.fn();
80+
const { asFragment } = render(
81+
<BrowserRouter>
82+
<GroupCallErrorBoundary onError={onErrorMock}>
83+
<TestComponent />
84+
</GroupCallErrorBoundary>
85+
</BrowserRouter>,
86+
);
87+
88+
await screen.findByText("Call is not supported");
89+
expect(screen.getByText(/Domain: example.com/i)).toBeInTheDocument();
90+
expect(
91+
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
92+
).toBeInTheDocument();
93+
94+
await screen.findByRole("button", { name: "Return to home screen" });
95+
96+
expect(onErrorMock).toHaveBeenCalledOnce();
97+
expect(onErrorMock).toHaveBeenCalledWith(error);
98+
99+
expect(asFragment()).toMatchSnapshot();
100+
});
101+
102+
test("should have a reconnect button for ConnectionLostError", async () => {
103+
const user = userEvent.setup();
104+
105+
const reconnectCallback = vi.fn();
106+
107+
const TestComponent = (): ReactNode => {
108+
throw new ConnectionLostError();
109+
};
110+
111+
const { asFragment } = render(
112+
<BrowserRouter>
113+
<GroupCallErrorBoundary
114+
onError={vi.fn()}
115+
recoveryActionHandler={reconnectCallback}
116+
>
117+
<TestComponent />
118+
</GroupCallErrorBoundary>
119+
</BrowserRouter>,
120+
);
121+
122+
await screen.findByText("Connection lost");
123+
await screen.findByRole("button", { name: "Reconnect" });
124+
await screen.findByRole("button", { name: "Return to home screen" });
125+
126+
expect(asFragment()).toMatchSnapshot();
127+
128+
await user.click(screen.getByRole("button", { name: "Reconnect" }));
129+
130+
expect(reconnectCallback).toHaveBeenCalledOnce();
131+
expect(reconnectCallback).toHaveBeenCalledWith("reconnect");
132+
});
133+
134+
describe("Rageshake button", () => {
135+
function setupTest(testError: ElementCallError): void {
136+
mockConfig({
137+
rageshake: {
138+
submit_url: "https://rageshake.example.com.localhost",
139+
},
140+
});
141+
142+
const TestComponent = (): ReactElement => {
143+
throw testError;
144+
};
145+
146+
render(
147+
<BrowserRouter>
148+
<GroupCallErrorBoundary onError={vi.fn()}>
149+
<TestComponent />
150+
</GroupCallErrorBoundary>
151+
</BrowserRouter>,
152+
);
153+
}
154+
155+
test("should show send rageshake button for unknown errors", () => {
156+
setupTest(new UnknownCallError(new Error("FOO")));
157+
158+
expect(
159+
screen.queryByRole("button", { name: "Send debug logs" }),
160+
).toBeInTheDocument();
161+
});
162+
163+
test("should not show send rageshake button for call errors", () => {
164+
setupTest(new E2EENotSupportedError());
165+
166+
expect(
167+
screen.queryByRole("button", { name: "Send debug logs" }),
168+
).not.toBeInTheDocument();
169+
});
170+
});

0 commit comments

Comments
 (0)