From 1fd9ac93c9772467fcb1978749dc13dfa09b579f Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 20 Nov 2025 14:42:12 +0100 Subject: [PATCH 01/11] refactor local transport testing and local memberhsip initialization --- src/livekit/openIDSFU.ts | 9 +- src/room/GroupCallView.tsx | 1 + src/room/InCallView.tsx | 2 +- src/state/Behavior.ts | 5 +- src/state/CallViewModel/CallViewModel.test.ts | 84 +---- src/state/CallViewModel/CallViewModel.ts | 47 ++- .../localMember/LocalMembership.test.ts | 350 +++++++++++------- .../localMember/LocalMembership.ts | 233 ++++++------ .../localMember/LocalTransport.test.ts | 120 ++++++ .../localMember/LocalTransport.ts | 71 ++-- .../remoteMembers/ConnectionFactory.ts | 1 + .../remoteMembers/ConnectionManager.test.ts | 20 +- .../remoteMembers/ConnectionManager.ts | 3 +- src/state/MuteStates.ts | 1 + src/utils/test.ts | 2 + 15 files changed, 571 insertions(+), 378 deletions(-) create mode 100644 src/state/CallViewModel/localMember/LocalTransport.test.ts diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index 073f6c75..c9c26c83 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -21,7 +21,14 @@ export type OpenIDClientParts = Pick< MatrixClient, "getOpenIdToken" | "getDeviceId" >; - +/** + * + * @param client + * @param serviceUrl + * @param matrixRoomId + * @returns + * @throws FailToGetOpenIdToken + */ export async function getSFUConfigWithOpenID( client: OpenIDClientParts, serviceUrl: string, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 076667a9..75438f7f 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -159,6 +159,7 @@ export const GroupCallView: FC = ({ }; }, [rtcSession]); + // TODO move this into the callViewModel LocalMembership.ts useTypedEventEmitter( rtcSession, MatrixRTCSessionEvent.MembershipManagerError, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 28293d29..b17d3aae 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -266,7 +266,7 @@ export const InCallView: FC = ({ const sharingScreen = useBehavior(vm.sharingScreen$); const ringOverlay = useBehavior(vm.ringOverlay$); - const fatalCallError = useBehavior(vm.configError$); + const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary if (fatalCallError) throw fatalCallError; diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts index 3c88dc00..53a826e1 100644 --- a/src/state/Behavior.ts +++ b/src/state/Behavior.ts @@ -16,7 +16,10 @@ import { BehaviorSubject } from "rxjs"; * distinction between Behaviors and Observables, see * https://monoid.dk/post/behaviors-and-streams-why-both/. */ -export type Behavior = Omit, "next" | "observers">; +export type Behavior = Omit< + BehaviorSubject, + "next" | "observers" | "error" +>; /** * Creates a Behavior which never changes in value. diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 3a621f33..76be5f65 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -6,8 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { test, vi, onTestFinished, it, describe, expect } from "vitest"; -import EventEmitter from "events"; +import { test, vi, onTestFinished, it, describe } from "vitest"; import { BehaviorSubject, combineLatest, @@ -19,12 +18,11 @@ import { of, switchMap, } from "rxjs"; -import { SyncState, type MatrixClient } from "matrix-js-sdk"; +import { SyncState } from "matrix-js-sdk"; import { ConnectionState, type LocalTrackPublication, type RemoteParticipant, - type Room as LivekitRoom, } from "livekit-client"; import * as ComponentsCore from "@livekit/components-core"; import { @@ -36,27 +34,18 @@ import { type LivekitTransport, } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; -import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; -import { createCallViewModel$ } from "./CallViewModel"; import { type Layout } from "../layout-types.ts"; import { mockLocalParticipant, - mockMatrixRoom, mockMatrixRoomMember, mockRemoteParticipant, withTestScheduler, mockRtcMembership, - MockRTCSession, - mockMediaDevices, - mockMuteStates, - mockConfig, testScope, - mockLivekitRoom, exampleTransport, } from "../../utils/test.ts"; import { E2eeType } from "../../e2ee/e2eeType.ts"; -import type { RaisedHandInfo, ReactionInfo } from "../../reactions/index.ts"; import { aliceId, aliceParticipant, @@ -71,10 +60,6 @@ import { import { MediaDevices } from "../MediaDevices.ts"; import { getValue } from "../../utils/observable.ts"; import { type Behavior, constant } from "../Behavior.ts"; -import { - type ElementCallError, - MatrixRTCTransportMissingError, -} from "../../utils/errors.ts"; import { withCallViewModel } from "./CallViewModelTestUtils.ts"; vi.mock("rxjs", async (importOriginal) => ({ @@ -245,71 +230,6 @@ function mockRingEvent( const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; describe("CallViewModel", () => { - // TODO: Restore this test. It requires makeTransport to not be mocked, unlike - // the rest of the tests in this file… what do we do? - it.skip("test missing RTC config error", async () => { - const rtcMemberships$ = new BehaviorSubject([]); - const emitter = new EventEmitter(); - const client = vi.mocked({ - on: emitter.on.bind(emitter), - off: emitter.off.bind(emitter), - getSyncState: vi.fn().mockReturnValue(SyncState.Syncing), - getUserId: vi.fn().mockReturnValue("@user:localhost"), - getUser: vi.fn().mockReturnValue(null), - getDeviceId: vi.fn().mockReturnValue("DEVICE"), - credentials: { - userId: "@user:localhost", - }, - getCrypto: vi.fn().mockReturnValue(undefined), - getDomain: vi.fn().mockReturnValue("example.org"), - } as unknown as MatrixClient); - - const matrixRoom = mockMatrixRoom({ - roomId: "!myRoomId:example.com", - client, - getMember: vi.fn().mockReturnValue(undefined), - }); - - const fakeRtcSession = new MockRTCSession(matrixRoom).withMemberships( - rtcMemberships$, - ); - - mockConfig({}); - - vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({}); - - const callVM = createCallViewModel$( - testScope(), - fakeRtcSession.asMockedSession(), - matrixRoom, - mockMediaDevices({}), - mockMuteStates(), - { - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - autoLeaveWhenOthersLeft: false, - livekitRoomFactory: (): LivekitRoom => - mockLivekitRoom({ - localParticipant, - disconnect: async () => Promise.resolve(), - setE2EEEnabled: async () => Promise.resolve(), - }), - }, - new BehaviorSubject({} as Record), - new BehaviorSubject({} as Record), - constant({ processor: undefined, supported: false }), - ); - - const failPromise = Promise.withResolvers(); - callVM.configError$.subscribe((error) => { - if (error) { - failPromise.resolve(error); - } - }); - - const error = await failPromise.promise; - expect(error).toBeInstanceOf(MatrixRTCTransportMissingError); - }); - test("participants are retained during a focus switch", () => { withTestScheduler(({ behavior, expectObservable }) => { // Participants disappear on frame 2 and come back on frame 3 diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index eb270641..f8deddc3 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -41,7 +41,10 @@ import { timer, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { + type LivekitTransport, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { @@ -95,7 +98,10 @@ import { import { type ElementCallError } from "../../utils/errors.ts"; import { type ObservableScope } from "../ObservableScope.ts"; import { + createHomeserverConnected$, createLocalMembership$, + enterRTCSession, + LivekitState, type LocalMemberConnectionState, } from "./localMember/LocalMembership.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; @@ -120,6 +126,8 @@ import { createMatrixMemberMetadata$, createRoomMembers$, } from "./remoteMembers/MatrixMemberMetadata.ts"; +import { Publisher } from "./localMember/Publisher.ts"; +import { type Connection } from "./remoteMembers/Connection.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO @@ -230,7 +238,7 @@ export interface CallViewModel { * This is a fatal error that prevents the call from being created/joined. * Should render a blocking error screen. */ - configError$: Behavior; + fatalError$: Behavior; // participants and counts /** @@ -446,15 +454,31 @@ export function createCallViewModel$( const localMembership = createLocalMembership$({ scope: scope, + homeserverConnected$: createHomeserverConnected$( + scope, + matrixRoom, + matrixRTCSession, + ), muteStates: muteStates, - mediaDevices: mediaDevices, + joinMatrixRTC: async (transport: LivekitTransport) => { + return enterRTCSession( + matrixRTCSession, + transport, + connectOptions$.value, + ); + }, + createPublisherFactory: (connection: Connection) => { + return new Publisher( + scope, + connection, + mediaDevices, + muteStates, + trackProcessorState$, + ); + }, connectionManager: connectionManager, matrixRTCSession: matrixRTCSession, - matrixRoom: matrixRoom, localTransport$: localTransport$, - trackProcessorState$: trackProcessorState$, - widget, - options: connectOptions$, logger: logger.getChild(`[${Date.now()}]`), }); @@ -1442,7 +1466,14 @@ export function createCallViewModel$( hoverScreen: (): void => screenHover$.next(), unhoverScreen: (): void => screenUnhover$.next(), - configError$: localMembership.configError$, + fatalError$: scope.behavior( + localMembership.connectionState.livekit$.pipe( + filter((v) => v.state === LivekitState.Error), + map((s) => s.error), + ), + null, + ), + participantCount$: participantCount$, audioParticipants$: audioParticipants$, diff --git a/src/state/CallViewModel/localMember/LocalMembership.test.ts b/src/state/CallViewModel/localMember/LocalMembership.test.ts index 716740d3..390fa1a8 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.test.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.test.ts @@ -6,154 +6,234 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; -import { expect, test, vi } from "vitest"; +import { + type LivekitTransport, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; +import { describe, expect, it, vi } from "vitest"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; -import EventEmitter from "events"; +import { map } from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; import { MatrixRTCMode } from "../../../settings/settings"; -import { mockConfig } from "../../../utils/test"; -import { enterRTCSession } from "./LocalMembership"; +import { + mockConfig, + mockMuteStates, + withTestScheduler, +} from "../../../utils/test"; +import { + createLocalMembership$, + enterRTCSession, + LivekitState, +} from "./LocalMembership"; +import { MatrixRTCTransportMissingError } from "../../../utils/errors"; +import { Epoch } from "../../ObservableScope"; +import { constant } from "../../Behavior"; +import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; +import { type Publisher } from "./Publisher"; const MATRIX_RTC_MODE = MatrixRTCMode.Legacy; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../../../UrlParams", () => ({ getUrlParams })); -vi.mock("../../../widget", async (importOriginal) => ({ - ...(await importOriginal()), - widget: { - api: { - setAlwaysOnScreen: (): void => {}, - transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() }, - }, - lazyActions: new EventEmitter(), - }, -})); - -test("It joins the correct Session", async () => { - const focusFromOlderMembership = { - type: "livekit", - livekit_service_url: "http://my-oldest-member-service-url.com", - livekit_alias: "my-oldest-member-service-alias", - }; - - const focusConfigFromWellKnown = { - type: "livekit", - livekit_service_url: "http://my-well-known-service-url.com", - }; - const focusConfigFromWellKnown2 = { - type: "livekit", - livekit_service_url: "http://my-well-known-service-url2.com", - }; - const clientWellKnown = { - "org.matrix.msc4143.rtc_foci": [ - focusConfigFromWellKnown, - focusConfigFromWellKnown2, - ], - }; - - mockConfig({ - livekit: { livekit_service_url: "http://my-default-service-url.com" }, - }); - - vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation( - async (domain) => { - if (domain === "example.org") { - return Promise.resolve(clientWellKnown); - } - return Promise.resolve({}); - }, - ); - - const mockedSession = vi.mocked({ - room: { - roomId: "roomId", - client: { - getDomain: vi.fn().mockReturnValue("example.org"), - getOpenIdToken: vi.fn().mockResolvedValue({ - access_token: "ACCCESS_TOKEN", - token_type: "Bearer", - matrix_server_name: "localhost", - expires_in: 10000, - }), - }, - }, - memberships: [], - getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership), - getOldestMembership: vi.fn().mockReturnValue({ - getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]), - }), - joinRoomSession: vi.fn(), - }) as unknown as MatrixRTCSession; - - await enterRTCSession( - mockedSession, - { - livekit_alias: "roomId", - livekit_service_url: "http://my-well-known-service-url.com", - type: "livekit", - }, - { - encryptMedia: true, - matrixRTCMode: MATRIX_RTC_MODE, - }, - ); - - expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith( - [ - { - livekit_alias: "roomId", - livekit_service_url: "http://my-well-known-service-url.com", +// vi.mock("../../../widget", async (importOriginal) => ({ +// ...(await importOriginal()), +// widget: { +// api: { +// setAlwaysOnScreen: (): void => {}, +// transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() }, +// }, +// lazyActions: new EventEmitter(), +// }, +// })); +describe("LocalMembership", () => { + describe("enterRTCSession", () => { + it("It joins the correct Session", async () => { + const focusFromOlderMembership = { type: "livekit", - }, - ], - undefined, - expect.objectContaining({ - manageMediaKeys: true, - useLegacyMemberEvents: false, - }), - ); -}); + livekit_service_url: "http://my-oldest-member-service-url.com", + livekit_alias: "my-oldest-member-service-alias", + }; -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": [ - { + const focusConfigFromWellKnown = { type: "livekit", livekit_service_url: "http://my-well-known-service-url.com", - }, - ], + }; + const focusConfigFromWellKnown2 = { + type: "livekit", + livekit_service_url: "http://my-well-known-service-url2.com", + }; + const clientWellKnown = { + "org.matrix.msc4143.rtc_foci": [ + focusConfigFromWellKnown, + focusConfigFromWellKnown2, + ], + }; + + mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, + }); + + vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation( + async (domain) => { + if (domain === "example.org") { + return Promise.resolve(clientWellKnown); + } + return Promise.resolve({}); + }, + ); + + const mockedSession = vi.mocked({ + room: { + roomId: "roomId", + client: { + getDomain: vi.fn().mockReturnValue("example.org"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, + }), + }, + }, + memberships: [], + getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership), + getOldestMembership: vi.fn().mockReturnValue({ + getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]), + }), + joinRoomSession: vi.fn(), + }) as unknown as MatrixRTCSession; + + await enterRTCSession( + mockedSession, + { + livekit_alias: "roomId", + livekit_service_url: "http://my-well-known-service-url.com", + type: "livekit", + }, + { + encryptMedia: true, + matrixRTCMode: MATRIX_RTC_MODE, + }, + ); + + expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith( + [ + { + livekit_alias: "roomId", + livekit_service_url: "http://my-well-known-service-url.com", + type: "livekit", + }, + ], + undefined, + expect.objectContaining({ + manageMediaKeys: true, + useLegacyMemberEvents: false, + }), + ); + }); + + it("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"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, + }), + }, + }, + memberships: [], + getFocusInUse: vi.fn(), + joinRoomSession: vi.fn(), + }) as unknown as MatrixRTCSession; + + await enterRTCSession( + mockedSession, + { + livekit_alias: "roomId", + livekit_service_url: "http://my-well-known-service-url.com", + type: "livekit", + }, + { + encryptMedia: true, + matrixRTCMode: MATRIX_RTC_MODE, + }, + ); + }); }); - const mockedSession = vi.mocked({ - room: { - roomId: "roomId", - client: { - getDomain: vi.fn().mockReturnValue("example.org"), - getOpenIdToken: vi.fn().mockResolvedValue({ - access_token: "ACCCESS_TOKEN", - token_type: "Bearer", - matrix_server_name: "localhost", - expires_in: 10000, - }), - }, - }, - memberships: [], - getFocusInUse: vi.fn(), - joinRoomSession: vi.fn(), - }) as unknown as MatrixRTCSession; + const defaultCreateLocalMemberValues = { + options: constant({ + encryptMedia: false, + matrixRTCMode: MatrixRTCMode.Matrix_2_0, + }), + matrixRTCSession: { + updateCallIntent: () => {}, + leaveRoomSession: () => {}, + } as unknown as MatrixRTCSession, + muteStates: mockMuteStates(), + isHomeserverConnected: constant(true), + trackProcessorState$: constant({ + supported: false, + processor: undefined, + }), + logger: logger, + createPublisherFactory: (): Publisher => ({}) as unknown as Publisher, + joinMatrixRTC: async (): Promise => {}, + homeserverConnected$: constant(true), + }; - await enterRTCSession( - mockedSession, - { - livekit_alias: "roomId", - livekit_service_url: "http://my-well-known-service-url.com", - type: "livekit", - }, - { - encryptMedia: true, - matrixRTCMode: MATRIX_RTC_MODE, - }, - ); + it("throws error on missing RTC config error", () => { + withTestScheduler(({ scope, hot, expectObservable }) => { + const goodTransport = { + livekit_service_url: "other", + } as LivekitTransport; + + const localTransport$ = scope.behavior( + hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), + goodTransport, + ); + + const mockConnectionManager = { + transports$: scope.behavior( + localTransport$.pipe(map((t) => new Epoch([t]))), + ), + connectionManagerData$: constant( + new Epoch(new ConnectionManagerData()), + ), + }; + + const localMembership = createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + connectionManager: mockConnectionManager, + localTransport$, + }); + + expectObservable(localMembership.connectionState.livekit$).toBe("ne", { + n: { state: LivekitState.Uninitialized }, + e: { + state: LivekitState.Error, + error: expect.toSatisfy( + (e) => e instanceof MatrixRTCTransportMissingError, + ), + }, + }); + }); + }); }); diff --git a/src/state/CallViewModel/localMember/LocalMembership.ts b/src/state/CallViewModel/localMember/LocalMembership.ts index 68b34d94..01267764 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.ts @@ -13,14 +13,14 @@ import { } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { - type LivekitTransport, - type MatrixRTCSession, MembershipManagerEvent, Status, + type LivekitTransport, + type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; -import { ClientEvent, SyncState, type Room as MatrixRoom } from "matrix-js-sdk"; import { BehaviorSubject, + catchError, combineLatest, distinctUntilChanged, fromEvent, @@ -32,23 +32,17 @@ import { switchMap, tap, } from "rxjs"; -import { type Logger } from "matrix-js-sdk/lib/logger"; +import { logger, type Logger } from "matrix-js-sdk/lib/logger"; +import { ClientEvent, type Room, SyncState } from "matrix-js-sdk"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; import { ObservableScope } from "../../ObservableScope"; -import { Publisher } from "./Publisher"; +import { type Publisher } from "./Publisher"; import { type MuteStates } from "../../MuteStates"; -import { type ProcessorState } from "../../../livekit/TrackProcessorContext"; -import { type MediaDevices } from "../../MediaDevices"; import { and$ } from "../../../utils/observable"; import { ElementCallError, UnknownCallError } from "../../../utils/errors"; -import { - ElementWidgetActions, - widget, - type WidgetHelpers, -} from "../../../widget"; -import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers"; +import { ElementWidgetActions, widget } from "../../../widget"; import { getUrlParams } from "../../../UrlParams.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts"; @@ -68,7 +62,7 @@ export enum LivekitState { } type LocalMemberLivekitState = - | { state: LivekitState.Error; error: string } + | { state: LivekitState.Error; error: ElementCallError } | { state: LivekitState.Connected } | { state: LivekitState.Connecting } | { state: LivekitState.Uninitialized } @@ -79,12 +73,14 @@ export enum MatrixState { Connected = "connected", Disconnected = "disconnected", Connecting = "connecting", + Error = "Error", } type LocalMemberMatrixState = | { state: MatrixState.Connected } | { state: MatrixState.Connecting } - | { state: MatrixState.Disconnected }; + | { state: MatrixState.Disconnected } + | { state: MatrixState.Error; error: Error }; export interface LocalMemberConnectionState { livekit$: Behavior; @@ -102,17 +98,21 @@ export interface LocalMemberConnectionState { * - Publisher.publishTracks() * - send join state/sticky event */ + interface Props { - options: Behavior; + // TODO add a comment into some code style readme or file header callviewmodel + // that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes scope: ObservableScope; - mediaDevices: MediaDevices; muteStates: MuteStates; connectionManager: IConnectionManager; - matrixRTCSession: MatrixRTCSession; - matrixRoom: MatrixRoom; + createPublisherFactory: (connection: Connection) => Publisher; + joinMatrixRTC: (trasnport: LivekitTransport) => Promise; + homeserverConnected$: Behavior; localTransport$: Behavior; - trackProcessorState$: Behavior; - widget: WidgetHelpers | null; + matrixRTCSession: Pick< + MatrixRTCSession, + "updateCallIntent" | "leaveRoomSession" + >; logger: Logger; } @@ -131,18 +131,15 @@ interface Props { */ export const createLocalMembership$ = ({ scope, - options, - muteStates, - mediaDevices, connectionManager, - matrixRTCSession, - localTransport$, - matrixRoom, - trackProcessorState$, - widget, + localTransport$: localTransportCanThrow$, + homeserverConnected$, + createPublisherFactory, + joinMatrixRTC, logger: parentLogger, + muteStates, + matrixRTCSession, }: Props): { - // publisher: Publisher requestConnect: () => LocalMemberConnectionState; startTracks: () => Behavior; requestDisconnect: () => Observable | null; @@ -154,17 +151,13 @@ export const createLocalMembership$ = ({ toggleScreenSharing: (() => void) | null; participant$: Behavior; connection$: Behavior; - // deprecated fields - /** @deprecated use state instead*/ homeserverConnected$: Behavior; + // deprecated fields /** @deprecated use state instead*/ connected$: Behavior; // this needs to be discussed /** @deprecated use state instead*/ reconnecting$: Behavior; - // also needs to be disccues - /** @deprecated use state instead*/ - configError$: Behavior; } => { const logger = parentLogger.getChild("[LocalMembership]"); logger.debug(`Creating local membership..`); @@ -188,18 +181,32 @@ export const createLocalMembership$ = ({ // This should be used in a combineLatest with publisher$ to connect. const tracks$ = new BehaviorSubject([]); + // unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error. + const localTransport$ = scope.behavior( + localTransportCanThrow$.pipe( + catchError((e: unknown) => { + if (e instanceof ElementCallError) { + state.livekit$.next({ state: LivekitState.Error, error: e }); + } else { + logger.error("Unknown error from localTransport$", e); + } + return of(null); + }), + ), + ); + // Drop Epoch data here since we will not combine this anymore const localConnection$ = scope.behavior( - combineLatest([connectionManager.connections$, localTransport$]).pipe( - map(([connections, localTransport]) => { + combineLatest([ + connectionManager.connectionManagerData$, + localTransport$, + ]).pipe( + map(([connectionData, localTransport]) => { if (localTransport === null) { return null; } - return ( - connections.value.find((connection) => - areLivekitTransportsEqual(connection.transport, localTransport), - ) ?? null - ); + + return connectionData.value.getConnectionForTransport(localTransport); }), tap((connection) => { logger.info( @@ -208,40 +215,6 @@ export const createLocalMembership$ = ({ }), ), ); - /** - * Whether we are connected to the MatrixRTC session. - */ - const homeserverConnected$ = scope.behavior( - // To consider ourselves connected to MatrixRTC, we check the following: - and$( - // The client is connected to the sync loop - ( - fromEvent(matrixRoom.client, ClientEvent.Sync) as Observable< - [SyncState] - > - ).pipe( - startWith([matrixRoom.client.getSyncState()]), - map(([state]) => state === SyncState.Syncing), - ), - // Room state observed by session says we're connected - fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe( - startWith(null), - map(() => matrixRTCSession.membershipStatus === Status.Connected), - ), - // Also watch out for warnings that we've likely hit a timeout and our - // delayed leave event is being sent (this condition is here because it - // provides an earlier warning than the sync loop timeout, and we wouldn't - // see the actual leave event until we reconnect to the sync loop) - fromEvent(matrixRTCSession, MembershipManagerEvent.ProbablyLeft).pipe( - startWith(null), - map(() => matrixRTCSession.probablyLeft !== true), - ), - ).pipe( - tap((connected) => { - logger.info(`Homeserver connected update: ${connected}`); - }), - ), - ); // /** // * Whether we are "fully" connected to the call. Accounts for both the @@ -265,18 +238,15 @@ export const createLocalMembership$ = ({ localConnection$.pipe(scope.bind()).subscribe((connection) => { if (connection !== null && publisher$.value === null) { // TODO looks strange to not change publisher if connection changes. - publisher$.next( - new Publisher( - scope, - connection, - mediaDevices, - muteStates, - trackProcessorState$, - ), - ); + // @valere will take care of this! + publisher$.next(createPublisherFactory(connection)); } }); + // const mutestate= publisher$.pipe(switchMap((publisher) => { + // return publisher.muteState$ + // }); + combineLatest([publisher$, trackStartRequested$]).subscribe( ([publisher, shouldStartTracks]) => { if (publisher && shouldStartTracks) { @@ -359,14 +329,21 @@ export const createLocalMembership$ = ({ } state.matrix$.next({ state: MatrixState.Connecting }); logger.info("Matrix State connecting"); - enterRTCSession(matrixRTCSession, transport, options.value).catch( - (error) => { - logger.error(error); - }, - ); + + joinMatrixRTC(transport).catch((error) => { + logger.error(error); + state.matrix$.next({ state: MatrixState.Error, error }); + }); }, ); + // TODO add this and update `state.matrix$` based on it. + // useTypedEventEmitter( + // rtcSession, + // MatrixRTCSessionEvent.MembershipManagerError, + // (error) => setExternalError(new ConnectionLostError()), + // ); + const requestConnect = (): LocalMemberConnectionState => { trackStartRequested$.next(true); connectRequested$.next(true); @@ -440,18 +417,25 @@ export const createLocalMembership$ = ({ } } }); + // TODO: Refactor updateCallIntent to sth like this: + // combineLatest([muteStates.video.enabled$,localTransport$, state.matrix$]).pipe(map(()=>{ + // matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"), + // })) + // - const configError$ = new BehaviorSubject(null); // TODO I do not fully understand what this does. // Is it needed? // Is this at the right place? // Can this be simplified? // Start and stop session membership as needed - scope.reconcile(localTransport$, async (advertised) => { - if (advertised !== null && advertised !== undefined) { + // Discussed in statndup -> It seems we can remove this (there is another call to enterRTCSession in this file) + // MAKE SURE TO UNDERSTAND why reconcile is needed and what is potentially missing from the alternative enterRTCSession block. + // @toger5 will try to take care of this. + scope.reconcile(localTransport$, async (transport) => { + if (transport !== null && transport !== undefined) { try { - await enterRTCSession(matrixRTCSession, advertised, options.value); - configError$.next(null); + state.matrix$.next({ state: MatrixState.Connecting }); + await joinMatrixRTC(transport); } catch (e) { logger.error("Error entering RTC session", e); } @@ -493,14 +477,13 @@ export const createLocalMembership$ = ({ return s.error instanceof ElementCallError ? s.error : new UnknownCallError(s.error); - } else { - return null; } }), scope.bind(), ) - .subscribe((fatalError) => { - configError$.next(fatalError); + .subscribe((error) => { + if (error !== undefined) + state.livekit$.next({ state: LivekitState.Error, error }); }); /** @@ -509,9 +492,9 @@ export const createLocalMembership$ = ({ const sharingScreen$ = scope.behavior( localConnection$.pipe( switchMap((c) => - c === null - ? of(false) - : observeSharingScreen$(c.livekitRoom.localParticipant), + c !== null && c.livekitRoom + ? observeSharingScreen$(c.livekitRoom.localParticipant) + : of(false), ), ), ); @@ -539,7 +522,7 @@ export const createLocalMembership$ = ({ : null; const participant$ = scope.behavior( - localConnection$.pipe(map((c) => c?.livekitRoom.localParticipant ?? null)), + localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)), ); return { startTracks, @@ -549,7 +532,7 @@ export const createLocalMembership$ = ({ homeserverConnected$, connected$, reconnecting$, - configError$, + sharingScreen$, toggleScreenSharing, participant$, @@ -603,6 +586,7 @@ export async function enterRTCSession( const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy; // Multi-sfu does not need a preferred foci list. just the focus that is actually used. + // TODO where/how do we track errors originating from the ongoing rtcSession? rtcSession.joinRoomSession( multiSFU ? [] : [transport], multiSFU ? transport : undefined, @@ -631,3 +615,44 @@ export async function enterRTCSession( await widget.api.transport.send(ElementWidgetActions.JoinCall, {}); } } + +/** + * Whether we are connected to the MatrixRTC session. + */ +export function createHomeserverConnected$( + scope: ObservableScope, + matrixRoom: Room, + matrixRTCSession: MatrixRTCSession, +): Behavior { + return scope.behavior( + // To consider ourselves connected to MatrixRTC, we check the following: + and$( + // The client is connected to the sync loop + ( + fromEvent(matrixRoom.client, ClientEvent.Sync) as Observable< + [SyncState] + > + ).pipe( + startWith([matrixRoom.client.getSyncState()]), + map(([state]) => state === SyncState.Syncing), + ), + // Room state observed by session says we're connected + fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe( + startWith(null), + map(() => matrixRTCSession.membershipStatus === Status.Connected), + ), + // Also watch out for warnings that we've likely hit a timeout and our + // delayed leave event is being sent (this condition is here because it + // provides an earlier warning than the sync loop timeout, and we wouldn't + // see the actual leave event until we reconnect to the sync loop) + fromEvent(matrixRTCSession, MembershipManagerEvent.ProbablyLeft).pipe( + startWith(null), + map(() => matrixRTCSession.probablyLeft !== true), + ), + ).pipe( + tap((connected) => { + logger.info(`Homeserver connected update: ${connected}`); + }), + ), + ); +} diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts new file mode 100644 index 00000000..d543f97a --- /dev/null +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -0,0 +1,120 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; + +import { mockConfig, flushPromises } from "../../../utils/test"; +import { createLocalTransport$ } from "./LocalTransport"; +import { constant } from "../../Behavior"; +import { Epoch, ObservableScope } from "../../ObservableScope"; +import { + MatrixRTCTransportMissingError, + FailToGetOpenIdToken, +} from "../../../utils/errors"; +import * as openIDSFU from "../../../livekit/openIDSFU"; + +describe("LocalTransport", () => { + let scope: ObservableScope; + beforeEach(() => (scope = new ObservableScope())); + afterEach(() => scope.end()); + + it("throws if config is missing", async () => { + const localTransport$ = createLocalTransport$({ + scope, + roomId: "!room:example.org", + useOldestMember$: constant(false), + memberships$: constant(new Epoch([])), + client: { + getDomain: () => "", + // These won't be called in this error path but satisfy the type + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + }, + }); + await flushPromises(); + + expect(() => localTransport$.value).toThrow( + new MatrixRTCTransportMissingError(""), + ); + }); + + it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { + // Provide a valid config so makeTransportInternal resolves a transport + const scope = new ObservableScope(); + mockConfig({ + livekit: { livekit_service_url: "https://lk.example.org" }, + }); + const resolver = Promise.withResolvers(); + vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation( + async () => { + await resolver.promise; + throw new FailToGetOpenIdToken(new Error("no openid")); + }, + ); + const observations: unknown[] = []; + const errors: Error[] = []; + const localTransport$ = createLocalTransport$({ + scope, + roomId: "!room:example.org", + useOldestMember$: constant(false), + memberships$: constant(new Epoch([])), + client: { + // Use empty domain to skip .well-known and use config directly + getDomain: () => "", + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + }, + }); + localTransport$.subscribe( + (o) => observations.push(o), + (e) => errors.push(e), + ); + resolver.resolve(); + await flushPromises(); + + const expectedError = new FailToGetOpenIdToken(new Error("no openid")); + expect(observations).toStrictEqual([null]); + expect(errors).toStrictEqual([expectedError]); + expect(() => localTransport$.value).toThrow(expectedError); + }); + + it("emits preferred transport after OpenID resolves", async () => { + // Use config so transport discovery succeeds, but delay OpenID JWT fetch + mockConfig({ + livekit: { livekit_service_url: "https://lk.example.org" }, + }); + + const openIdResolver = Promise.withResolvers(); + + vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue( + openIdResolver.promise, + ); + + const localTransport$ = createLocalTransport$({ + scope, + roomId: "!room:example.org", + useOldestMember$: constant(false), + memberships$: constant(new Epoch([])), + client: { + getDomain: () => "", + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + }, + }); + + openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" }); + expect(localTransport$.value).toBe(null); + await flushPromises(); + // final + expect(localTransport$.value).toStrictEqual({ + livekit_alias: "!room:example.org", + livekit_service_url: "https://lk.example.org", + type: "livekit", + }); + }); +}); diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 6bb31e57..bd5ae92f 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -21,24 +21,21 @@ import { type Behavior } from "../../Behavior.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { Config } from "../../../config/Config.ts"; import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts"; -import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts"; +import { + getSFUConfigWithOpenID, + type OpenIDClientParts, +} from "../../../livekit/openIDSFU.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; /* - * - get well known - * - get oldest membership - * - get transport to use - * - get openId + jwt token - * - wait for createTrack() call - * - create tracks - * - wait for join() call - * - Publisher.publishTracks() - * - send join state/sticky event + * It figures out “which LiveKit focus URL/alias the local user should use,” + * optionally aligning with the oldest member, and ensures the SFU path is primed + * before advertising that choice. */ interface Props { scope: ObservableScope; memberships$: Behavior>; - client: MatrixClient; + client: Pick & OpenIDClientParts; roomId: string; useOldestMember$: Behavior; } @@ -49,6 +46,8 @@ interface Props { * * @prop useOldestMember Whether to use the same transport as the oldest member. * This will only update once the first oldest member appears. Will not recompute if the oldest member leaves. + * + * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken */ export const createLocalTransport$ = ({ scope, @@ -75,6 +74,8 @@ export const createLocalTransport$ = ({ /** * The transport that we would personally prefer to publish on (if not for the * transport preferences of others, perhaps). + * + * @throws */ const preferredTransport$: Behavior = scope.behavior( from(makeTransport(client, roomId)), @@ -103,10 +104,18 @@ export const createLocalTransport$ = ({ const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; -async function makeTransportInternal( - client: MatrixClient, +/** + * + * @param client + * @param roomId + * @returns + * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken + */ +async function makeTransport( + client: Pick & OpenIDClientParts, roomId: string, ): Promise { + let transport: LivekitTransport | undefined; logger.log("Searching for a preferred transport"); //TODO refactor this to use the jwt service returned alias. const livekitAlias = roomId; @@ -124,7 +133,7 @@ async function makeTransportInternal( "Using LiveKit transport from local storage: ", transportFromStorage, ); - return transportFromStorage; + transport = transportFromStorage; } // Prioritize the .well-known/matrix/client, if available, over the configured SFU @@ -136,12 +145,11 @@ async function makeTransportInternal( FOCI_WK_KEY ]; if (Array.isArray(wellKnownFoci)) { - const transport: LivekitTransportConfig | undefined = wellKnownFoci.find( - (f) => f && isLivekitTransportConfig(f), - ); - if (transport !== undefined) { + const wellKnownTransport: LivekitTransportConfig | undefined = + wellKnownFoci.find((f) => f && isLivekitTransportConfig(f)); + if (wellKnownTransport !== undefined) { logger.log("Using LiveKit transport from .well-known: ", transport); - return { ...transport, livekit_alias: livekitAlias }; + transport = { ...wellKnownTransport, livekit_alias: livekitAlias }; } } } @@ -154,26 +162,15 @@ async function makeTransportInternal( livekit_alias: livekitAlias, }; logger.log("Using LiveKit transport from config: ", transportFromConf); - return transportFromConf; + transport = transportFromConf; } + if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. - throw new MatrixRTCTransportMissingError(domain ?? ""); -} + await getSFUConfigWithOpenID( + client, + transport.livekit_service_url, + transport.livekit_alias, + ); -async function makeTransport( - client: MatrixClient, - roomId: string, -): Promise { - const transport = await makeTransportInternal(client, roomId); - // this will call the jwt/sfu/get endpoint to pre create the livekit room. - try { - await getSFUConfigWithOpenID( - client, - transport.livekit_service_url, - transport.livekit_alias, - ); - } catch (e) { - logger.warn(`Failed to get SFU config for transport: ${e}`); - } return transport; } diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 9f448cd9..f58fcb76 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -23,6 +23,7 @@ import type { Behavior } from "../../Behavior.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import { defaultLiveKitOptions } from "../../../livekit/options.ts"; +// TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { createConnection( transport: LivekitTransport, diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts index 5887442c..484a44e7 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts @@ -11,7 +11,7 @@ import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type Participant as LivekitParticipant } from "livekit-client"; import { logger } from "matrix-js-sdk/lib/logger"; -import { Epoch, ObservableScope } from "../../ObservableScope.ts"; +import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { createConnectionManager$, type ConnectionManagerData, @@ -73,7 +73,7 @@ afterEach(() => { describe("connections$ stream", () => { test("Should create and start new connections for each transports", () => { withTestScheduler(({ behavior, expectObservable }) => { - const { connections$ } = createConnectionManager$({ + const { connectionManagerData$ } = createConnectionManager$({ scope: testScope, connectionFactory: fakeConnectionFactory, inputTransports$: behavior("a", { @@ -82,7 +82,9 @@ describe("connections$ stream", () => { logger: logger, }); - expectObservable(connections$).toBe("a", { + expectObservable( + connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), + ).toBe("a", { a: expect.toSatisfy((e: Epoch) => { const connections = e.value; expect(connections.length).toBe(2); @@ -110,7 +112,7 @@ describe("connections$ stream", () => { test("Should start connection only once", () => { withTestScheduler(({ behavior, expectObservable }) => { - const { connections$ } = createConnectionManager$({ + const { connectionManagerData$ } = createConnectionManager$({ scope: testScope, connectionFactory: fakeConnectionFactory, inputTransports$: behavior("abcdef", { @@ -124,7 +126,9 @@ describe("connections$ stream", () => { logger: logger, }); - expectObservable(connections$).toBe("xxxxxa", { + expectObservable( + connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), + ).toBe("xxxxxa", { x: expect.anything(), a: expect.toSatisfy((e: Epoch) => { const connections = e.value; @@ -153,7 +157,7 @@ describe("connections$ stream", () => { test("Should cleanup connections when not needed anymore", () => { withTestScheduler(({ behavior, expectObservable }) => { - const { connections$ } = createConnectionManager$({ + const { connectionManagerData$ } = createConnectionManager$({ scope: testScope, connectionFactory: fakeConnectionFactory, inputTransports$: behavior("abc", { @@ -164,7 +168,9 @@ describe("connections$ stream", () => { logger: logger, }); - expectObservable(connections$).toBe("xab", { + expectObservable( + connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), + ).toBe("xab", { x: expect.anything(), a: expect.toSatisfy((e: Epoch) => { const connections = e.value; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index f284c9e3..d9a0380e 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -94,7 +94,6 @@ interface Props { export interface IConnectionManager { transports$: Behavior>; connectionManagerData$: Behavior>; - connections$: Behavior>; } /** * Crete a `ConnectionManager` @@ -217,7 +216,7 @@ export function createConnectionManager$({ new Epoch(new ConnectionManagerData()), ); - return { transports$, connectionManagerData$, connections$ }; + return { transports$, connectionManagerData$ }; } function removeDuplicateTransports( diff --git a/src/state/MuteStates.ts b/src/state/MuteStates.ts index 50be5e05..0d8e2e43 100644 --- a/src/state/MuteStates.ts +++ b/src/state/MuteStates.ts @@ -157,6 +157,7 @@ export class MuteStates { private readonly mediaDevices: MediaDevices, private readonly joined$: Observable, ) { + logger.log("widget", widget); if (widget !== null) { // Sync our mute states with the hosting client const widgetApiState$ = combineLatest( diff --git a/src/utils/test.ts b/src/utils/test.ts index 4fec433c..5fda90e8 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -80,6 +80,7 @@ export async function flushPromises(): Promise { } export interface OurRunHelpers extends RunHelpers { + scheduler: TestScheduler; /** * Schedules a sequence of actions to happen, as described by a marble * diagram. @@ -123,6 +124,7 @@ export function withTestScheduler( continuation({ ...helpers, scope, + scheduler, schedule(marbles, actions) { const actionsObservable$ = helpers .cold(marbles) From 89a1bfac2d578d8552b0655cca5d052f2b25a0e5 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 20 Nov 2025 15:25:53 +0100 Subject: [PATCH 02/11] remove unused test helper --- src/utils/test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/test.ts b/src/utils/test.ts index 5fda90e8..4fec433c 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -80,7 +80,6 @@ export async function flushPromises(): Promise { } export interface OurRunHelpers extends RunHelpers { - scheduler: TestScheduler; /** * Schedules a sequence of actions to happen, as described by a marble * diagram. @@ -124,7 +123,6 @@ export function withTestScheduler( continuation({ ...helpers, scope, - scheduler, schedule(marbles, actions) { const actionsObservable$ = helpers .cold(marbles) From a69a50d30c340f474f0eed36ec12bcb919867a83 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 20 Nov 2025 16:32:40 +0100 Subject: [PATCH 03/11] add log to error boudnary --- src/room/GroupCallErrorBoundary.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 72958683..995cf770 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -33,6 +33,7 @@ import { import { FullScreenView } from "../FullScreenView.tsx"; import { ErrorView } from "../ErrorView.tsx"; import { type WidgetHelpers } from "../widget.ts"; +import { logger } from "matrix-js-sdk/lib/logger"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; @@ -53,7 +54,7 @@ const ErrorPage: FC = ({ widget, }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); - + logger.log("Error boundary caught:", error); let icon: ComponentType>; switch (error.category) { case ErrorCategory.CONFIGURATION_ISSUE: From a29f0162ad0229e8206ab6c2f931f71b005be925 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 20 Nov 2025 16:34:53 +0100 Subject: [PATCH 04/11] remove widget log --- src/state/MuteStates.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/state/MuteStates.ts b/src/state/MuteStates.ts index 0d8e2e43..50be5e05 100644 --- a/src/state/MuteStates.ts +++ b/src/state/MuteStates.ts @@ -157,7 +157,6 @@ export class MuteStates { private readonly mediaDevices: MediaDevices, private readonly joined$: Observable, ) { - logger.log("widget", widget); if (widget !== null) { // Sync our mute states with the hosting client const widgetApiState$ = combineLatest( From d34775fc474cfcc511c21c4511a1f13f32ade3bc Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 20 Nov 2025 16:40:06 +0100 Subject: [PATCH 05/11] fix linter --- src/room/GroupCallErrorBoundary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 995cf770..98c2aefb 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -22,6 +22,7 @@ import { WebBrowserIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Button } from "@vector-im/compound-web"; +import { logger } from "matrix-js-sdk/lib/logger"; import { ConnectionLostError, @@ -33,7 +34,6 @@ import { import { FullScreenView } from "../FullScreenView.tsx"; import { ErrorView } from "../ErrorView.tsx"; import { type WidgetHelpers } from "../widget.ts"; -import { logger } from "matrix-js-sdk/lib/logger"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; From 4099c4383dab3e1e2c17302f1ababcd949726cf2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 21 Nov 2025 13:04:28 +0100 Subject: [PATCH 06/11] move HomeserverConnected --- src/config/ConfigOptions.ts | 2 +- src/livekit/openIDSFU.ts | 6 +- src/room/GroupCallErrorBoundary.tsx | 2 +- src/state/CallViewModel/CallViewModel.ts | 14 +- .../localMember/HomeserverConnected.test.ts | 202 ++++++++++++++++++ .../localMember/HomeserverConnected.ts | 85 ++++++++ .../localMember/LocalMembership.test.ts | 10 - .../localMember/LocalMembership.ts | 62 +----- src/utils/test.ts | 7 + 9 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 src/state/CallViewModel/localMember/HomeserverConnected.test.ts create mode 100644 src/state/CallViewModel/localMember/HomeserverConnected.ts diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 40b2342b..c587fa50 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -122,7 +122,7 @@ export interface ConfigOptions { delayed_leave_event_delay_ms?: number; /** - * The time (in milliseconds) after which a we consider a delayed event restart http request to have failed. + * The time (in milliseconds) after which we consider a delayed event restart http request to have failed. * Setting this to a lower value will result in more frequent retries but also a higher chance of failiour. * * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index c9c26c83..3ae003fb 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -22,11 +22,13 @@ export type OpenIDClientParts = Pick< "getOpenIdToken" | "getDeviceId" >; /** - * + * Gets a bearer token from the homeserver and then use it to authenticate + * to the matrix RTC backend in order to get acces to the SFU. + * It has built-in retry for calls to the homeserver with a backoff policy. * @param client * @param serviceUrl * @param matrixRoomId - * @returns + * @returns Object containing the token information * @throws FailToGetOpenIdToken */ export async function getSFUConfigWithOpenID( diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 98c2aefb..ca407ed4 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -54,7 +54,7 @@ const ErrorPage: FC = ({ widget, }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); - logger.log("Error boundary caught:", error); + logger.error("Error boundary caught:", error); let icon: ComponentType>; switch (error.category) { case ErrorCategory.CONFIGURATION_ISSUE: diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index f8deddc3..506eca1b 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -97,8 +97,8 @@ import { } from "../layout-types.ts"; import { type ElementCallError } from "../../utils/errors.ts"; import { type ObservableScope } from "../ObservableScope.ts"; +import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { - createHomeserverConnected$, createLocalMembership$, enterRTCSession, LivekitState, @@ -365,9 +365,9 @@ export function createCallViewModel$( reactionsSubject$: Observable>, trackProcessorState$: Behavior, ): CallViewModel { - const userId = matrixRoom.client.getUserId()!; - const deviceId = matrixRoom.client.getDeviceId()!; - + const client = matrixRoom.client; + const userId = client.getUserId()!; + const deviceId = client.getDeviceId()!; const livekitKeyProvider = getE2eeKeyProvider( options.encryptionSystem, matrixRTCSession, @@ -401,7 +401,7 @@ export function createCallViewModel$( const localTransport$ = createLocalTransport$({ scope: scope, memberships$: memberships$, - client: matrixRoom.client, + client, roomId: matrixRoom.roomId, useOldestMember$: scope.behavior( matrixRTCMode.value$.pipe(map((v) => v === MatrixRTCMode.Legacy)), @@ -409,7 +409,7 @@ export function createCallViewModel$( }); const connectionFactory = new ECConnectionFactory( - matrixRoom.client, + client, mediaDevices, trackProcessorState$, livekitKeyProvider, @@ -456,7 +456,7 @@ export function createCallViewModel$( scope: scope, homeserverConnected$: createHomeserverConnected$( scope, - matrixRoom, + client, matrixRTCSession, ), muteStates: muteStates, diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts new file mode 100644 index 00000000..1f61e533 --- /dev/null +++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts @@ -0,0 +1,202 @@ +/* +Copyright 2025 Element Creations Ltd. +Copyright 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 { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "events"; +import { ClientEvent, SyncState } from "matrix-js-sdk"; +import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc"; + +import { ObservableScope } from "../../ObservableScope"; +import { createHomeserverConnected$ } from "./HomeserverConnected"; + +/** + * Minimal stub of a Matrix client sufficient for our tests: + ``` + createHomeserverConnected$( + scope: ObservableScope, + client: NodeStyleEventEmitter & Pick, + matrixRTCSession: NodeStyleEventEmitter & + Pick, + ) + ``` + */ +class MockMatrixClient extends EventEmitter { + private syncState: SyncState; + public constructor(initial: SyncState) { + super(); + this.syncState = initial; + } + public setSyncState(state: SyncState): void { + this.syncState = state; + // Matrix's Sync event in createHomeserverConnected$ expects [SyncState] + this.emit(ClientEvent.Sync, [state]); + } + public getSyncState(): SyncState { + return this.syncState; + } +} + +/** + * Minimal stub of MatrixRTCSession (membership manager): + ``` + createHomeserverConnected$( + scope: ObservableScope, + client: NodeStyleEventEmitter & Pick, + matrixRTCSession: NodeStyleEventEmitter & + Pick, + ) + ``` + */ +class MockMatrixRTCSession extends EventEmitter { + public membershipStatus: Status; + public probablyLeft: boolean; + + public constructor(props: { + membershipStatus: Status; + probablyLeft: boolean; + }) { + super(); + this.membershipStatus = props.membershipStatus; + this.probablyLeft = props.probablyLeft; + } + + public setMembershipStatus(status: Status): void { + this.membershipStatus = status; + this.emit(MembershipManagerEvent.StatusChanged); + } + + public setProbablyLeft(flag: boolean): void { + this.probablyLeft = flag; + this.emit(MembershipManagerEvent.ProbablyLeft); + } +} + +describe("createHomeserverConnected$", () => { + let scope: ObservableScope; + let client: MockMatrixClient; + let session: MockMatrixRTCSession; + + beforeEach(() => { + scope = new ObservableScope(); + client = new MockMatrixClient(SyncState.Error); // start disconnected + session = new MockMatrixRTCSession({ + membershipStatus: Status.Disconnected, + probablyLeft: false, + }); + }); + + afterEach(() => { + scope.end(); + }); + + // LLM generated test cases. They are a bit overkill but I improved the mocking so it is + // easy enough to read them so I think they can stay. + it("is false when sync state is not Syncing", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + expect(hsConnected$.value).toBe(false); + }); + + it("remains false while membership status is not Connected even if sync is Syncing", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + client.setSyncState(SyncState.Syncing); + expect(hsConnected$.value).toBe(false); // membership still disconnected + }); + + it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + // Make sync loop OK + client.setSyncState(SyncState.Syncing); + // Indicate probable leave before connection + session.setProbablyLeft(true); + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(false); + }); + + it("becomes true only when all three conditions are satisfied", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + // 1. Sync loop connected + client.setSyncState(SyncState.Syncing); + expect(hsConnected$.value).toBe(false); // not yet membership connected + // 2. Membership connected + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); // probablyLeft is false + }); + + it("drops back to false when sync loop leaves Syncing", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + // Reach connected state + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); + + // Sync loop error => should flip false + client.setSyncState(SyncState.Error); + expect(hsConnected$.value).toBe(false); + }); + + it("drops back to false when membership status becomes disconnected", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); + + session.setMembershipStatus(Status.Disconnected); + expect(hsConnected$.value).toBe(false); + }); + + it("drops to false when ProbablyLeft is emitted after being true", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); + + session.setProbablyLeft(true); + expect(hsConnected$.value).toBe(false); + }); + + it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); + + session.setProbablyLeft(true); + expect(hsConnected$.value).toBe(false); + + // Simulate clearing the flag (in realistic scenario membership manager would update) + session.setProbablyLeft(false); + expect(hsConnected$.value).toBe(true); + }); + + it("composite sequence reflects each individual failure reason", () => { + const hsConnected$ = createHomeserverConnected$(scope, client, session); + + // Initially false (sync error + disconnected + not probably left) + expect(hsConnected$.value).toBe(false); + + // Fix sync only + client.setSyncState(SyncState.Syncing); + expect(hsConnected$.value).toBe(false); + + // Fix membership + session.setMembershipStatus(Status.Connected); + expect(hsConnected$.value).toBe(true); + + // Introduce probablyLeft -> false + session.setProbablyLeft(true); + expect(hsConnected$.value).toBe(false); + + // Restore notProbablyLeft -> true again + session.setProbablyLeft(false); + expect(hsConnected$.value).toBe(true); + + // Drop sync -> false + client.setSyncState(SyncState.Error); + expect(hsConnected$.value).toBe(false); + }); +}); diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts new file mode 100644 index 00000000..e1c28078 --- /dev/null +++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts @@ -0,0 +1,85 @@ +/* +Copyright 2025 Element Creations Ltd. +Copyright 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 { + MembershipManagerEvent, + Status, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; +import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk"; +import { fromEvent, startWith, map, tap, type Observable } from "rxjs"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; + +import { type ObservableScope } from "../../ObservableScope"; +import { type Behavior } from "../../Behavior"; +import { and$ } from "../../../utils/observable"; +import { type NodeStyleEventEmitter } from "../../../utils/test"; + +/** + * Logger instance (scoped child) for homeserver connection updates. + */ +const logger = rootLogger.getChild("[HomeserverConnected]"); + +/** + * Behavior representing whether we consider ourselves connected to the Matrix homeserver + * for the purposes of a MatrixRTC session. + * + * Becomes FALSE if ANY sub-condition is fulfilled: + * 1. Sync loop is not in SyncState.Syncing + * 2. membershipStatus !== Status.Connected + * 3. probablyLeft === true + */ +export function createHomeserverConnected$( + scope: ObservableScope, + client: NodeStyleEventEmitter & Pick, + matrixRTCSession: NodeStyleEventEmitter & + Pick, +): Behavior { + const syncing$ = ( + fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> + ).pipe( + startWith([client.getSyncState()]), + map(([state]) => state === SyncState.Syncing), + ); + + const membershipConnected$ = fromEvent( + matrixRTCSession, + MembershipManagerEvent.StatusChanged, + ).pipe( + startWith(null), + map(() => matrixRTCSession.membershipStatus === Status.Connected), + ); + + // This is basically notProbablyLeft$ + // + // probablyLeft is computed by a local timer that mimics the server delayed event. + // If we locally predict our server event timed out. We consider ourselves as probablyLeft + // even though we might not yet have received the delayed event leave. + // + // If that is not the case we certainly still have a valid membership on the matrix network + // independet if the sync currently works. + const certainlyConnected$ = fromEvent( + matrixRTCSession, + MembershipManagerEvent.ProbablyLeft, + ).pipe( + startWith(null), + map(() => matrixRTCSession.probablyLeft !== true), + ); + + const connectedCombined$ = and$( + syncing$, + membershipConnected$, + certainlyConnected$, + ).pipe( + tap((connected) => { + logger.info(`Homeserver connected update: ${connected}`); + }), + ); + + return scope.behavior(connectedCombined$); +} diff --git a/src/state/CallViewModel/localMember/LocalMembership.test.ts b/src/state/CallViewModel/localMember/LocalMembership.test.ts index 390fa1a8..9459d419 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.test.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.test.ts @@ -36,16 +36,6 @@ const MATRIX_RTC_MODE = MatrixRTCMode.Legacy; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../../../UrlParams", () => ({ getUrlParams })); -// vi.mock("../../../widget", async (importOriginal) => ({ -// ...(await importOriginal()), -// widget: { -// api: { -// setAlwaysOnScreen: (): void => {}, -// transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() }, -// }, -// lazyActions: new EventEmitter(), -// }, -// })); describe("LocalMembership", () => { describe("enterRTCSession", () => { it("It joins the correct Session", async () => { diff --git a/src/state/CallViewModel/localMember/LocalMembership.ts b/src/state/CallViewModel/localMember/LocalMembership.ts index 01267764..948bc5ad 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.ts @@ -13,8 +13,6 @@ import { } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { - MembershipManagerEvent, - Status, type LivekitTransport, type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; @@ -23,17 +21,14 @@ import { catchError, combineLatest, distinctUntilChanged, - fromEvent, map, type Observable, of, scan, - startWith, switchMap, tap, } from "rxjs"; -import { logger, type Logger } from "matrix-js-sdk/lib/logger"; -import { ClientEvent, type Room, SyncState } from "matrix-js-sdk"; +import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; @@ -185,11 +180,17 @@ export const createLocalMembership$ = ({ const localTransport$ = scope.behavior( localTransportCanThrow$.pipe( catchError((e: unknown) => { + let error: ElementCallError; if (e instanceof ElementCallError) { - state.livekit$.next({ state: LivekitState.Error, error: e }); + error = e; } else { - logger.error("Unknown error from localTransport$", e); + error = new UnknownCallError( + e instanceof Error + ? e + : new Error("Unknown error from localTransport"), + ); } + state.livekit$.next({ state: LivekitState.Error, error }); return of(null); }), ), @@ -238,7 +239,7 @@ export const createLocalMembership$ = ({ localConnection$.pipe(scope.bind()).subscribe((connection) => { if (connection !== null && publisher$.value === null) { // TODO looks strange to not change publisher if connection changes. - // @valere will take care of this! + // @toger5 will take care of this! publisher$.next(createPublisherFactory(connection)); } }); @@ -492,7 +493,7 @@ export const createLocalMembership$ = ({ const sharingScreen$ = scope.behavior( localConnection$.pipe( switchMap((c) => - c !== null && c.livekitRoom + c !== null ? observeSharingScreen$(c.livekitRoom.localParticipant) : of(false), ), @@ -615,44 +616,3 @@ export async function enterRTCSession( await widget.api.transport.send(ElementWidgetActions.JoinCall, {}); } } - -/** - * Whether we are connected to the MatrixRTC session. - */ -export function createHomeserverConnected$( - scope: ObservableScope, - matrixRoom: Room, - matrixRTCSession: MatrixRTCSession, -): Behavior { - return scope.behavior( - // To consider ourselves connected to MatrixRTC, we check the following: - and$( - // The client is connected to the sync loop - ( - fromEvent(matrixRoom.client, ClientEvent.Sync) as Observable< - [SyncState] - > - ).pipe( - startWith([matrixRoom.client.getSyncState()]), - map(([state]) => state === SyncState.Syncing), - ), - // Room state observed by session says we're connected - fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe( - startWith(null), - map(() => matrixRTCSession.membershipStatus === Status.Connected), - ), - // Also watch out for warnings that we've likely hit a timeout and our - // delayed leave event is being sent (this condition is here because it - // provides an earlier warning than the sync loop timeout, and we wouldn't - // see the actual leave event until we reconnect to the sync loop) - fromEvent(matrixRTCSession, MembershipManagerEvent.ProbablyLeft).pipe( - startWith(null), - map(() => matrixRTCSession.probablyLeft !== true), - ), - ).pipe( - tap((connected) => { - logger.info(`Homeserver connected update: ${connected}`); - }), - ), - ); -} diff --git a/src/utils/test.ts b/src/utils/test.ts index 4fec433c..471d35d8 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -79,6 +79,13 @@ export async function flushPromises(): Promise { await new Promise((resolve) => window.setTimeout(resolve)); } +export type NodeEventHandler = (...args: unknown[]) => void; + +export interface NodeStyleEventEmitter { + addListener(eventName: string | symbol, handler: NodeEventHandler): this; + removeListener(eventName: string | symbol, handler: NodeEventHandler): this; +} + export interface OurRunHelpers extends RunHelpers { /** * Schedules a sequence of actions to happen, as described by a marble From a731981388f387ad9f2f4522427ae8740c8b61f2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 21 Nov 2025 14:02:15 +0100 Subject: [PATCH 07/11] Cleanup transport creation and local store hack. --- locales/en/app.json | 2 + src/settings/DeveloperSettingsTab.tsx | 52 ++++++++++++++++++- src/settings/settings.ts | 5 ++ .../localMember/LocalTransport.ts | 52 ++++++++++++------- 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index ff26fbe3..8bc121bd 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -64,6 +64,7 @@ "developer_mode": { "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "crypto_version": "Crypto version: {{version}}", + "custom_livekit_url": "Custom Livekit-url", "debug_tile_layout_label": "Debug tile layout", "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", @@ -89,6 +90,7 @@ }, "mute_all_audio": "Mute all audio (participants, reactions, join sounds)", "show_connection_stats": "Show connection statistics", + "update": "Update", "url_params": "URL parameters" }, "disconnected_banner": "Connectivity to the server has been lost.", diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 60d9028d..04437d9e 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -11,8 +11,8 @@ import { useCallback, useEffect, useMemo, - useState, useId, + useState, } from "react"; import { useTranslation } from "react-i18next"; import { @@ -21,6 +21,7 @@ import { } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { + Button, Root as Form, Heading, HelpMessage, @@ -38,6 +39,7 @@ import { muteAllAudio as muteAllAudioSetting, alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, matrixRTCMode as matrixRTCModeSetting, + customLivekitUrl as customLivekitUrlSetting, MatrixRTCMode, } from "./settings"; import type { Room as LivekitRoom } from "livekit-client"; @@ -85,6 +87,12 @@ export const DeveloperSettingsTab: FC = ({ client, livekitRooms }) => { alwaysShowIphoneEarpieceSetting, ); + const [customLivekitUrl, setCustomLivekitUrl] = useSetting( + customLivekitUrlSetting, + ); + const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] = + useState(""); + const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); const urlParams = useUrlParams(); @@ -200,6 +208,48 @@ export const DeveloperSettingsTab: FC = ({ client, livekitRooms }) => { )} />{" "} + {/*// TODO this feels a bit off... There has to be better way to create the desired look. + Also the indent should be further to the left...*/} + } + > + + + {customLivekitUrl === null + ? "Use Default" + : `Current:${customLivekitUrl}`} + + + + ): void => { + setCustomLivekitUrlTextBuffer(event.target.value); + }, + [setCustomLivekitUrlTextBuffer], + )} + /> + + {t("developer_mode.matrixRTCMode.title")} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 5309ecf8..f85e1414 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -134,3 +134,8 @@ export const matrixRTCMode = new Setting( "matrix-rtc-mode", MatrixRTCMode.Legacy, ); + +export const customLivekitUrl = new Setting( + "custom-livekit-url", + null, +); diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index bd5ae92f..0a85bbc1 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -13,8 +13,15 @@ import { isLivekitTransportConfig, } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixClient } from "matrix-js-sdk"; -import { combineLatest, distinctUntilChanged, first, from, map } from "rxjs"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { + combineLatest, + distinctUntilChanged, + first, + from, + map, + switchMap, +} from "rxjs"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { type Behavior } from "../../Behavior.ts"; @@ -26,6 +33,9 @@ import { type OpenIDClientParts, } from "../../../livekit/openIDSFU.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; +import { customLivekitUrl } from "../../../settings/settings.ts"; + +const logger = rootLogger.getChild("[LocalTransport]"); /* * It figures out “which LiveKit focus URL/alias the local user should use,” @@ -78,14 +88,16 @@ export const createLocalTransport$ = ({ * @throws */ const preferredTransport$: Behavior = scope.behavior( - from(makeTransport(client, roomId)), + customLivekitUrl.value$.pipe( + switchMap((customUrl) => from(makeTransport(client, roomId, customUrl))), + ), null, ); /** - * The transport we should advertise in our MatrixRTC membership. + * The chosen transport we should advertise in our MatrixRTC membership. */ - const advertisedTransport$ = scope.behavior( + return scope.behavior( combineLatest([ useOldestMember$, oldestMemberTransport$, @@ -99,7 +111,6 @@ export const createLocalTransport$ = ({ distinctUntilChanged(areLivekitTransportsEqual), ), ); - return advertisedTransport$; }; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; @@ -114,31 +125,30 @@ const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; async function makeTransport( client: Pick & OpenIDClientParts, roomId: string, + urlFromDevSettings: string | null, ): Promise { let transport: LivekitTransport | undefined; - logger.log("Searching for a preferred transport"); + logger.trace("Searching for a preferred transport"); //TODO refactor this to use the jwt service returned alias. const livekitAlias = roomId; - // TODO-MULTI-SFU: Either remove this dev tool or make it more official - const urlFromStorage = - localStorage.getItem("robin-matrixrtc-auth") ?? - localStorage.getItem("timo-focus-url"); - if (urlFromStorage !== null) { + + // DEVTOOL: Highest priority: Load from devtool setting + if (urlFromDevSettings !== null) { const transportFromStorage: LivekitTransport = { type: "livekit", - livekit_service_url: urlFromStorage, + livekit_service_url: urlFromDevSettings, livekit_alias: livekitAlias, }; - logger.log( - "Using LiveKit transport from local storage: ", + logger.info( + "Using LiveKit transport from dev tools: ", transportFromStorage, ); transport = transportFromStorage; } - // Prioritize the .well-known/matrix/client, if available, over the configured SFU + // WELL_KNOWN: Prioritize the .well-known/matrix/client, if available, over the configured SFU const domain = client.getDomain(); - if (domain) { + if (domain && transport === undefined) { // we use AutoDiscovery instead of relying on the MatrixClient having already // been fully configured and started const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ @@ -148,22 +158,24 @@ async function makeTransport( const wellKnownTransport: LivekitTransportConfig | undefined = wellKnownFoci.find((f) => f && isLivekitTransportConfig(f)); if (wellKnownTransport !== undefined) { - logger.log("Using LiveKit transport from .well-known: ", transport); + logger.info("Using LiveKit transport from .well-known: ", transport); transport = { ...wellKnownTransport, livekit_alias: livekitAlias }; } } } + // CONFIG: Least prioritized; Load from config file const urlFromConf = Config.get().livekit?.livekit_service_url; - if (urlFromConf) { + if (urlFromConf && transport === undefined) { const transportFromConf: LivekitTransport = { type: "livekit", livekit_service_url: urlFromConf, livekit_alias: livekitAlias, }; - logger.log("Using LiveKit transport from config: ", transportFromConf); + logger.info("Using LiveKit transport from config: ", transportFromConf); transport = transportFromConf; } + if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. await getSFUConfigWithOpenID( From e60bc9e98f68e295dec07619d4b380629c2fa630 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 21 Nov 2025 17:31:03 +0100 Subject: [PATCH 08/11] add developer tab snapshot test --- src/settings/DeveloperSettingsTab.test.tsx | 95 ++++ src/settings/DeveloperSettingsTab.tsx | 9 +- src/settings/SettingsModal.tsx | 6 +- .../DeveloperSettingsTab.test.tsx.snap | 430 ++++++++++++++++++ 4 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 src/settings/DeveloperSettingsTab.test.tsx create mode 100644 src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx new file mode 100644 index 00000000..c18cf23b --- /dev/null +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -0,0 +1,95 @@ +/* +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 { describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +import type { MatrixClient } from "matrix-js-sdk"; +import type { Room as LivekitRoom } from "livekit-client"; +import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; + +// Mock url params hook to avoid environment-dependent snapshot churn. +vi.mock("../UrlParams", () => ({ + useUrlParams: (): { mocked: boolean; answer: number } => ({ + mocked: true, + answer: 42, + }), +})); + +// Provide a minimal mock of a Livekit Room structure used by the component. +function createMockLivekitRoom( + wsUrl: string, + serverInfo: object, + metadata: string, +): { isLocal: boolean; url: string; room: LivekitRoom } { + const mockRoom = { + serverInfo, + metadata, + engine: { client: { ws: { url: wsUrl } } }, + } as unknown as LivekitRoom; + + return { + isLocal: true, + url: wsUrl, + room: mockRoom, + }; +} + +// Minimal MatrixClient mock with only the methods used by the component. +function createMockMatrixClient(): MatrixClient { + return { + doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true), // ensure stickyEventsSupported eventually becomes true + getCrypto: (): { getVersion: () => string } | undefined => ({ + getVersion: () => "crypto-1.0.0", + }), + getUserId: () => "@alice:example.org", + getDeviceId: () => "DEVICE123", + } as unknown as MatrixClient; +} + +describe("DeveloperSettingsTab", () => { + it("renders and matches snapshot", async () => { + const client = createMockMatrixClient(); + + const livekitRooms: { + room: LivekitRoom; + url: string; + isLocal?: boolean; + }[] = [ + createMockLivekitRoom( + "wss://local-sfu.example.org", + { region: "local", version: "1.2.3" }, + "local-metadata", + ), + { + isLocal: false, + url: "wss://remote-sfu.example.org", + room: { + serverInfo: { region: "remote", version: "4.5.6" }, + metadata: "remote-metadata", + engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, + } as unknown as LivekitRoom, + }, + ]; + + const { container } = render( + , + ); + + // Wait for the async sticky events feature check to resolve so the final UI + // (e.g. enabled Matrix_2_0 radio button) appears deterministically. + await waitFor(() => + expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(), + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 04437d9e..5374b978 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -49,9 +49,14 @@ import { useUrlParams } from "../UrlParams"; interface Props { client: MatrixClient; livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[]; + env: ImportMetaEnv; } -export const DeveloperSettingsTab: FC = ({ client, livekitRooms }) => { +export const DeveloperSettingsTab: FC = ({ + client, + livekitRooms, + env, +}) => { const { t } = useTranslation(); const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting); const [debugTileLayout, setDebugTileLayout] = useSetting( @@ -320,7 +325,7 @@ export const DeveloperSettingsTab: FC = ({ client, livekitRooms }) => { ))}

