diff --git a/locales/en/app.json b/locales/en/app.json index 7da0f593..30c134bf 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -74,6 +74,7 @@ }, "disconnected_banner": "Connectivity to the server has been lost.", "error": { + "call_is_not_supported": "Call is not supported", "call_not_found": "Call not found", "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one.", "connection_lost": "Connection lost", @@ -84,8 +85,10 @@ "generic_description": "Submitting debug logs will help us track down the problem.", "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.", + "matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "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 admin." }, "group_call_loader": { "banned_body": "You have been banned from the room.", diff --git a/src/RichError.tsx b/src/RichError.tsx index d16ef640..1525f153 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -5,14 +5,16 @@ 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 ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { + ErrorIcon, HostIcon, PopOutIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import type { ComponentType, FC, ReactNode, SVGAttributes } from "react"; import { ErrorView } from "./ErrorView"; +import { type ElementCallError, ErrorCategory } from "./utils/errors.ts"; /** * An error consisting of a terse message to be logged to the console and a @@ -65,3 +67,46 @@ export class InsufficientCapacityError extends RichError { super("Insufficient server capacity", ); } } + +type ECErrorProps = { + error: ElementCallError; +}; + +const GenericECError: FC<{ error: ElementCallError }> = ({ + error, +}: ECErrorProps) => { + const { t } = useTranslation(); + + let title: string; + let icon: ComponentType>; + switch (error.category) { + case ErrorCategory.CONFIGURATION_ISSUE: + title = t("error.call_is_not_supported"); + icon = HostIcon; + break; + default: + title = t("error.generic"); + icon = ErrorIcon; + } + return ( + +

+ {error.localisedMessage ?? ( + , ]} + values={{ errorCode: error.code }} + /> + )} +

+
+ ); +}; + +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 71c1cc48..95d1d12c 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"; @@ -44,7 +44,7 @@ import { CallEndedView } from "./CallEndedView"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useProfile } from "../profile/useProfile"; import { findDeviceByName } from "../utils/media"; -import { ActiveCall, ConnectionLostError } from "./InCallView"; +import { ActiveCall } from "./InCallView"; import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates"; import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; @@ -61,6 +61,13 @@ import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { useLatest } from "../useLatest"; import { usePageTitle } from "../usePageTitle"; import { ErrorView } from "../ErrorView"; +import { + ConnectionLostError, + ElementCallError, + ErrorCategory, + ErrorCode, +} from "../utils/errors.ts"; +import { ElementCallRichError } from "../RichError.tsx"; declare global { interface Window { @@ -165,6 +172,28 @@ 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 ElementCallError( + e instanceof Error ? e.message : "Unknown error", + ErrorCode.UNKNOWN_ERROR, + ErrorCategory.UNKNOWN, + ); + setEnterRTCError(error); + } + } + }; + useEffect(() => { const defaultDeviceSetup = async ({ audioInput, @@ -214,7 +243,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); @@ -227,13 +256,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); } } }, [ @@ -247,6 +276,9 @@ export const GroupCallView: FC = ({ ]); const [left, setLeft] = useState(false); + const [enterRTCError, setEnterRTCError] = useState( + null, + ); const navigate = useNavigate(); const onLeave = useCallback( @@ -347,8 +379,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]); @@ -397,7 +429,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} @@ -407,7 +441,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/room/InCallView.tsx b/src/room/InCallView.tsx index c0ee0711..17ca63bb 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -97,13 +97,12 @@ import { useSetting, } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; +import { ConnectionLostError } from "../utils/errors.ts"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const maxTapDurationMs = 400; -export class ConnectionLostError extends Error {} - export interface ActiveCallProps extends Omit { e2eeSystem: EncryptionSystem; diff --git a/src/rtcSessionHelpers.test.ts b/src/rtcSessionHelpers.test.ts index 57f73f8e..21ee2cd3 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/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_MATRIX_RTC_FOCUS }), + ); +}); + +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..719af998 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 { MatrixRTCFocusMissingError } from "./utils/errors.ts"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; @@ -80,10 +81,7 @@ async function makePreferredLivekitFoci( } if (preferredFoci.length === 0) - throw new Error( - `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.`, - ); + throw new MatrixRTCFocusMissingError(domain ?? ""); return Promise.resolve(preferredFoci); // TODO: we want to do something like this: diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 00000000..c87bdee7 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,74 @@ +/* +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 { t } from "i18next"; + +export enum ErrorCode { + /** + * Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured. + */ + MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS", + CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", + UNKNOWN_ERROR = "UNKNOWN_ERROR", +} + +export enum ErrorCategory { + /** Calling is not supported, server misconfigured (JWT service missing, no MSC support ...)*/ + CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE", + NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY", + UNKNOWN = "UNKNOWN", + // SYSTEM_FAILURE / FEDERATION_FAILURE .. +} + +/** + * Structure for errors that occur when using ElementCall. + */ +export class ElementCallError extends Error { + public code: ErrorCode; + public category: ErrorCategory; + public localisedMessage?: string; + + public constructor( + name: string, + code: ErrorCode, + category: ErrorCategory, + localisedMessage?: string, + ) { + super(name); + this.localisedMessage = localisedMessage; + this.category = category; + this.code = code; + } +} + +export class MatrixRTCFocusMissingError extends ElementCallError { + public domain: string; + + public constructor(domain: string) { + super( + "MatrixRTCFocusMissingError", + ErrorCode.MISSING_MATRIX_RTC_FOCUS, + ErrorCategory.CONFIGURATION_ISSUE, + t("error.matrix_rtc_focus_missing", { + domain, + brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", + errorCode: ErrorCode.MISSING_MATRIX_RTC_FOCUS, + }), + ); + this.domain = domain; + } +} + +export class ConnectionLostError extends ElementCallError { + public constructor() { + super( + "Connection lost", + ErrorCode.CONNECTION_LOST_ERROR, + ErrorCategory.NETWORK_CONNECTIVITY, + ); + } +}