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: <1>{{ errorCode }}). 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);