{t("developer_mode.environment_variables")}

-
{JSON.stringify(import.meta.env, null, 2)}
+
{JSON.stringify(env, null, 2)}

{t("developer_mode.url_params")}

{JSON.stringify(urlParams, null, 2)}
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 9e581647..2b4078aa 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -209,7 +209,11 @@ export const SettingsModal: FC = ({ key: "developer", name: t("settings.developer_tab_title"), content: ( - + ), }; diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap new file mode 100644 index 00000000..ddaa9b7f --- /dev/null +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -0,0 +1,430 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` +
+
+

+ Hostname: localhost +

+

+ Element Call version: dev +

+

+ Crypto version: crypto-1.0.0 +

+

+ Matrix ID: @alice:example.org +

+

+ Device ID: DEVICE123 +

+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+
+
+ + + Use Default + +
+
+
+
+ + +
+ +
+

+ MatrixRTC mode +

+
+
+
+ +
+
+
+
+ + + Compatible with old versions of EC that do not support multi SFU + +
+
+
+
+
+ +
+
+
+
+ + + Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later) + +
+
+
+
+
+ +
+
+
+
+ + + Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later + +
+
+

+ LiveKit SFU: wss://local-sfu.example.org +

+

+ ws-url: + wss://local-sfu.example.org/ +

+

+ LiveKit Server Info + ( + local + ) +

+
+      {
+  "region": "local",
+  "version": "1.2.3"
+}
+      local-metadata
+    
+

+ LiveKit SFU: wss://remote-sfu.example.org +

+

+ LiveKit Server Info + ( + remote + ) +

+
+      {
+  "region": "remote",
+  "version": "4.5.6"
+}
+      remote-metadata
+    
+

+ Environment variables +

+
+      {
+  "MY_MOCK_ENV": 10,
+  "ENV": "test"
+}
+    
+

+ URL parameters +

+
+      {
+  "mocked": true,
+  "answer": 42
+}
+    
+ +
+`; From 7532f72c9094926cb3971680b437f76221fd955c Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 21 Nov 2025 18:49:30 +0100 Subject: [PATCH 09/11] use edit in place (WIP) This is WIP since it will submite -> reload the page onSave --- locales/en/app.json | 10 ++- src/settings/DeveloperSettingsTab.tsx | 83 +++++++++---------- .../DeveloperSettingsTab.test.tsx.snap | 76 +++++++---------- 3 files changed, 75 insertions(+), 94 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index 8bc121bd..4d3d5d6c 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -64,7 +64,14 @@ "developer_mode": { "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "crypto_version": "Crypto version: {{version}}", - "custom_livekit_url": "Custom Livekit-url", + "custom_livekit_url": { + "current_url": "Overwrite: ", + "from_config": "Currently, no overwrite is set. Url from well-known or config is used.", + "label": "Custom Livekit-url", + "reset": "Reset overwrite", + "save": "Save", + "saving": "Saving..." + }, "debug_tile_layout_label": "Debug tile layout", "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", @@ -90,7 +97,6 @@ }, "mute_all_audio": "Mute all audio (participants, reactions, join sounds)", "show_connection_stats": "Show connection statistics", - "update": "Update", "url_params": "URL parameters" }, "disconnected_banner": "Connectivity to the server has been lost.", diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 5374b978..ed022370 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -21,7 +21,7 @@ import { } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { - Button, + EditInPlace, Root as Form, Heading, HelpMessage, @@ -114,7 +114,7 @@ export const DeveloperSettingsTab: FC = ({ }, [livekitRooms]); return ( -
+ e.preventDefault()}>

{t("developer_mode.hostname", { hostname: window.location.hostname || "unknown", @@ -213,48 +213,43 @@ export const DeveloperSettingsTab: FC = ({ )} />{" "} - {/*// TODO this feels a bit off... There has to be better way to create the desired look. - Also the indent should be further to the left...*/} - } - > - - - {customLivekitUrl === null - ? "Use Default" - : `Current:${customLivekitUrl}`} - - - - ): void => { - setCustomLivekitUrlTextBuffer(event.target.value); - }, - [setCustomLivekitUrlTextBuffer], - )} - /> - - + e.preventDefault()} + helpLabel={ + customLivekitUrl === null + ? t("developer_mode.custom_livekit_url.from_config") + : t("developer_mode.custom_livekit_url.current_url") + + customLivekitUrl + } + label={t("developer_mode.custom_livekit_url.label")} + saveButtonLabel={t("developer_mode.custom_livekit_url.save")} + savingLabel={t("developer_mode.custom_livekit_url.saving")} + cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} + onSave={useCallback( + (e: React.FormEvent) => { + // e.preventDefault(); + setCustomLivekitUrl( + customLivekitUrlTextBuffer === "" + ? null + : customLivekitUrlTextBuffer, + ); + }, + [setCustomLivekitUrl, customLivekitUrlTextBuffer], + )} + onChange={useCallback( + (event: ChangeEvent): void => { + setCustomLivekitUrlTextBuffer(event.target.value); + }, + [setCustomLivekitUrlTextBuffer], + )} + onCancel={useCallback( + (e: React.FormEvent) => { + // e.preventDefault(); + setCustomLivekitUrl(null); + }, + [setCustomLivekitUrl], + )} + /> {t("developer_mode.matrixRTCMode.title")} diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index ddaa9b7f..ca861eb6 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -190,14 +190,11 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

-
-
+
+ +
- Use Default + Currently, no overwrite is set. Url from well-known or config is used.
-
-
-
- - -
- -
+

@@ -256,10 +236,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1e0uz_10" > renders and matches snapshot 1`] = ` > Compatible with old versions of EC that do not support multi SFU @@ -297,9 +277,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1e0uz_10" > renders and matches snapshot 1`] = ` > Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later) @@ -337,9 +317,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1e0uz_10" > renders and matches snapshot 1`] = ` > Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later From ddd015d696e51cea1fbdbec1c5b1aae867a09564 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 24 Nov 2025 10:04:01 +0100 Subject: [PATCH 10/11] fix dev EditInPlace --- locales/en/app.json | 2 +- src/settings/DeveloperSettingsTab.tsx | 106 ++-- .../DeveloperSettingsTab.test.tsx.snap | 519 +++++++++--------- 3 files changed, 316 insertions(+), 311 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index 4d3d5d6c..9e8fbbd3 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -65,7 +65,7 @@ "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "crypto_version": "Crypto version: {{version}}", "custom_livekit_url": { - "current_url": "Overwrite: ", + "current_url": "Currently set to: ", "from_config": "Currently, no overwrite is set. Url from well-known or config is used.", "label": "Custom Livekit-url", "reset": "Reset overwrite", diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index ed022370..254aaf0f 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -96,7 +96,10 @@ export const DeveloperSettingsTab: FC = ({ customLivekitUrlSetting, ); const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] = - useState(""); + useState(customLivekitUrl); + useEffect(() => { + setCustomLivekitUrlTextBuffer(customLivekitUrl); + }, [customLivekitUrl]); const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); @@ -114,7 +117,7 @@ export const DeveloperSettingsTab: FC = ({ }, [livekitRooms]); return ( -
e.preventDefault()}> + <>

{t("developer_mode.hostname", { hostname: window.location.hostname || "unknown", @@ -227,7 +230,6 @@ export const DeveloperSettingsTab: FC = ({ cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} onSave={useCallback( (e: React.FormEvent) => { - // e.preventDefault(); setCustomLivekitUrl( customLivekitUrlTextBuffer === "" ? null @@ -236,6 +238,7 @@ export const DeveloperSettingsTab: FC = ({ }, [setCustomLivekitUrl, customLivekitUrlTextBuffer], )} + value={customLivekitUrlTextBuffer ?? ""} onChange={useCallback( (event: ChangeEvent): void => { setCustomLivekitUrlTextBuffer(event.target.value); @@ -244,7 +247,6 @@ export const DeveloperSettingsTab: FC = ({ )} onCancel={useCallback( (e: React.FormEvent) => { - // e.preventDefault(); setCustomLivekitUrl(null); }, [setCustomLivekitUrl], @@ -253,52 +255,54 @@ export const DeveloperSettingsTab: FC = ({ {t("developer_mode.matrixRTCMode.title")} - - } - > - - - {t("developer_mode.matrixRTCMode.Legacy.description")} - - - - } - > - - - {t("developer_mode.matrixRTCMode.Comptibility.description")} - - - - } - > - - - {t("developer_mode.matrixRTCMode.Matrix_2_0.description")} - - + + + } + > + + + {t("developer_mode.matrixRTCMode.Legacy.description")} + + + + } + > + + + {t("developer_mode.matrixRTCMode.Comptibility.description")} + + + + } + > + + + {t("developer_mode.matrixRTCMode.Matrix_2_0.description")} + + + {livekitRooms?.map((livekitRoom) => ( <>

@@ -323,6 +327,6 @@ export const DeveloperSettingsTab: FC = ({
{JSON.stringify(env, null, 2)}

{t("developer_mode.url_params")}

{JSON.stringify(urlParams, null, 2)}
- + ); }; diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index ca861eb6..ef3db126 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -2,230 +2,231 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+

+ Hostname: localhost +

+

+ Element Call version: dev +

+

+ Crypto version: crypto-1.0.0 +

+

+ Matrix ID: @alice:example.org +

+

+ Device ID: DEVICE123 +

+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ + +
+ +
-

- Hostname: localhost -

-

- Element Call version: dev -

-

- Crypto version: crypto-1.0.0 -

-

- Matrix ID: @alice:example.org -

-

- Device ID: DEVICE123 -

+
-
-
-
-
- - -
+ Currently, no overwrite is set. Url from well-known or config is used. +
-
-
- - -
-
-
-
- - -
-
- -
-
- - -
- -
- -
- -
- -
- - Currently, no overwrite is set. Url from well-known or config is used. - -
-
-

- MatrixRTC mode -

+ +

+ MatrixRTC mode +

+
@@ -347,64 +348,64 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
-

- LiveKit SFU: wss://local-sfu.example.org -

-

- ws-url: - wss://local-sfu.example.org/ -

-

- LiveKit Server Info - ( - local - ) -

-
-      {
+  
+  

+ LiveKit SFU: wss://local-sfu.example.org +

+

+ ws-url: + wss://local-sfu.example.org/ +

+

+ LiveKit Server Info + ( + local + ) +

+
+    {
   "region": "local",
   "version": "1.2.3"
 }
-      local-metadata
-    
-

- LiveKit SFU: wss://remote-sfu.example.org -

-

- LiveKit Server Info - ( - remote - ) -

-
-      {
+    local-metadata
+  
+

+ LiveKit SFU: wss://remote-sfu.example.org +

+

+ LiveKit Server Info + ( + remote + ) +

+
+    {
   "region": "remote",
   "version": "4.5.6"
 }
-      remote-metadata
-    
-

- Environment variables -

-
-      {
+    remote-metadata
+  
+

+ Environment variables +

+
+    {
   "MY_MOCK_ENV": 10,
   "ENV": "test"
 }
-    
-

- URL parameters -

-
-      {
+  
+

+ URL parameters +

+
+    {
   "mocked": true,
   "answer": 42
 }
-    
- +

`; From 294b90b6dc128682130d9ecf9348d7a49fc96230 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 24 Nov 2025 11:11:15 +0100 Subject: [PATCH 11/11] fix fixture following web change --- playwright/fixtures/widget-user.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index 8089c9de..433c960b 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -159,7 +159,11 @@ export const widgetTest = test.extend({ } = await registerUser(browser, userB); // Invite the second user - await ewPage1.getByRole("button", { name: "Add", exact: true }).click(); + await ewPage1 + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); await ewPage1.getByRole("button", { name: "Create room" }).click();