diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx
index c19e4f4d..77ea81d6 100644
--- a/src/settings/DeveloperSettingsTab.test.tsx
+++ b/src/settings/DeveloperSettingsTab.test.tsx
@@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
-import { describe, expect, it, vi } from "vitest";
-import { render, waitFor } from "@testing-library/react";
-import { type Room as LivekitRoom } from "livekit-client";
+import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
+import { render, waitFor, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { TooltipProvider } from "@vector-im/compound-web";
import type { MatrixClient } from "matrix-js-sdk";
+import type { Room as LivekitRoom } from "livekit-client";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
-
+import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
+import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
// Mock url params hook to avoid environment-dependent snapshot churn.
vi.mock("../UrlParams", () => ({
useUrlParams: (): { mocked: boolean; answer: number } => ({
@@ -20,6 +23,14 @@ vi.mock("../UrlParams", () => ({
}),
}));
+// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
+vi.mock("../livekit/openIDSFU", () => ({
+ getSFUConfigWithOpenID: vi.fn().mockResolvedValue({
+ url: "mock-url",
+ jwt: "mock-jwt",
+ }),
+}));
+
// Provide a minimal mock of a Livekit Room structure used by the component.
function createMockLivekitRoom(
wsUrl: string,
@@ -86,6 +97,7 @@ describe("DeveloperSettingsTab", () => {
const { container } = render(
,
@@ -99,4 +111,141 @@ describe("DeveloperSettingsTab", () => {
expect(container).toMatchSnapshot();
});
+ describe("custom livekit url", () => {
+ afterEach(() => {
+ customLivekitUrlSetting.setValue(null);
+ });
+ const client = {
+ doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
+ getCrypto: () => ({ getVersion: (): string => "x" }),
+ getUserId: () => "@u:hs",
+ getDeviceId: () => "DEVICE",
+ } as unknown as MatrixClient;
+ it("will not update custom livekit url without roomId", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByLabelText("Custom Livekit-url");
+ await user.clear(input);
+ await user.type(input, "wss://example.livekit.invalid");
+
+ const saveButton = screen.getByRole("button", { name: "Save" });
+ await user.click(saveButton);
+ expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
+
+ expect(customLivekitUrlSetting.getValue()).toBe(null);
+ });
+ it("will not update custom livekit url without text in input", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByLabelText("Custom Livekit-url");
+ await user.clear(input);
+
+ const saveButton = screen.getByRole("button", { name: "Save" });
+ await user.click(saveButton);
+ expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
+
+ expect(customLivekitUrlSetting.getValue()).toBe(null);
+ });
+ it("will not update custom livekit url when pressing cancel", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByLabelText("Custom Livekit-url");
+ await user.clear(input);
+ await user.type(input, "wss://example.livekit.invalid");
+
+ const cancelButton = screen.getByRole("button", {
+ name: "Reset overwrite",
+ });
+ await user.click(cancelButton);
+ expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
+
+ expect(customLivekitUrlSetting.getValue()).toBe(null);
+ });
+ it("will update custom livekit url", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByLabelText("Custom Livekit-url");
+ await user.clear(input);
+ await user.type(input, "wss://example.livekit.valid");
+
+ const saveButton = screen.getByRole("button", { name: "Save" });
+ await user.click(saveButton);
+ expect(getSFUConfigWithOpenID).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ "wss://example.livekit.valid",
+ "#testRoom",
+ );
+
+ expect(customLivekitUrlSetting.getValue()).toBe(
+ "wss://example.livekit.valid",
+ );
+ });
+ it("will show error on invalid url", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByLabelText("Custom Livekit-url");
+ await user.clear(input);
+ await user.type(input, "wss://example.livekit.valid");
+
+ const saveButton = screen.getByRole("button", { name: "Save" });
+ (getSFUConfigWithOpenID as Mock).mockImplementation(() => {
+ throw new Error("Invalid URL");
+ });
+ await user.click(saveButton);
+ expect(
+ screen.getByText("invalid URL (did not update)"),
+ ).toBeInTheDocument();
+ expect(customLivekitUrlSetting.getValue()).toBe(null);
+ });
+ });
});
diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx
index 91a2e241..9df6181f 100644
--- a/src/settings/DeveloperSettingsTab.tsx
+++ b/src/settings/DeveloperSettingsTab.tsx
@@ -22,6 +22,7 @@ import {
import { logger } from "matrix-js-sdk/lib/logger";
import {
EditInPlace,
+ ErrorMessage,
Root as Form,
Heading,
HelpMessage,
@@ -45,9 +46,11 @@ import {
} from "./settings";
import styles from "./DeveloperSettingsTab.module.css";
import { useUrlParams } from "../UrlParams";
+import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
interface Props {
client: MatrixClient;
+ roomId?: string;
livekitRooms?: {
room: LivekitRoom;
url: string;
@@ -60,6 +63,7 @@ interface Props {
export const DeveloperSettingsTab: FC = ({
client,
livekitRooms,
+ roomId,
env,
}) => {
const { t } = useTranslation();
@@ -97,6 +101,8 @@ export const DeveloperSettingsTab: FC = ({
alwaysShowIphoneEarpieceSetting,
);
+ const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
+ useState(null);
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
customLivekitUrlSetting,
);
@@ -234,14 +240,36 @@ export const DeveloperSettingsTab: FC = ({
savingLabel={t("developer_mode.custom_livekit_url.saving")}
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
onSave={useCallback(
- (e: React.FormEvent) => {
- setCustomLivekitUrl(
- customLivekitUrlTextBuffer === ""
- ? null
- : customLivekitUrlTextBuffer,
- );
+ async (e: React.FormEvent): Promise => {
+ if (
+ roomId === undefined ||
+ customLivekitUrlTextBuffer === "" ||
+ customLivekitUrlTextBuffer === null
+ ) {
+ setCustomLivekitUrl(null);
+ return;
+ }
+
+ try {
+ const userId = client.getUserId();
+ const deviceId = client.getDeviceId();
+
+ if (userId === null || deviceId === null) {
+ throw new Error("Invalid user or device ID");
+ }
+ await getSFUConfigWithOpenID(
+ client,
+ { userId, deviceId, memberId: "" },
+ customLivekitUrlTextBuffer,
+ roomId,
+ );
+ setCustomLivekitUrlUpdateError(null);
+ setCustomLivekitUrl(customLivekitUrlTextBuffer);
+ } catch {
+ setCustomLivekitUrlUpdateError("invalid URL (did not update)");
+ }
},
- [setCustomLivekitUrl, customLivekitUrlTextBuffer],
+ [customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
)}
value={customLivekitUrlTextBuffer ?? ""}
onChange={useCallback(
@@ -256,7 +284,12 @@ export const DeveloperSettingsTab: FC = ({
},
[setCustomLivekitUrl],
)}
- />
+ serverInvalid={customLivekitUrlUpdateError !== null}
+ >
+ {customLivekitUrlUpdateError !== null && (
+ {customLivekitUrlUpdateError}
+ )}
+
{t("developer_mode.matrixRTCMode.title")}
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx
index 2b4078aa..30ac3618 100644
--- a/src/settings/SettingsModal.tsx
+++ b/src/settings/SettingsModal.tsx
@@ -213,6 +213,7 @@ export const SettingsModal: FC = ({
env={import.meta.env}
client={client}
livekitRooms={livekitRooms}
+ roomId={roomId}
/>
),
};
diff --git a/src/settings/settings.ts b/src/settings/settings.ts
index a674f1aa..917c79f1 100644
--- a/src/settings/settings.ts
+++ b/src/settings/settings.ts
@@ -34,15 +34,20 @@ export class Setting {
this._value$ = new BehaviorSubject(initialValue);
this.value$ = this._value$;
+ this._lastUpdateReason$ = new BehaviorSubject(null);
+ this.lastUpdateReason$ = this._lastUpdateReason$;
}
private readonly key: string;
private readonly _value$: BehaviorSubject;
+ private readonly _lastUpdateReason$: BehaviorSubject;
public readonly value$: Behavior;
+ public readonly lastUpdateReason$: Behavior;
- public readonly setValue = (value: T): void => {
+ public readonly setValue = (value: T, reason?: string): void => {
this._value$.next(value);
+ this._lastUpdateReason$.next(reason ?? null);
localStorage.setItem(this.key, JSON.stringify(value));
};
public readonly getValue = (): T => {