diff --git a/locales/en/app.json b/locales/en/app.json
index 7da0f593..5b7ce69d 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -85,7 +85,8 @@
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "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.",
"open_elsewhere": "Opened in another tab",
- "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
+ "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
+ "unexpected_ec_error": "An unexpected error occurred (<0>Error Code:0> <1>{{ errorCode }}1>). Please contact your server administrator."
},
"group_call_loader": {
"banned_body": "You have been banned from the room.",
diff --git a/src/RichError.tsx b/src/RichError.tsx
index d16ef640..2565bfe2 100644
--- a/src/RichError.tsx
+++ b/src/RichError.tsx
@@ -6,13 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode } from "react";
-import { useTranslation } from "react-i18next";
+import { Trans, useTranslation } from "react-i18next";
import {
HostIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorView } from "./ErrorView";
+import { type ElementCallError, type ErrorCode } from "./utils/ec-errors.ts";
/**
* An error consisting of a terse message to be logged to the console and a
@@ -65,3 +66,33 @@ export class InsufficientCapacityError extends RichError {
super("Insufficient server capacity", );
}
}
+
+type ECErrorProps = {
+ errorCode: ErrorCode;
+};
+
+const GenericECError: FC<{ errorCode: ErrorCode }> = ({
+ errorCode,
+}: ECErrorProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ , ]}
+ values={{ errorCode }}
+ />
+
+
+ );
+};
+
+export class ElementCallRichError extends RichError {
+ public ecError: ElementCallError;
+ public constructor(ecError: ElementCallError) {
+ super(ecError.message, );
+ this.ecError = ecError;
+ }
+}
diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx
index be1ed14c..31ab776c 100644
--- a/src/room/GroupCallView.tsx
+++ b/src/room/GroupCallView.tsx
@@ -16,8 +16,8 @@ import {
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import {
- Room,
isE2EESupported as isE2EESupportedBrowser,
+ Room,
} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
@@ -62,6 +62,7 @@ import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import { ErrorView } from "../ErrorView";
import { ConnectionLostError, ElementCallError } from "../utils/ec-errors.ts";
+import { ElementCallRichError } from "../RichError.tsx";
declare global {
interface Window {
@@ -166,6 +167,22 @@ 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);
+ }
+ }
+ };
+
useEffect(() => {
const defaultDeviceSetup = async ({
audioInput,
@@ -215,7 +232,7 @@ export const GroupCallView: FC = ({
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
- await enterRTCSession(rtcSession, perParticipantE2EE);
+ await enterRTCSessionOrError(rtcSession, perParticipantE2EE);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
@@ -228,13 +245,13 @@ export const GroupCallView: FC = ({
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise => {
- await enterRTCSession(rtcSession, perParticipantE2EE);
+ await enterRTCSessionOrError(rtcSession, perParticipantE2EE);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
}
} else {
- void enterRTCSession(rtcSession, perParticipantE2EE);
+ void enterRTCSessionOrError(rtcSession, perParticipantE2EE);
}
}
}, [
@@ -248,6 +265,9 @@ export const GroupCallView: FC = ({
]);
const [left, setLeft] = useState(false);
+ const [enterRTCError, setEnterRTCError] = useState(
+ null,
+ );
const navigate = useNavigate();
const onLeave = useCallback(
@@ -348,8 +368,8 @@ export const GroupCallView: FC = ({
const onReconnect = useCallback(() => {
setLeft(false);
resetError();
- enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
- logger.error("Error re-entering RTC session on reconnect", e);
+ enterRTCSessionOrError(rtcSession, perParticipantE2EE).catch((e) => {
+ logger.error("Error re-entering RTC session", e);
});
}, [resetError]);
@@ -398,7 +418,9 @@ export const GroupCallView: FC = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
- onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
+ onEnter={() =>
+ void enterRTCSessionOrError(rtcSession, perParticipantE2EE)
+ }
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
@@ -408,7 +430,14 @@ export const GroupCallView: FC = ({
);
let body: ReactNode;
- if (isJoined) {
+ 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 = ;
+ } else if (isJoined) {
body = (
<>
{shareModal}
diff --git a/src/rtcSessionHelpers.test.ts b/src/rtcSessionHelpers.test.ts
index 57f73f8e..5ef9d6a0 100644
--- a/src/rtcSessionHelpers.test.ts
+++ b/src/rtcSessionHelpers.test.ts
@@ -13,6 +13,7 @@ import EventEmitter from "events";
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";
import { mockConfig } from "./utils/test";
import { ElementWidgetActions, widget } from "./widget";
+import { ErrorCode } from "./utils/ec-errors.ts";
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
vi.mock("./widget", () => ({
@@ -137,3 +138,50 @@ test("leaveRTCSession doesn't close the widget on a fatal error", async () => {
expect.anything(),
);
});
+
+test("It fails with configuration error if no live kit url config is set in fallback", async () => {
+ mockConfig({});
+ vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
+
+ const mockedSession = vi.mocked({
+ room: {
+ roomId: "roomId",
+ client: {
+ getDomain: vi.fn().mockReturnValue("example.org"),
+ },
+ },
+ memberships: [],
+ getFocusInUse: vi.fn(),
+ joinRoomSession: vi.fn(),
+ }) as unknown as MatrixRTCSession;
+
+ await expect(enterRTCSession(mockedSession, false)).rejects.toThrowError(
+ expect.objectContaining({ code: ErrorCode.MISSING_LIVE_KIT_SERVICE_URL }),
+ );
+});
+
+test("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => {
+ mockConfig({});
+ vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
+ "org.matrix.msc4143.rtc_foci": [
+ {
+ type: "livekit",
+ livekit_service_url: "http://my-well-known-service-url.com",
+ },
+ ],
+ });
+
+ const mockedSession = vi.mocked({
+ room: {
+ roomId: "roomId",
+ client: {
+ getDomain: vi.fn().mockReturnValue("example.org"),
+ },
+ },
+ memberships: [],
+ getFocusInUse: vi.fn(),
+ joinRoomSession: vi.fn(),
+ }) as unknown as MatrixRTCSession;
+
+ await enterRTCSession(mockedSession, false);
+});
diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts
index 52498516..03ab24d8 100644
--- a/src/rtcSessionHelpers.ts
+++ b/src/rtcSessionHelpers.ts
@@ -8,16 +8,17 @@ Please see LICENSE in the repository root for full details.
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import {
- type LivekitFocus,
- type LivekitFocusActive,
isLivekitFocus,
isLivekitFocusConfig,
+ type LivekitFocus,
+ type LivekitFocusActive,
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config";
-import { ElementWidgetActions, type WidgetHelpers, widget } from "./widget";
+import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
+import { ElementCallError, ErrorCode } from "./utils/ec-errors.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
@@ -80,9 +81,10 @@ async function makePreferredLivekitFoci(
}
if (preferredFoci.length === 0)
- throw new Error(
+ throw new ElementCallError(
`No livekit_service_url is configured so we could not create a focus.
Currently we skip computing a focus based on other users in the room.`,
+ ErrorCode.MISSING_LIVE_KIT_SERVICE_URL,
);
return Promise.resolve(preferredFoci);