Merge branch 'livekit' into robin/berry

This commit is contained in:
Hugh Nimmo-Smith
2025-03-21 17:14:10 +00:00
63 changed files with 3530 additions and 246 deletions

View File

@@ -161,7 +161,12 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
height={20}
aria-label={t("header_participants_label")}
/>
<Text as="span" size="sm" weight="medium">
<Text
as="span"
size="sm"
weight="medium"
data-testid="roomHeader_participants_count"
>
{t("participant_count", { count: participantCount ?? 0 })}
</Text>
</div>

View File

@@ -105,7 +105,15 @@ export interface UrlParams {
/**
* The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
*/
analyticsID: string | null;
posthogUserId: string | null;
/**
* The Posthog API host. This is only used in the embedded package of Element Call.
*/
posthogApiHost: string | null;
/**
* The Posthog API key. This is only used in the embedded package of Element Call.
*/
posthogApiKey: string | null;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
@@ -155,6 +163,20 @@ export interface UrlParams {
* If it was a Join Call button, it would be `join_existing`.
*/
intent: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
}
// This is here as a stopgap, but what would be far nicer is a function that
@@ -257,7 +279,6 @@ export const getUrlParams = (
lang: parser.getParam("lang"),
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
analyticsID: parser.getParam("analyticsID"),
allowIceFallback: parser.getFlagParam("allowIceFallback"),
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
skipLobby: parser.getFlagParam(
@@ -271,6 +292,13 @@ export const getUrlParams = (
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
intent,
posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId:
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
};
};

View File

@@ -0,0 +1,91 @@
/*
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 {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
describe("PosthogAnalytics", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value or URL params", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("ignores config value and does not create instance", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("uses URL params if both set", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("ignores URL params and does not create instance", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("creates instance with config value", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
});

View File

@@ -70,11 +70,6 @@ interface PlatformProperties {
cryptoVersion?: string;
}
interface PosthogSettings {
project_api_key?: string;
api_host?: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
@@ -112,24 +107,27 @@ export class PosthogAnalytics {
return this.internalInstance;
}
public static resetInstance(): void {
// Reset the singleton instance
this.internalInstance = null;
}
private constructor(private readonly posthog: PostHog) {
const posthogConfig: PosthogSettings = {
project_api_key: Config.get().posthog?.api_key,
api_host: Config.get().posthog?.api_host,
};
let apiKey: string | undefined;
let apiHost: string | undefined;
if (import.meta.env.VITE_PACKAGE === "embedded") {
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
apiKey = getUrlParams().posthogApiKey ?? undefined;
apiHost = getUrlParams().posthogApiHost ?? undefined;
} else if (import.meta.env.VITE_PACKAGE === "full") {
// in full package it is the server responsible for the analytics
apiKey = Config.get().posthog?.api_key;
apiHost = Config.get().posthog?.api_host;
}
if (posthogConfig.project_api_key && posthogConfig.api_host) {
if (
PosthogAnalytics.getPlatformProperties().matrixBackend === "embedded"
) {
const { analyticsID } = getUrlParams();
// if the embedding platform (element web) already got approval to communicating with posthog
// element call can also send events to posthog
optInAnalytics.setValue(Boolean(analyticsID));
}
this.posthog.init(posthogConfig.project_api_key, {
api_host: posthogConfig.api_host,
if (apiKey && apiHost) {
this.posthog.init(apiKey, {
api_host: apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
@@ -273,7 +271,7 @@ export class PosthogAnalytics {
const client: MatrixClient = window.matrixclient;
let accountAnalyticsId: string | null;
if (widget) {
accountAnalyticsId = getUrlParams().analyticsID;
accountAnalyticsId = getUrlParams().posthogUserId;
} else {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
export interface ConfigOptions {
/**
* The Posthog endpoint to which analytics data will be sent.
* This is only used in the full package of Element Call.
*/
posthog?: {
api_key: string;
@@ -15,6 +16,7 @@ export interface ConfigOptions {
};
/**
* The Sentry endpoint to which crash data will be sent.
* This is only used in the full package of Element Call.
*/
sentry?: {
DSN: string;
@@ -22,6 +24,7 @@ export interface ConfigOptions {
};
/**
* The rageshake server to which feedback and debug logs will be sent.
* This is only used in the full package of Element Call.
*/
rageshake?: {
submit_url: string;
@@ -29,7 +32,7 @@ export interface ConfigOptions {
/**
* Sets the URL to send opentelemetry data to. If unset, opentelemetry will
* be disabled.
* be disabled. This is only used in the full package of Element Call.
*/
opentelemetry?: {
collector_url: string;

View File

@@ -5,24 +5,158 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { Initializer } from "../src/initializer";
import { mockConfig } from "./utils/test";
test("initBeforeReact sets font family from URL param", async () => {
window.location.hash = "#?font=DejaVu Sans";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family",
),
).toBe('"DejaVu Sans"');
});
test("initBeforeReact sets font scale from URL param", async () => {
window.location.hash = "#?fontScale=1.2";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue("--font-scale"),
).toBe("1.2");
const sentryInitSpy = vi.fn();
// Place the mock after the spy is defined
vi.mock("@sentry/react", () => ({
init: sentryInitSpy,
reactRouterV7BrowserTracingIntegration: vi.fn(),
}));
describe("Initializer", async () => {
// we import here to make sure that Sentry is mocked first
const { Initializer } = await import("./initializer.tsx");
describe("initBeforeReact()", () => {
it("sets font family from URL param", async () => {
window.location.hash = "#?font=DejaVu Sans";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family",
),
).toBe('"DejaVu Sans"');
});
it("sets font scale from URL param", async () => {
window.location.hash = "#?fontScale=1.2";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-scale",
),
).toBe("1.2");
});
});
describe("init()", () => {
describe("sentry setup", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
Initializer.reset();
});
afterEach(() => {
sentryInitSpy.mockClear();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not call Sentry.init() without config value", async () => {
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("ignores config value and does not create instance", async () => {
mockConfig({
sentry: {
DSN: "https://config.example.com.localhost",
environment: "config",
},
});
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("uses sentryDsn param if set", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}`;
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: undefined,
}),
);
});
it("uses sentryDsn and sentryEnvironment params if set", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
}),
);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
Initializer.reset();
});
afterEach(() => {
sentryInitSpy.mockClear();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value or URL param", async () => {
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("ignores URL params and does not create instance", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("creates instance with config value", async () => {
mockConfig({
sentry: {
DSN: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
},
});
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
}),
);
});
});
});
});
});

View File

@@ -109,11 +109,11 @@ class DependencyLoadStates {
}
export class Initializer {
private static internalInstance: Initializer;
private static internalInstance: Initializer | undefined;
private isInitialized = false;
public static isInitialized(): boolean {
return Initializer.internalInstance?.isInitialized;
return !!Initializer.internalInstance?.isInitialized;
}
public static async initBeforeReact(): Promise<void> {
@@ -193,11 +193,19 @@ export class Initializer {
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
// initStep calls itself recursively until everything is initialized in the correct order.
// Then the promise gets resolved.
Initializer.internalInstance.initStep(resolve);
Initializer.internalInstance?.initStep(resolve);
});
return Initializer.internalInstance.initPromise;
}
/**
* Resets the initializer. This is used in tests to ensure that the initializer
* is re-initialized for each test.
*/
public static reset(): void {
Initializer.internalInstance = undefined;
}
private loadStates = new DependencyLoadStates();
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
@@ -220,10 +228,22 @@ export class Initializer {
this.loadStates.sentry === LoadState.None &&
this.loadStates.config === LoadState.Loaded
) {
if (Config.get().sentry?.DSN && Config.get().sentry?.environment) {
let dsn: string | undefined;
let environment: string | undefined;
if (import.meta.env.VITE_PACKAGE === "embedded") {
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
dsn = getUrlParams().sentryDsn ?? undefined;
environment = getUrlParams().sentryEnvironment ?? undefined;
}
if (import.meta.env.VITE_PACKAGE === "full") {
// in full package it is the server responsible for the analytics
dsn = Config.get().sentry?.DSN;
environment = Config.get().sentry?.environment;
}
if (dsn) {
Sentry.init({
dsn: Config.get().sentry?.DSN,
environment: Config.get().sentry?.environment,
dsn,
environment,
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect: React.useEffect,

View File

@@ -12,6 +12,9 @@ import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { useActiveLivekitFocus } from "../room/useActiveFocus";
import { useGroupCallErrorBoundary } from "../room/useCallErrorBoundary.ts";
import { FailToGetOpenIdToken } from "../utils/errors.ts";
import { doNetworkOperationWithRetry } from "../utils/matrix.ts";
export interface SFUConfig {
url: string;
@@ -38,6 +41,7 @@ export function useOpenIDSFU(
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const activeFocus = useActiveLivekitFocus(rtcSession);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useEffect(() => {
if (activeFocus) {
@@ -46,13 +50,14 @@ export function useOpenIDSFU(
setSFUConfig(sfuConfig);
},
(e) => {
showGroupCallErrorBoundary(new FailToGetOpenIdToken(e));
logger.error("Failed to get SFU config", e);
},
);
} else {
setSFUConfig(undefined);
}
}, [client, activeFocus]);
}, [client, activeFocus, showGroupCallErrorBoundary]);
return sfuConfig;
}
@@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
activeFocus: LivekitFocus,
): Promise<SFUConfig | undefined> {
const openIdToken = await client.getOpenIdToken();
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
try {

View File

@@ -22,11 +22,15 @@ import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
test.each<[string, ConnectionError]>([
[
"LiveKit",
"LiveKit hits track limit",
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
],
[
"LiveKit Cloud",
"LiveKit hits room participant limit",
new ConnectionError("", ConnectionErrorReason.ServerUnreachable, 200),
],
[
"LiveKit Cloud hits connection limit",
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
],
])(

View File

@@ -144,11 +144,16 @@ async function connectAndPublish(
websocketTimeout: window.websocketTimeout ?? 45000,
});
} catch (e) {
// LiveKit uses 503 to indicate that the server has hit its track limits
// or equivalently, 429 in LiveKit Cloud
// For reference, the 503 response is generated at: https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
if (e instanceof ConnectionError && (e.status === 503 || e.status === 429))
// LiveKit uses 503 to indicate that the server has hit its track limits.
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
// It also errors with a status code of 200 (yes, really) for room
// participant limits.
// LiveKit Cloud uses 429 for connection limits.
// Either way, all these errors can be explained as "insufficient capacity".
if (
e instanceof ConnectionError &&
(e.status === 503 || e.status === 200 || e.status === 429)
)
throw new InsufficientCapacityError();
throw e;
}

79
src/otel/otel.test.ts Normal file
View File

@@ -0,0 +1,79 @@
/*
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 {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { ElementCallOpenTelemetry } from "./otel";
import { mockConfig } from "../utils/test";
describe("ElementCallOpenTelemetry", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
it("ignores config value and does not create instance", () => {
mockConfig({
opentelemetry: {
collector_url: "https://collector.example.com.localhost",
},
});
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
it("creates instance with config value", () => {
mockConfig({
opentelemetry: {
collector_url: "https://collector.example.com.localhost",
},
});
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(true);
});
});
});

View File

@@ -16,6 +16,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
import { Config } from "../config/Config";
import { RageshakeSpanProcessor } from "../analytics/RageshakeSpanProcessor";
import { getRageshakeSubmitUrl } from "../settings/submit-rageshake";
const SERVICE_NAME = "element-call";
@@ -28,20 +29,24 @@ export class ElementCallOpenTelemetry {
public readonly rageshakeProcessor?: RageshakeSpanProcessor;
public static globalInit(): void {
const config = Config.get();
// this is only supported in the full package as the is currently no support for passing in the collector URL from the widget host
const collectorUrl =
import.meta.env.VITE_PACKAGE === "full"
? Config.get().opentelemetry?.collector_url
: undefined;
// we always enable opentelemetry in general. We only enable the OTLP
// collector if a URL is defined (and in future if another setting is defined)
// Posthog reporting is enabled or disabled
// within the posthog code.
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);
const shouldEnableOtlp = Boolean(collectorUrl);
if (!sharedInstance || sharedInstance.isOtlpEnabled !== shouldEnableOtlp) {
logger.info("(Re)starting OpenTelemetry debug reporting");
sharedInstance?.dispose();
sharedInstance = new ElementCallOpenTelemetry(
config.opentelemetry?.collector_url,
config.rageshake?.submit_url,
collectorUrl,
getRageshakeSubmitUrl(),
);
}
}

View File

@@ -67,6 +67,7 @@ import {
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
declare global {
interface Window {
@@ -120,11 +121,13 @@ export const GroupCallView: FC<Props> = ({
};
}, [rtcSession]);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setError(
showGroupCallErrorBoundary(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
@@ -173,30 +176,32 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setError(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 }),
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
setError(error);
} 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 ({
@@ -289,11 +294,12 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
const navigate = useNavigate();
const onLeave = useCallback(
@@ -416,14 +422,7 @@ export const GroupCallView: FC<Props> = ({
);
let body: ReactNode;
if (error) {
// 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 error;
};
body = <ErrorComponent />;
} else if (isJoined) {
if (isJoined) {
body = (
<>
{shareModal}

View File

@@ -0,0 +1,51 @@
/*
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, vi } 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 { 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 (
<div>
<h1>HELLO</h1>
<button onClick={onClick}>Click me</button>
</div>
);
};
render(
<BrowserRouter>
<GroupCallErrorBoundary widget={null} recoveryActionHandler={vi.fn()}>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
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");
});

View File

@@ -0,0 +1,31 @@
/*
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 { useMemo, useState } from "react";
import type { ElementCallError } from "../utils/errors.ts";
export type UseErrorBoundaryApi = {
showGroupCallErrorBoundary: (error: ElementCallError) => void;
};
export function useGroupCallErrorBoundary(): UseErrorBoundaryApi {
const [error, setError] = useState<ElementCallError | null>(null);
const memoized: UseErrorBoundaryApi = useMemo(
() => ({
showGroupCallErrorBoundary: (error: ElementCallError) => setError(error),
}),
[],
);
if (error) {
throw error;
}
return memoized;
}

View File

@@ -23,7 +23,8 @@ interface Props {
export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const { submitRageshake, sending, sent, error, available } =
useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
@@ -66,20 +67,27 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
</Text>
);
return (
<div>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
// in the embedded package the widget host is responsible for analytics consent
const analyticsConsentBlock =
import.meta.env.VITE_PACKAGE === "embedded" ? null : (
<>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</>
);
const feedbackBlock = available ? (
<>
<h4>{t("settings.feedback_tab_h4")}</h4>
<Text>{t("settings.feedback_tab_body")}</Text>
<form onSubmit={onSubmitFeedback}>
@@ -113,6 +121,13 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
{sent && <Text>{t("settings.feedback_tab_thank_you")}</Text>}
</FieldRow>
</form>
</>
) : null;
return (
<div>
{analyticsConsentBlock}
{feedbackBlock}
</div>
);
};

View File

@@ -30,6 +30,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { isRageshakeAvailable } from "./submit-rageshake";
type SettingsTab =
| "audio"
@@ -146,7 +147,12 @@ export const SettingsModal: FC<Props> = ({
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
tabs.push(preferencesTab, feedbackTab);
tabs.push(preferencesTab);
if (isRageshakeAvailable() || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox
// even if rageshake is not available
tabs.push(feedbackTab);
}
if (showDeveloperSettingsTab) tabs.push(developerTab);
return (

View File

@@ -0,0 +1,153 @@
/*
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 {
expect,
describe,
it,
afterEach,
vi,
type Mock,
beforeEach,
} from "vitest";
import {
getRageshakeSubmitUrl,
isRageshakeAvailable,
} from "./submit-rageshake";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
describe("isRageshakeAvailable", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns false with no rageshakeSubmitUrl URL param", () => {
expect(isRageshakeAvailable()).toBe(false);
});
it("ignores config value and returns false with no rageshakeSubmitUrl URL param", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(isRageshakeAvailable()).toBe(false);
});
it("returns true with rageshakeSubmitUrl URL param", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(isRageshakeAvailable()).toBe(true);
});
});
describe("full package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns false with no config value", () => {
expect(isRageshakeAvailable()).toBe(false);
});
it("ignores rageshakeSubmitUrl URL param and returns false with no config value", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(isRageshakeAvailable()).toBe(false);
});
it("returns true with config value", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(isRageshakeAvailable()).toBe(true);
});
});
});
describe("getRageshakeSubmitUrl", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns undefined no rageshakeSubmitUrl URL param", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns rageshakeSubmitUrl URL param when set", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBe("https://url.example.com.localhost");
});
it("ignores config param and returns undefined", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
});
describe("full package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns undefined with no config value", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("ignores rageshakeSubmitUrl URL param and returns undefined", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns config value when set", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBe(
"https://config.example.com.localhost",
);
});
});
});

View File

@@ -19,6 +19,7 @@ import { useClient } from "../ClientContext";
import { Config } from "../config/Config";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
import { getUrlParams } from "../UrlParams";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
@@ -116,11 +117,30 @@ interface RageShakeSubmitOptions {
label?: string;
}
export function getRageshakeSubmitUrl(): string | undefined {
if (import.meta.env.VITE_PACKAGE === "full") {
// in full package we always use the one configured on the server
return Config.get().rageshake?.submit_url;
}
if (import.meta.env.VITE_PACKAGE === "embedded") {
// in embedded package we always use the one provided by the widget host
return getUrlParams().rageshakeSubmitUrl ?? undefined;
}
return undefined;
}
export function isRageshakeAvailable(): boolean {
return !!getRageshakeSubmitUrl();
}
export function useSubmitRageshake(): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error?: Error;
available: boolean;
} {
const { client } = useClient();
@@ -138,7 +158,7 @@ export function useSubmitRageshake(): {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async (opts) => {
if (!Config.get().rageshake?.submit_url) {
if (!getRageshakeSubmitUrl()) {
throw new Error("No rageshake URL is configured");
}
@@ -297,6 +317,7 @@ export function useSubmitRageshake(): {
sending,
sent,
error,
available: isRageshakeAvailable(),
};
}

View File

@@ -17,6 +17,7 @@ export enum ErrorCode {
/** LiveKit indicates that the server has hit its track limits */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
@@ -43,7 +44,7 @@ export class ElementCallError extends Error {
localisedTitle: string,
code: ErrorCode,
category: ErrorCategory,
localisedMessage: string,
localisedMessage?: string,
cause?: Error,
) {
super(localisedTitle, { cause });
@@ -88,7 +89,6 @@ export class RTCSessionError extends ElementCallError {
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
}
}
export class E2EENotSupportedError extends ElementCallError {
public constructor() {
super(
@@ -113,6 +113,19 @@ export class UnknownCallError extends ElementCallError {
}
}
export class FailToGetOpenIdToken extends ElementCallError {
public constructor(error: Error) {
super(
t("error.generic"),
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
undefined,
// Properly set it as a cause for a better reporting on sentry
error,
);
}
}
export class InsufficientCapacityError extends ElementCallError {
public constructor() {
super(

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import {
calculateRetryBackoff,
createClient,
type ICreateClientOpts,
Preset,
@@ -17,6 +18,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
import { sleep } from "matrix-js-sdk/src/utils";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
@@ -335,3 +337,30 @@ export function getRelativeRoomUrl(
: "";
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
}
/**
* Perfom a network operation with retries on ConnectionError.
* If the error is not retryable, or the max number of retries is reached, the error is rethrown.
* Supports handling of matrix quotas.
*/
export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>,
): Promise<T> {
let currentRetryCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await operation();
} catch (e) {
currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true);
if (backoff < 0) {
// Max number of retries reached, or error is not retryable. rethrow the error
throw e;
}
// wait for the specified time and then retry the request
await sleep(backoff);
}
}
}

View File

@@ -264,6 +264,8 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
...DEFAULT_CONFIG,
...config,
});
// simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0);
}
export class MockRTCSession extends TypedEventEmitter<