Merge branch 'livekit' into fkwp/elementregistry

This commit is contained in:
fkwp
2026-02-25 17:09:09 +01:00
committed by GitHub
31 changed files with 823 additions and 405 deletions

View File

@@ -33,8 +33,8 @@ jobs:
name: build-output-full name: build-output-full
path: dist path: dist
- name: Login to GitHub container registry - name: Log in to container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -78,7 +78,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -88,10 +88,10 @@ jobs:
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -7,7 +7,7 @@ on:
type: string type: string
package: package:
type: string # This would ideally be a `choice` type, but that isn't supported yet type: string # This would ideally be a `choice` type, but that isn't supported yet
description: The package type to be built. Must be one of 'full' or 'embedded' description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk'
required: true required: true
build_mode: build_mode:
type: string # This would ideally be a `choice` type, but that isn't supported yet type: string # This would ideally be a `choice` type, but that isn't supported yet

View File

@@ -69,3 +69,17 @@ jobs:
SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
uses: ./.github/workflows/build-element-call.yaml
with:
package: sdk
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -14,6 +14,10 @@ on:
deployment_ref: deployment_ref:
required: true required: true
type: string type: string
package:
required: true
type: string
description: Which package to deploy - 'full', 'embedded', or 'sdk'
artifact_run_id: artifact_run_id:
required: false required: false
type: string type: string
@@ -50,7 +54,7 @@ jobs:
with: with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ inputs.artifact_run_id }} run-id: ${{ inputs.artifact_run_id }}
name: build-output-full name: build-output-${{ inputs.package }}
path: webapp path: webapp
- name: Add redirects file - name: Add redirects file
@@ -58,15 +62,19 @@ jobs:
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
- name: Add config file - name: Add config file
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json run: |
if [ "${{ inputs.package }}" = "full" ]; then
curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
else
curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json
fi
- name: ☁️ Deploy to Netlify - name: ☁️ Deploy to Netlify
id: netlify id: netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
with: with:
publish-dir: webapp publish-dir: webapp
deploy-message: "Deploy from GitHub Actions" deploy-message: "Deploy from GitHub Actions"
alias: pr${{ inputs.pr_number }} alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
env: env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -20,7 +20,7 @@ jobs:
owner: ${{ github.event.workflow_run.head_repository.owner.login }} owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }} branch: ${{ github.event.workflow_run.head_branch }}
netlify: netlify-full:
needs: prdetails needs: prdetails
permissions: permissions:
deployments: write deployments: write
@@ -31,6 +31,24 @@ jobs:
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }} pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }} pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }} deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
package: full
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
netlify-sdk:
needs: prdetails
permissions:
deployments: write
uses: ./.github/workflows/deploy-to-netlify.yaml
with:
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
pr_number: ${{ needs.prdetails.outputs.pr_number }}
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
package: sdk
secrets: secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -153,7 +153,7 @@ jobs:
path: embedded/android/lib/src/main/assets/element-call path: embedded/android/lib/src/main/assets/element-call
- name: ☕️ Setup Java - name: ☕️ Setup Java
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"

View File

@@ -22,7 +22,7 @@ jobs:
- name: Vitest - name: Vitest
run: "yarn run test:coverage" run: "yarn run test:coverage"
- name: Upload to codecov - name: Upload to codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with: with:

View File

@@ -42,7 +42,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: cpr id: cpr
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/localazy-download branch: actions/localazy-download

View File

@@ -0,0 +1,16 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://call-unstable.ems.host",
"server_name": "call-unstable.ems.host"
}
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100
}
}

View File

@@ -13,8 +13,9 @@
"build:embedded": "yarn build:full --config vite-embedded.config.js", "build:embedded": "yarn build:full --config vite-embedded.config.js",
"build:embedded:production": "yarn build:embedded", "build:embedded:production": "yarn build:embedded",
"build:embedded:development": "yarn build:embedded --mode development", "build:embedded:development": "yarn build:embedded --mode development",
"build:sdk": "yarn build:full --config vite-sdk.config.js",
"build:sdk:development": "yarn build:sdk --mode development", "build:sdk:development": "yarn build:sdk --mode development",
"build:sdk": "yarn build:full --config vite-sdk.config.js",
"build:sdk:production": "yarn build:sdk",
"serve": "vite preview", "serve": "vite preview",
"prettier:check": "prettier -c .", "prettier:check": "prettier -c .",
"prettier:format": "prettier -w .", "prettier:format": "prettier -w .",
@@ -104,7 +105,7 @@
"livekit-client": "^2.13.0", "livekit-client": "^2.13.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
"matrix-widget-api": "^1.16.1", "matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1", "node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",

View File

@@ -1,4 +1,4 @@
# SDK mode # SDK mode (EXPERIMENTAL)
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps. EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.

View File

@@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details.
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { scan } from "rxjs"; import { scan } from "rxjs";
import { widget as _widget } from "../src/widget"; import { type WidgetHelpers } from "../src/widget";
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel"; import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
export const logger = rootLogger.getChild("[MatrixRTCSdk]"); export const logger = rootLogger.getChild("[MatrixRTCSdk]");
if (!_widget) throw Error("No widget. This webapp can only start as a widget"); export const tryMakeSticky = (widget: WidgetHelpers): void => {
export const widget = _widget;
export const tryMakeSticky = (): void => {
logger.info("try making sticky MatrixRTCSdk"); logger.info("try making sticky MatrixRTCSdk");
void widget.api void widget.api
.setAlwaysOnScreen(true) .setAlwaysOnScreen(true)

View File

@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
*/ */
/** /**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk` * This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
* use in widgets. * use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that * It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
@@ -30,8 +32,8 @@ import {
} from "rxjs"; } from "rxjs";
import { import {
type CallMembership, type CallMembership,
MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
MatrixRTCSessionManager,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import {
type Room as LivekitRoom, type Room as LivekitRoom,
@@ -50,14 +52,12 @@ import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates"; import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices"; import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType"; import { E2eeType } from "../src/e2ee/e2eeType";
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { import {
currentAndPrev, ElementWidgetActions,
logger, widget as _widget,
TEXT_LK_TOPIC, initializeWidget,
tryMakeSticky, } from "../src/widget";
widget,
} from "./helper";
import { ElementWidgetActions, initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
interface MatrixRTCSdk { interface MatrixRTCSdk {
@@ -68,7 +68,13 @@ interface MatrixRTCSdk {
join: () => void; join: () => void;
/** @throws on leave errors */ /** @throws on leave errors */
leave: () => void; leave: () => void;
data$: Observable<{ sender: string; data: string }>; /**
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
* A new sdk needs to be created via createMatrixRTCSdk.
*/
stop: () => void;
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/** /**
* flattened list of members * flattened list of members
*/ */
@@ -79,32 +85,54 @@ interface MatrixRTCSdk {
participant: LocalParticipant | RemoteParticipant | null; participant: LocalParticipant | RemoteParticipant | null;
}[] }[]
>; >;
/**
* flattened local members
*/
localMember$: Behavior<{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | null;
} | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */ /** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior<boolean>; connected$: Behavior<boolean>;
sendData?: (data: unknown) => Promise<void>; sendData?: (data: unknown) => Promise<void>;
sendRoomMessage?: (message: string) => Promise<void>;
} }
export async function createMatrixRTCSdk( export async function createMatrixRTCSdk(
application: string = "m.call", application: string = "m.call",
id: string = "", id: string = "",
sticky: boolean = false,
): Promise<MatrixRTCSdk> { ): Promise<MatrixRTCSdk> {
initializeWidget(); const scope = new ObservableScope();
// widget client
initializeWidget(application, true);
const widget = _widget;
if (!widget) throw Error("No widget. This webapp can only start as a widget");
const client = await widget.client; const client = await widget.client;
logger.info("client created"); logger.info("client created");
const scope = new ObservableScope();
// url params
const { roomId } = getUrlParams(); const { roomId } = getUrlParams();
if (roomId === null) throw Error("could not get roomId from url params"); if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client"); if (room === null) throw Error("could not get room from client");
// rtc session
const slot = { application, id };
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
rtcSessionManager.start();
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope); const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, { const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: true, audioEnabled: false,
videoEnabled: true, videoEnabled: false,
}); });
const slot = { application, id };
const rtcSession = new MatrixRTCSession(client, room, slot); // call view model
const callViewModel = createCallViewModel$( const callViewModel = createCallViewModel$(
scope, scope,
rtcSession, rtcSession,
@@ -117,8 +145,9 @@ export async function createMatrixRTCSdk(
constant({ supported: false, processor: undefined }), constant({ supported: false, processor: undefined }),
); );
logger.info("CallViewModelCreated"); logger.info("CallViewModelCreated");
// create data listener // create data listener
const data$ = new Subject<{ sender: string; data: string }>(); const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
const lkTextStreamHandlerFunction = async ( const lkTextStreamHandlerFunction = async (
reader: TextStreamReader, reader: TextStreamReader,
@@ -140,7 +169,7 @@ export async function createMatrixRTCSdk(
if (participants && participants.includes(participantInfo.identity)) { if (participants && participants.includes(participantInfo.identity)) {
const text = await reader.readAll(); const text = await reader.readAll();
logger.info(`Received text: ${text}`); logger.info(`Received text: ${text}`);
data$.next({ sender: participantInfo.identity, data: text }); data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
} else { } else {
logger.warn( logger.warn(
"Received text from unknown participant", "Received text from unknown participant",
@@ -230,6 +259,16 @@ export async function createMatrixRTCSdk(
} }
}; };
const sendRoomMessage = async (message: string): Promise<void> => {
const messageString = JSON.stringify(message);
logger.info("try sending to room: ", messageString);
try {
await client.sendTextMessage(room.roomId, message);
} catch (e) {
logger.error("failed sending to room: ", messageString, e);
}
};
// after hangup gets called // after hangup gets called
const leaveSubs = callViewModel.leave$.subscribe(() => { const leaveSubs = callViewModel.leave$.subscribe(() => {
const scheduleWidgetCloseOnLeave = async (): Promise<void> => { const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
@@ -257,9 +296,6 @@ export async function createMatrixRTCSdk(
// schedule close first and then leave (scope.end) // schedule close first and then leave (scope.end)
void scheduleWidgetCloseOnLeave(); void scheduleWidgetCloseOnLeave();
// actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast)
scope.end();
}); });
logger.info("createMatrixRTCSdk done"); logger.info("createMatrixRTCSdk done");
@@ -267,15 +303,40 @@ export async function createMatrixRTCSdk(
return { return {
join: (): void => { join: (): void => {
// first lets try making the widget sticky // first lets try making the widget sticky
tryMakeSticky(); if (sticky) tryMakeSticky(widget);
callViewModel.join(); callViewModel.join();
}, },
leave: (): void => { leave: (): void => {
callViewModel.hangup(); callViewModel.leave();
},
stop: (): void => {
leaveSubs.unsubscribe(); leaveSubs.unsubscribe();
livekitRoomItemsSub.unsubscribe(); livekitRoomItemsSub.unsubscribe();
scope.end();
}, },
data$, data$,
localMember$: scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
tap((member) =>
logger.info("localMatrixLivekitMember$ next: ", member),
),
switchMap((member) => {
if (member === null) return of(null);
return combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
);
}),
tap((member) => logger.info("localMember$ next: ", member)),
),
),
connected$: callViewModel.connected$, connected$: callViewModel.connected$,
members$: scope.behavior( members$: scope.behavior(
callViewModel.matrixLivekitMembers$.pipe( callViewModel.matrixLivekitMembers$.pipe(
@@ -302,5 +363,6 @@ export async function createMatrixRTCSdk(
[], [],
), ),
sendData, sendData,
sendRoomMessage,
}; };
} }

View File

@@ -0,0 +1,34 @@
/*
Copyright 2026 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 { expect, it } from "vitest";
import { init as initRageshake } from "./rageshake";
it("Logger should not crash if JSON.stringify fails", async () => {
// JSON.stringify can throw. We want to make sure that the logger can handle this gracefully.
await initRageshake();
const bigIntObj = { n: 1n };
const notStringifiable = {
bigIntObj,
};
// @ts-expect-error - we want to create an object that cannot be stringified
notStringifiable.foo = notStringifiable; // circular reference
// ensure this cannot be stringified
expect(() => JSON.stringify(notStringifiable)).toThrow();
expect(() =>
global.mx_rage_logger.log(
1,
"test",
"This is a test message",
notStringifiable,
),
).not.toThrow();
});

View File

@@ -75,7 +75,14 @@ class ConsoleLogger extends EventEmitter {
} else if (arg instanceof Error) { } else if (arg instanceof Error) {
return arg.message + (arg.stack ? `\n${arg.stack}` : ""); return arg.message + (arg.stack ? `\n${arg.stack}` : "");
} else if (typeof arg === "object") { } else if (typeof arg === "object") {
return JSON.stringify(arg, getCircularReplacer()); try {
return JSON.stringify(arg, getCircularReplacer());
} catch {
// Stringify can fail if the object has circular references or if
// there is a bigInt.
// Did happen even with our `getCircularReplacer`. In this case, just log
return "<$ failed to serialize object $>";
}
} else { } else {
return arg; return arg;
} }

View File

@@ -60,6 +60,7 @@ import {
import { import {
accumulate, accumulate,
filterBehavior, filterBehavior,
generateItem,
generateItems, generateItems,
pauseWhen, pauseWhen,
} from "../../utils/observable"; } from "../../utils/observable";
@@ -217,15 +218,23 @@ export interface CallViewModel {
"unknown" | "ringing" | "timeout" | "decline" | "success" | null "unknown" | "ringing" | "timeout" | "decline" | "success" | null
>; >;
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error). /** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope. * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope
* - or calling requestDisconnect
*
* TODO: it seems more reasonable to add a leave() method (that calls requestDisconnect) that will then update leave$ and remove the hangup pattern
*/ */
leave$: Observable<"user" | AutoLeaveReason>; leave$: Observable<"user" | AutoLeaveReason>;
/** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */ /** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
hangup: () => void; hangup: () => void;
// joining // joining
join: () => void; join: () => void;
/**
* calls requestDisconnect. The async leave state can than be observed via connected$
*/
leave: () => void;
// screen sharing // screen sharing
/** /**
* Callback to toggle screen sharing. If null, screen sharing is not possible. * Callback to toggle screen sharing. If null, screen sharing is not possible.
@@ -436,35 +445,38 @@ export function createCallViewModel$(
memberId: uuidv4(), memberId: uuidv4(),
}; };
const localTransport$ = createLocalTransport$({ const localTransport$ = scope.behavior(
scope: scope, matrixRTCMode$.pipe(
memberships$: memberships$, generateItem(
ownMembershipIdentity, "CallViewModel localTransport$",
client, // Re-create LocalTransport whenever the mode changes
delayId$: scope.behavior( (mode) => ({ keys: [mode], data: undefined }),
( (scope, _data$, mode) =>
fromEvent( createLocalTransport$({
matrixRTCSession, scope: scope,
MembershipManagerEvent.DelayIdChanged, memberships$: memberships$,
// The type of reemitted event includes the original emitted as the second arg. ownMembershipIdentity,
) as Observable<[string | undefined, IMembershipManager]> client,
).pipe(map(([delayId]) => delayId ?? null)), delayId$: scope.behavior(
matrixRTCSession.delayId ?? null, (
), fromEvent(
roomId: matrixRoom.roomId, matrixRTCSession,
forceJwtEndpoint$: scope.behavior( MembershipManagerEvent.DelayIdChanged,
matrixRTCMode$.pipe( // The type of reemitted event includes the original emitted as the second arg.
map((v) => ) as Observable<[string | undefined, IMembershipManager]>
v === MatrixRTCMode.Matrix_2_0 ).pipe(map(([delayId]) => delayId ?? null)),
? JwtEndpointVersion.Matrix_2_0 matrixRTCSession.delayId ?? null,
: JwtEndpointVersion.Legacy, ),
), roomId: matrixRoom.roomId,
forceJwtEndpoint:
mode === MatrixRTCMode.Matrix_2_0
? JwtEndpointVersion.Matrix_2_0
: JwtEndpointVersion.Legacy,
useOldestMember: mode === MatrixRTCMode.Legacy,
}),
), ),
), ),
useOldestMember$: scope.behavior( );
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
),
});
const connectionFactory = new ECConnectionFactory( const connectionFactory = new ECConnectionFactory(
client, client,
@@ -483,6 +495,7 @@ export function createCallViewModel$(
connectionFactory: connectionFactory, connectionFactory: connectionFactory,
localTransport$: scope.behavior( localTransport$: scope.behavior(
localTransport$.pipe( localTransport$.pipe(
switchMap((t) => t.active$),
catchError((e: unknown) => { catchError((e: unknown) => {
logger.info( logger.info(
"could not pass local transport to createConnectionManager$. localTransport$ threw an error", "could not pass local transport to createConnectionManager$. localTransport$ threw an error",
@@ -516,13 +529,13 @@ export function createCallViewModel$(
); );
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope: scope, scope,
homeserverConnected: createHomeserverConnected$( homeserverConnected: createHomeserverConnected$(
scope, scope,
client, client,
matrixRTCSession, matrixRTCSession,
), ),
muteStates: muteStates, muteStates,
joinMatrixRTC: (transport: LivekitTransportConfig) => { joinMatrixRTC: (transport: LivekitTransportConfig) => {
return enterRTCSession( return enterRTCSession(
matrixRTCSession, matrixRTCSession,
@@ -542,9 +555,11 @@ export function createCallViewModel$(
), ),
); );
}, },
connectionManager: connectionManager, connectionManager,
matrixRTCSession: matrixRTCSession, matrixRTCSession,
localTransport$: localTransport$, localTransport$: scope.behavior(
localTransport$.pipe(switchMap((t) => t.advertised$)),
),
logger: logger.getChild(`[${Date.now()}]`), logger: logger.getChild(`[${Date.now()}]`),
}); });
@@ -715,6 +730,7 @@ export function createCallViewModel$(
// Generate a collection of MediaItems from the list of expected (whether // Generate a collection of MediaItems from the list of expected (whether
// present or missing) LiveKit participants. // present or missing) LiveKit participants.
generateItems( generateItems(
"CallViewModel userMedia$",
function* ([ function* ([
localMatrixLivekitMember, localMatrixLivekitMember,
matrixLivekitMembers, matrixLivekitMembers,
@@ -1496,6 +1512,7 @@ export function createCallViewModel$(
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish, join: localMembership.requestJoinAndPublish,
leave: localMembership.requestDisconnect,
toggleScreenSharing: toggleScreenSharing, toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$, sharingScreen$: sharingScreen$,
@@ -1545,7 +1562,15 @@ export function createCallViewModel$(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
map((members) => members.value), map((members) => members.value),
tap((v) => { tap((v) => {
logger.debug("matrixLivekitMembers$ updated (exported)", v); const listForLogs = v
.map(
(m) =>
m.membership$.value.userId + "|" + m.membership$.value.deviceId,
)
.join(",");
logger.debug(
`matrixLivekitMembers$ updated (exported) [${listForLogs}]`,
);
}), }),
), ),
), ),

View File

@@ -39,7 +39,6 @@ import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
import { initializeWidget } from "../../../widget"; import { initializeWidget } from "../../../widget";
initializeWidget(); initializeWidget();
@@ -216,11 +215,10 @@ describe("LocalMembership", () => {
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => { withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
scope.behavior<null | LocalTransportWithSFUConfig>( hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), null,
null, );
);
// we do not need any connection data since we want to fail before reaching that. // we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = { const mockConnectionManager = {
@@ -279,23 +277,11 @@ describe("LocalMembership", () => {
}); });
const aTransport = { const aTransport = {
transport: { livekit_service_url: "a",
livekit_service_url: "a", } as LivekitTransportConfig;
} as LivekitTransportConfig,
sfuConfig: {
url: "sfu-url",
jwt: "sfu-token",
},
} as LocalTransportWithSFUConfig;
const bTransport = { const bTransport = {
transport: { livekit_service_url: "b",
livekit_service_url: "b", } as LivekitTransportConfig;
} as LivekitTransportConfig,
sfuConfig: {
url: "sfu-url",
jwt: "sfu-token",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = { const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
@@ -305,7 +291,7 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant, } as unknown as LocalParticipant,
}), }),
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: aTransport.transport, transport: aTransport,
} as unknown as Connection; } as unknown as Connection;
const connectionTransportAConnecting = { const connectionTransportAConnecting = {
...connectionTransportAConnected, ...connectionTransportAConnected,
@@ -314,7 +300,7 @@ describe("LocalMembership", () => {
} as unknown as Connection; } as unknown as Connection;
const connectionTransportBConnected = { const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: bTransport.transport, transport: bTransport,
livekitRoom: mockLivekitRoom({}), livekitRoom: mockLivekitRoom({}),
} as unknown as Connection; } as unknown as Connection;
@@ -368,12 +354,8 @@ describe("LocalMembership", () => {
// stop the first Publisher and let the second one life. // stop the first Publisher and let the second one life.
expect(publishers[0].destroy).toHaveBeenCalled(); expect(publishers[0].destroy).toHaveBeenCalled();
expect(publishers[1].destroy).not.toHaveBeenCalled(); expect(publishers[1].destroy).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe( expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
aTransport.transport, expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
);
expect(publisherFactory.mock.calls[1][0].transport).toBe(
bTransport.transport,
);
scope.end(); scope.end();
await flushPromises(); await flushPromises();
// stop all tracks after ending scopes // stop all tracks after ending scopes
@@ -446,8 +428,9 @@ describe("LocalMembership", () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
const localTransport$ = const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null); null,
);
const connectionManagerData$ = new BehaviorSubject( const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData), new Epoch(connectionManagerData),
); );
@@ -519,7 +502,7 @@ describe("LocalMembership", () => {
}); });
( (
connectionManagerData2.getConnectionForTransport(aTransport.transport)! connectionManagerData2.getConnectionForTransport(aTransport)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected); ).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({

View File

@@ -62,7 +62,6 @@ import {
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { and$ } from "../../../utils/observable.ts"; import { and$ } from "../../../utils/observable.ts";
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
export enum TransportState { export enum TransportState {
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
@@ -128,7 +127,7 @@ interface Props {
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransportConfig) => void; joinMatrixRTC: (transport: LivekitTransportConfig) => void;
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LocalTransportWithSFUConfig | null>; localTransport$: Behavior<LivekitTransportConfig | null>;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession" "updateCallIntent" | "leaveRoomSession"
@@ -147,7 +146,7 @@ interface Props {
* @param props.createPublisherFactory Factory to create a publisher once we have a connection. * @param props.createPublisherFactory Factory to create a publisher once we have a connection.
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport. * @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
* @param props.homeserverConnected The homeserver connected state. * @param props.homeserverConnected The homeserver connected state.
* @param props.localTransport$ The local transport to use for publishing. * @param props.localTransport$ The transport to advertise in our membership.
* @param props.logger The logger to use. * @param props.logger The logger to use.
* @param props.muteStates The mute states for video and audio. * @param props.muteStates The mute states for video and audio.
* @param props.matrixRTCSession The matrix RTC session to join. * @param props.matrixRTCSession The matrix RTC session to join.
@@ -237,9 +236,7 @@ export const createLocalMembership$ = ({
return null; return null;
} }
return connectionData.getConnectionForTransport( return connectionData.getConnectionForTransport(localTransport);
localTransport.transport,
);
}), }),
tap((connection) => { tap((connection) => {
logger.info( logger.info(
@@ -549,7 +546,7 @@ export const createLocalMembership$ = ({
if (!shouldConnect) return; if (!shouldConnect) return;
try { try {
joinMatrixRTC(transport.transport); joinMatrixRTC(transport);
} catch (error) { } catch (error) {
logger.error("Error entering RTC session", error); logger.error("Error entering RTC session", error);
if (error instanceof Error) if (error instanceof Error)

View File

@@ -13,15 +13,24 @@ import {
it, it,
type MockedObject, type MockedObject,
vi, vi,
type MockInstance,
} from "vitest"; } from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import {
type CallMembership,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, lastValueFrom } from "rxjs"; import { BehaviorSubject, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test"; import {
mockConfig,
flushPromises,
ownMemberMock,
mockRtcMembership,
} from "../../../utils/test";
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport"; import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
import { import {
MatrixRTCTransportMissingError, MatrixRTCTransportMissingError,
FailToGetOpenIdToken, FailToGetOpenIdToken,
@@ -43,10 +52,10 @@ describe("LocalTransport", () => {
afterEach(() => scope.end()); afterEach(() => scope.end());
it("throws if config is missing", async () => { it("throws if config is missing", async () => {
const localTransport$ = createLocalTransport$({ const { advertised$, active$ } = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!room:example.org",
useOldestMember$: constant(false), useOldestMember: false,
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -58,14 +67,15 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"), delayId$: constant("delay_id_mock"),
}); });
await flushPromises(); await flushPromises();
expect(() => localTransport$.value).toThrow( expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError(""), new MatrixRTCTransportMissingError(""),
); );
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
}); });
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
@@ -83,10 +93,10 @@ describe("LocalTransport", () => {
); );
const observations: unknown[] = []; const observations: unknown[] = [];
const errors: Error[] = []; const errors: Error[] = [];
const localTransport$ = createLocalTransport$({ const { advertised$, active$ } = createLocalTransport$({
scope, scope,
roomId: "!example_room_id", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember: false,
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
baseUrl: "https://lk.example.org", baseUrl: "https://lk.example.org",
@@ -98,10 +108,10 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"), delayId$: constant("delay_id_mock"),
}); });
localTransport$.subscribe( active$.subscribe(
(o) => observations.push(o), (o) => observations.push(o),
(e) => errors.push(e), (e) => errors.push(e),
); );
@@ -111,7 +121,8 @@ describe("LocalTransport", () => {
const expectedError = new FailToGetOpenIdToken(new Error("no openid")); const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
expect(observations).toStrictEqual([null]); expect(observations).toStrictEqual([null]);
expect(errors).toStrictEqual([expectedError]); expect(errors).toStrictEqual([expectedError]);
expect(() => localTransport$.value).toThrow(expectedError); expect(() => advertised$.value).toThrow(expectedError);
expect(() => active$.value).toThrow(expectedError);
}); });
it("emits preferred transport after OpenID resolves", async () => { it("emits preferred transport after OpenID resolves", async () => {
@@ -126,10 +137,10 @@ describe("LocalTransport", () => {
openIdResolver.promise, openIdResolver.promise,
); );
const localTransport$ = createLocalTransport$({ const { advertised$, active$ } = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!room:example.org",
useOldestMember$: constant(false), useOldestMember: false,
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -140,7 +151,7 @@ describe("LocalTransport", () => {
baseUrl: "https://lk.example.org", baseUrl: "https://lk.example.org",
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"), delayId$: constant("delay_id_mock"),
}); });
@@ -150,14 +161,17 @@ describe("LocalTransport", () => {
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId, livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
}); });
expect(localTransport$.value).toBe(null); expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises(); await flushPromises();
// final // final
expect(localTransport$.value).toStrictEqual({ const expectedTransport = {
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", };
}, expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "jwt", jwt: "jwt",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -167,51 +181,122 @@ describe("LocalTransport", () => {
}); });
}); });
it("updates local transport when oldest member changes", async () => { describe("oldest member mode", () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch const aliceTransport: LivekitTransportConfig = {
mockConfig({ type: "livekit",
livekit: { livekit_service_url: "https://lk.example.org" }, livekit_service_url: "https://alice.example.org",
};
const bobTransport: LivekitTransportConfig = {
type: "livekit",
livekit_service_url: "https://bob.example.org",
};
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
fociPreferred: [aliceTransport],
}); });
const memberships$ = new BehaviorSubject(new Epoch([])); const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>(); fociPreferred: [bobTransport],
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const localTransport$ = createLocalTransport$({
scope,
roomId: "!example_room_id",
useOldestMember$: constant(true),
memberships$,
client: {
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant("delay_id_mock"),
}); });
openIdResolver.resolve?.(openIdResponse); let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
expect(localTransport$.value).toBe(null); beforeEach(() => {
await flushPromises(); openIdSpy = vi
// final .spyOn(openIDSFU, "getSFUConfigWithOpenID")
expect(localTransport$.value).toStrictEqual({ .mockResolvedValue(openIdResponse);
transport: { });
livekit_service_url: "https://lk.example.org",
type: "livekit", it("updates active transport when oldest member changes", async () => {
}, // Initially, Alice is the only member
sfuConfig: { const memberships$ = new BehaviorSubject([aliceMembership]);
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", const { advertised$, active$ } = createLocalTransport$({
livekitIdentity: "@lk_user:ABCDEF", scope,
url: "https://lk.example.org", roomId: "!example_room_id",
}, useOldestMember: true,
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
client: {
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
expect(active$.value).toBe(null);
await flushPromises();
// SFU config should've been fetched
expect(openIdSpy).toHaveBeenCalled();
// Alice's transport should be active and advertised
expect(active$.value?.transport).toStrictEqual(aliceTransport);
expect(advertised$.value).toStrictEqual(aliceTransport);
// Now Bob joins the call, but Alice is still the oldest member
openIdSpy.mockClear();
memberships$.next([aliceMembership, bobMembership]);
await flushPromises();
// No new SFU config should've been fetched
expect(openIdSpy).not.toHaveBeenCalled();
// Alice's transport should still be active and advertised
expect(active$.value?.transport).toStrictEqual(aliceTransport);
expect(advertised$.value).toStrictEqual(aliceTransport);
// Now Bob takes Alice's place as the oldest member
openIdSpy.mockClear();
memberships$.next([bobMembership, aliceMembership]);
// Active transport should reset to null until we have Bob's SFU config
expect(active$.value).toStrictEqual(null);
await flushPromises();
// Bob's SFU config should've been fetched
expect(openIdSpy).toHaveBeenCalled();
// Bob's transport should be active, but Alice's should remain advertised
// (since we don't want the change in oldest member to cause a wave of new
// state events)
expect(active$.value?.transport).toStrictEqual(bobTransport);
expect(advertised$.value).toStrictEqual(aliceTransport);
});
it("advertises preferred transport when no other member exists", async () => {
// Initially, there are no members
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
const { advertised$, active$ } = createLocalTransport$({
scope,
roomId: "!example_room_id",
useOldestMember: true,
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
client: {
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () =>
Promise.resolve([aliceTransport]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
expect(active$.value).toBe(null);
await flushPromises();
// Our own preferred transport should be advertised
expect(advertised$.value).toStrictEqual(aliceTransport);
// No transport should be active however (there is still no oldest member)
expect(active$.value).toBe(null);
// Now Bob joins the call and becomes the oldest member
memberships$.next([bobMembership]);
await flushPromises();
// We should still advertise our own preferred transport (to avoid
// unnecessary state changes)
expect(advertised$.value).toStrictEqual(aliceTransport);
// Bob's transport should become active
expect(active$.value?.transport).toBe(bobTransport);
}); });
}); });
@@ -229,8 +314,8 @@ describe("LocalTransport", () => {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
scope, scope,
roomId: "!example_room_id", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember: false,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null), delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
@@ -256,15 +341,19 @@ describe("LocalTransport", () => {
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
const localTransport$ = createLocalTransport$(localTransportOpts); const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ const expectedTransport = {
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", };
}, expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -273,13 +362,15 @@ describe("LocalTransport", () => {
}, },
}); });
}); });
it("supports getting transport via user settings", async () => { it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org"); customLivekitUrl.setValue("https://lk.example.org");
const localTransport$ = createLocalTransport$(localTransportOpts); const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(advertised$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ expect(active$.value).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
@@ -292,19 +383,24 @@ describe("LocalTransport", () => {
}, },
}); });
}); });
it("supports getting transport via backend", async () => { it("supports getting transport via backend", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
]); ]);
const localTransport$ = createLocalTransport$(localTransportOpts); const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ const expectedTransport = {
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", };
}, expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -313,6 +409,7 @@ describe("LocalTransport", () => {
}, },
}); });
}); });
it("fails fast if the openID request fails for backend config", async () => { it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
@@ -320,13 +417,11 @@ describe("LocalTransport", () => {
openIdResolver.reject( openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")), new FailToGetOpenIdToken(new Error("Test driven error")),
); );
try { await expect(async () =>
await lastValueFrom(createLocalTransport$(localTransportOpts)); lastValueFrom(createLocalTransport$(localTransportOpts).active$),
throw Error("Expected test to throw"); ).rejects.toThrow(expect.any(FailToGetOpenIdToken));
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
}); });
it("supports getting transport via well-known", async () => { it("supports getting transport via well-known", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org"); localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", { fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
@@ -334,15 +429,19 @@ describe("LocalTransport", () => {
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
], ],
}); });
const localTransport$ = createLocalTransport$(localTransportOpts); const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ const expectedTransport = {
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", };
}, expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -352,6 +451,7 @@ describe("LocalTransport", () => {
}); });
expect(fetchMock.done()).toEqual(true); expect(fetchMock.done()).toEqual(true);
}); });
it("fails fast if the openId request fails for the well-known config", async () => { it("fails fast if the openId request fails for the well-known config", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org"); localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", { fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
@@ -362,20 +462,18 @@ describe("LocalTransport", () => {
openIdResolver.reject( openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")), new FailToGetOpenIdToken(new Error("Test driven error")),
); );
try { await expect(async () =>
await lastValueFrom(createLocalTransport$(localTransportOpts)); lastValueFrom(createLocalTransport$(localTransportOpts).active$),
throw Error("Expected test to throw"); ).rejects.toThrow(expect.any(FailToGetOpenIdToken));
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
}); });
it("throws if no options are available", async () => { it("throws if no options are available", async () => {
const localTransport$ = createLocalTransport$({ const { advertised$, active$ } = createLocalTransport$({
scope, scope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember: false,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null), delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
@@ -390,7 +488,10 @@ describe("LocalTransport", () => {
}); });
await flushPromises(); await flushPromises();
expect(() => localTransport$.value).toThrow( expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError(""), new MatrixRTCTransportMissingError(""),
); );
}); });

View File

@@ -13,12 +13,15 @@ import {
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { MatrixError, type MatrixClient } from "matrix-js-sdk"; import { MatrixError, type MatrixClient } from "matrix-js-sdk";
import { import {
combineLatest,
distinctUntilChanged, distinctUntilChanged,
first,
from, from,
map, map,
merge,
of, of,
startWith,
switchMap, switchMap,
tap,
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
@@ -58,8 +61,8 @@ interface Props {
OpenIDClientParts; OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias. // Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string; roomId: string;
useOldestMember$: Behavior<boolean>; useOldestMember: boolean;
forceJwtEndpoint$: Behavior<JwtEndpointVersion>; forceJwtEndpoint: JwtEndpointVersion;
delayId$: Behavior<string | null>; delayId$: Behavior<string | null>;
} }
@@ -93,23 +96,35 @@ export interface LocalTransportWithSFUConfig {
transport: LivekitTransportConfig; transport: LivekitTransportConfig;
sfuConfig: SFUConfig; sfuConfig: SFUConfig;
} }
export function isLocalTransportWithSFUConfig( export function isLocalTransportWithSFUConfig(
obj: LivekitTransportConfig | LocalTransportWithSFUConfig, obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
): obj is LocalTransportWithSFUConfig { ): obj is LocalTransportWithSFUConfig {
return "transport" in obj && "sfuConfig" in obj; return "transport" in obj && "sfuConfig" in obj;
} }
interface LocalTransport {
/**
* The transport to be advertised in our MatrixRTC membership. `null` when not
* yet fetched/validated.
*/
advertised$: Behavior<LivekitTransportConfig | null>;
/**
* The transport to connect to and publish media on. `null` when not yet known
* or available.
*/
active$: Behavior<LocalTransportWithSFUConfig | null>;
}
/** /**
* This class is responsible for managing the local transport. * Connects to the JWT service and determines the transports that the local member should use.
* "Which transport is the local member going to use"
* *
* @prop useOldestMember Whether to use the same transport as the oldest member. * @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. * This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
* * @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity. * This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events) * (which is expected for non sticky event based rtc member events)
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value. * @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
export const createLocalTransport$ = ({ export const createLocalTransport$ = ({
@@ -118,114 +133,156 @@ export const createLocalTransport$ = ({
ownMembershipIdentity, ownMembershipIdentity,
client, client,
roomId, roomId,
useOldestMember$, useOldestMember,
forceJwtEndpoint$, forceJwtEndpoint,
delayId$, delayId$,
}: Props): Behavior<LocalTransportWithSFUConfig | null> => { }: Props): LocalTransport => {
/** /**
* The transport over which we should be actively publishing our media. * The LiveKit transport in use by the oldest RTC membership. `null` when the
* undefined when not joined. * oldest member has no such transport.
*/ */
const oldestMemberTransport$ = const oldestMemberTransport$ = scope.behavior<LivekitTransportConfig | null>(
scope.behavior<LocalTransportWithSFUConfig | null>( memberships$.pipe(
combineLatest([memberships$, useOldestMember$]).pipe( map((memberships) => {
map(([memberships, useOldestMember]) => { const oldestMember = memberships.value[0];
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member if (oldestMember === undefined) {
const oldestMember = memberships.value[0]; logger.info("Oldest member: not found");
const transport = oldestMember?.getTransport(oldestMember); return null;
if (!transport) return null; }
return transport; const transport = oldestMember.getTransport(oldestMember);
}), if (transport === undefined) {
switchMap((transport) => { logger.warn(
if (transport !== null && isLivekitTransportConfig(transport)) { `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
// Get the open jwt token to connect to the sfu );
const computeLocalTransportWithSFUConfig = return null;
async (): Promise<LocalTransportWithSFUConfig> => { }
return { if (!isLivekitTransportConfig(transport)) {
transport, logger.warn(
sfuConfig: await getSFUConfigWithOpenID( `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
client, );
ownMembershipIdentity, return null;
transport.livekit_service_url, }
roomId, logger.info(
{ forceJwtEndpoint: JwtEndpointVersion.Legacy }, "Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
logger, );
), return transport;
}; }),
}; distinctUntilChanged(areLivekitTransportsEqual),
return from(computeLocalTransportWithSFUConfig()); ),
} );
return of(null);
}),
),
null,
);
/** /**
* The transport that we would personally prefer to publish on (if not for the * The transport that we would personally prefer to publish on (if not for the
* transport preferences of others, perhaps). * transport preferences of others, perhaps). `null` until fetched and
* validated.
* *
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
const preferredTransport$ = scope.behavior( const preferredTransport$ =
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new scope.behavior<LocalTransportWithSFUConfig | null>(
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity // preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`) // jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
// When using sticky events (we need to use the new endpoint). // differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe( // When using sticky events (we need to use the new endpoint).
switchMap(([customUrl, delayId, forceEndpoint]) => { customLivekitUrl.value$.pipe(
logger.info( switchMap((customUrl) =>
"Creating preferred transport based on: ", startWith<LocalTransportWithSFUConfig | null>(null)(
"customUrl: ", // Fetch the SFU config, and repeat this asynchronously for every
customUrl, // change in delay ID.
"delayId: ", delayId$.pipe(
delayId, switchMap(async (delayId) => {
"forceEndpoint: ", logger.info(
forceEndpoint, "Creating preferred transport based on: ",
); "customUrl: ",
return from( customUrl,
makeTransport( "delayId: ",
client, delayId,
ownMembershipIdentity, "forceJwtEndpoint: ",
roomId, forceJwtEndpoint,
customUrl, );
forceEndpoint, return makeTransport(
delayId ?? undefined, client,
ownMembershipIdentity,
roomId,
customUrl,
forceJwtEndpoint,
delayId ?? undefined,
);
}),
// We deliberately hide any changes to the SFU config because we
// do not actually want the app to reconnect whenever the JWT
// token changes due to us delegating a new delayed event. The
// initial SFU config for the transport is all the app needs.
distinctUntilChanged((prev, next) =>
areLivekitTransportsEqual(prev.transport, next.transport),
),
),
), ),
); ),
}), ),
), );
null,
);
/** if (useOldestMember) {
* The chosen transport we should advertise in our MatrixRTC membership. // --- Oldest member mode ---
*/ return {
return scope.behavior( // Never update the transport that we advertise in our membership. Just
combineLatest([ // take the first valid oldest member or preferred transport that we learn
useOldestMember$, // about, and stick with that. This avoids unnecessary SFU hops and room
oldestMemberTransport$, // state changes.
preferredTransport$, advertised$: scope.behavior(
]).pipe( merge(
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => { oldestMemberTransport$,
return useOldestMember preferredTransport$.pipe(map((t) => t?.transport ?? null)),
? (oldestMemberTransport ?? preferredTransport) ).pipe(
: preferredTransport; first((t) => t !== null),
}), tap((t) =>
distinctUntilChanged((t1, t2) => { logger.info(`Advertise transport: ${t.livekit_service_url}`),
logger.info( ),
"Local Transport Update from:", ),
t1?.transport.livekit_service_url, null,
" to ", ),
t2?.transport.livekit_service_url, // Publish on the transport used by the oldest member.
); active$: scope.behavior(
return areLivekitTransportsEqual( oldestMemberTransport$.pipe(
t1?.transport ?? null, switchMap((transport) => {
t2?.transport ?? null, // Oldest member not available (or invalid SFU config).
); if (transport === null) return of(null);
}), // Oldest member available: fetch the SFU config.
const fetchOldestMemberTransport =
async (): Promise<LocalTransportWithSFUConfig> => ({
transport,
sfuConfig: await getSFUConfigWithOpenID(
client,
ownMembershipIdentity,
transport.livekit_service_url,
roomId,
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
logger,
),
});
return from(fetchOldestMemberTransport()).pipe(startWith(null));
}),
tap((t) =>
logger.info(
`Publish on transport: ${t?.transport.livekit_service_url}`,
),
),
),
),
};
}
// --- Multi-SFU mode ---
// Always publish on and advertise the preferred transport.
return {
advertised$: scope.behavior(
preferredTransport$.pipe(
map((t) => t?.transport ?? null),
distinctUntilChanged(areLivekitTransportsEqual),
),
), ),
); active$: preferredTransport$,
};
}; };
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";

View File

@@ -90,7 +90,7 @@ export interface IConnectionManager {
* @param props - Configuration object * @param props - Configuration object
* @param props.scope - The observable scope used by this object * @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections * @param props.connectionFactory - Used to create new connections
* @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$) * @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$) * @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
* @param props.ownMembershipIdentity - The own membership identity to use. * @param props.ownMembershipIdentity - The own membership identity to use.
* @param props.logger - The logger to use. * @param props.logger - The logger to use.
@@ -162,22 +162,23 @@ export function createConnectionManager$({
const connections$ = scope.behavior( const connections$ = scope.behavior(
localAndRemoteTransports$.pipe( localAndRemoteTransports$.pipe(
generateItemsWithEpoch( generateItemsWithEpoch(
"ConnectionManager connections$",
function* (transports) { function* (transports) {
for (const transportWithOrWithoutSfuConfig of transports) { for (const transport of transports) {
if ( if (isLocalTransportWithSFUConfig(transport)) {
isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig) // This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
) {
// This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
yield { yield {
keys: [transport.livekit_service_url, sfuConfig], keys: [
transport.transport.livekit_service_url,
transport.sfuConfig,
],
data: undefined, data: undefined,
}; };
} else { } else {
yield { yield {
keys: [ keys: [
transportWithOrWithoutSfuConfig.livekit_service_url, transport.livekit_service_url,
undefined as undefined | SFUConfig, undefined as SFUConfig | undefined,
], ],
data: undefined, data: undefined,
}; };
@@ -193,6 +194,8 @@ export function createConnectionManager$({
}, },
ownMembershipIdentity, ownMembershipIdentity,
logger, logger,
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
// I would like it if connections always fetched the SFUConfig by themselves.
sfuConfig, sfuConfig,
); );
// Start the connection immediately // Start the connection immediately

View File

@@ -11,7 +11,6 @@ import {
type LivekitTransportConfig, type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs"; import { combineLatest, filter, map } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager"; import { type IConnectionManager } from "./ConnectionManager";
@@ -19,8 +18,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { generateItemsWithEpoch } from "../../../utils/observable"; import { generateItemsWithEpoch } from "../../../utils/observable";
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
interface LocalTaggedParticipant { interface LocalTaggedParticipant {
type: "local"; type: "local";
value$: Behavior<LocalParticipant | null>; value$: Behavior<LocalParticipant | null>;
@@ -94,9 +91,10 @@ export function createMatrixLivekitMembers$({
), ),
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)), map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
generateItemsWithEpoch( generateItemsWithEpoch(
"MatrixLivekitMembers",
// Generator function. // Generator function.
// creates an array of `{key, data}[]` // creates an array of `{key, data}[]`
// Each change in the keys (new key, missing key) will result in a call to the factory function. // Each change in the keys (new key) will result in a call to the factory function.
function* ([membershipsWithTransport, managerData]) { function* ([membershipsWithTransport, managerData]) {
for (const { membership, transport } of membershipsWithTransport) { for (const { membership, transport } of membershipsWithTransport) {
const participants = transport const participants = transport
@@ -111,26 +109,23 @@ export function createMatrixLivekitMembers$({
: null; : null;
yield { yield {
// This could also just be the memberId without the other fields. // This could just be the backend identity without the other keys.
// In theory we should never have the same memberId for different userIds (they are UUIDs) // The user ID, device ID, and member ID are included however so
// This still makes us resilient agains someone who intentionally tries to use the same memberId. // they show up in debug logs.
// If they want to do this they would now need to also use the same sender which is impossible.
keys: [ keys: [
membership.userId, membership.userId,
membership.deviceId, membership.deviceId,
membership.memberId, membership.memberId,
membership.rtcBackendIdentity,
], ],
data: { membership, participant, connection }, data: { membership, participant, connection },
}; };
} }
}, },
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory. // Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
(scope, data$, userId, deviceId, memberId) => { (scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => {
logger.debug(
`Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity},keys userId:deviceId:memberId ${userId}:${deviceId}:${memberId}`,
);
const { participant$, ...rest } = scope.splitBehavior(data$); const { participant$, ...rest } = scope.splitBehavior(data$);
// will only get called once per `participantId, userId` pair. // will only get called once per backend identity.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return { return {
userId, userId,

View File

@@ -257,10 +257,9 @@ abstract class BaseMediaViewModel {
* The Matrix user to which this media belongs. * The Matrix user to which this media belongs.
*/ */
public readonly userId: string, public readonly userId: string,
public readonly rtcBackendIdentity: string,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit. // livekit.
protected readonly participant$: Observable< protected readonly participant$: Behavior<
LocalParticipant | RemoteParticipant | null LocalParticipant | RemoteParticipant | null
>, >,
@@ -407,8 +406,11 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string, /**
participant$: Observable<LocalParticipant | RemoteParticipant | null>, * The expected identity of the LiveKit participant. Exposed for debugging.
*/
public readonly rtcBackendIdentity: string,
participant$: Behavior<LocalParticipant | RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl$: Behavior<string | undefined>, focusUrl$: Behavior<string | undefined>,
@@ -421,7 +423,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.Microphone, Track.Source.Microphone,
@@ -677,7 +678,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string, rtcBackendIdentity: string,
participant$: Observable<RemoteParticipant | null>, participant$: Behavior<RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl$: Behavior<string | undefined>, focusUrl$: Behavior<string | undefined>,
@@ -779,8 +780,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string, participant$: Behavior<LocalParticipant | RemoteParticipant>,
participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl$: Behavior<string | undefined>, focusUrl$: Behavior<string | undefined>,
@@ -793,7 +793,6 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,

View File

@@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { of } from "rxjs";
import { import {
type LocalParticipant, type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
@@ -14,7 +14,7 @@ import {
import { type ObservableScope } from "./ObservableScope.ts"; import { type ObservableScope } from "./ObservableScope.ts";
import { ScreenShareViewModel } from "./MediaViewModel.ts"; import { ScreenShareViewModel } from "./MediaViewModel.ts";
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
import type { Behavior } from "./Behavior.ts"; import { constant, type Behavior } from "./Behavior.ts";
/** /**
* A screen share media item to be presented in a tile. This is a thin wrapper * A screen share media item to be presented in a tile. This is a thin wrapper
@@ -28,7 +28,6 @@ export class ScreenShare {
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -41,8 +40,7 @@ export class ScreenShare {
this.scope, this.scope,
id, id,
userId, userId,
rtcBackendIdentity, constant(participant),
of(participant),
encryptionSystem, encryptionSystem,
livekitRoom$, livekitRoom$,
focusUrl$, focusUrl$,

View File

@@ -130,6 +130,7 @@ export class UserMedia {
// MediaViewModels don't support it though since they look for a unique // MediaViewModels don't support it though since they look for a unique
// track for the given source. So generateItems here is a bit overkill. // track for the given source. So generateItems here is a bit overkill.
generateItems( generateItems(
`${this.id} screenShares$`,
function* (p) { function* (p) {
if (p.isScreenShareEnabled) if (p.isScreenShareEnabled)
yield { yield {
@@ -142,7 +143,6 @@ export class UserMedia {
scope, scope,
`${this.id}:${key}`, `${this.id}:${key}`,
this.userId, this.userId,
this.rtcBackendIdentity,
p, p,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom$, this.livekitRoom$,

View File

@@ -10,6 +10,8 @@ import {
type SpotlightTileViewModel, type SpotlightTileViewModel,
} from "./TileViewModel.ts"; } from "./TileViewModel.ts";
import { import {
type LocalUserMediaViewModel,
type RemoteUserMediaViewModel,
type MediaViewModel, type MediaViewModel,
type UserMediaViewModel, type UserMediaViewModel,
} from "./MediaViewModel.ts"; } from "./MediaViewModel.ts";
@@ -40,8 +42,8 @@ export interface SpotlightExpandedLayoutMedia {
export interface OneOnOneLayoutMedia { export interface OneOnOneLayoutMedia {
type: "one-on-one"; type: "one-on-one";
local: UserMediaViewModel; local: LocalUserMediaViewModel;
remote: UserMediaViewModel; remote: RemoteUserMediaViewModel;
} }
export interface PipLayoutMedia { export interface PipLayoutMedia {

View File

@@ -47,6 +47,7 @@ test("generateItems", () => {
expectObservable( expectObservable(
hot<string>(inputMarbles).pipe( hot<string>(inputMarbles).pipe(
generateItems( generateItems(
"test items",
function* (input) { function* (input) {
for (let i = 1; i <= +input; i++) { for (let i = 1; i <= +input; i++) {
yield { keys: [i], data: undefined }; yield { keys: [i], data: undefined };

View File

@@ -24,6 +24,7 @@ import {
type OperatorFunction, type OperatorFunction,
distinctUntilChanged, distinctUntilChanged,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { Epoch, ObservableScope } from "../state/ObservableScope"; import { Epoch, ObservableScope } from "../state/ObservableScope";
@@ -122,8 +123,9 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
); );
} }
interface ItemHandle<Data, Item> { interface ItemHandle<Keys extends unknown[], Data, Item> {
scope: ObservableScope; scope: ObservableScope;
keys: readonly [...Keys];
data$: BehaviorSubject<Data>; data$: BehaviorSubject<Data>;
item: Item; item: Item;
} }
@@ -135,6 +137,7 @@ interface ItemHandle<Data, Item> {
* requested at a later time, and destroyed (have their scope ended) when the * requested at a later time, and destroyed (have their scope ended) when the
* key is no longer requested. * key is no longer requested.
* *
* @param name A name for this collection to use in debug logs.
* @param generator A generator function yielding a tuple of keys and the * @param generator A generator function yielding a tuple of keys and the
* currently associated data for each item that it wants to exist. * currently associated data for each item that it wants to exist.
* @param factory A function constructing an individual item, given the item's key, * @param factory A function constructing an individual item, given the item's key,
@@ -146,16 +149,17 @@ export function generateItems<
Data, Data,
Item, Item,
>( >(
name: string,
generator: ( generator: (
input: Input, input: Input,
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: ( factory: (
scope: ObservableScope, scope: ObservableScope,
data$: Behavior<Data>, data$: Behavior<Data>,
...keys: Keys ...keys: Keys
) => Item, ) => Item,
): OperatorFunction<Input, Item[]> { ): OperatorFunction<Input, Item[]> {
return generateItemsInternal(generator, factory, (items) => items); return generateItemsInternal(name, generator, factory, (items) => items);
} }
/** /**
@@ -167,9 +171,10 @@ export function generateItemsWithEpoch<
Data, Data,
Item, Item,
>( >(
name: string,
generator: ( generator: (
input: Input, input: Input,
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: ( factory: (
scope: ObservableScope, scope: ObservableScope,
data$: Behavior<Data>, data$: Behavior<Data>,
@@ -177,6 +182,7 @@ export function generateItemsWithEpoch<
) => Item, ) => Item,
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> { ): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
return generateItemsInternal( return generateItemsInternal(
name,
function* (input) { function* (input) {
yield* generator(input.value); yield* generator(input.value);
}, },
@@ -207,6 +213,38 @@ export function filterBehavior<T, S extends T>(
); );
} }
/**
* Maps a changing input value to an item whose lifetime is tied to a certain
* computed key. The item may capture some dynamic data from the input.
*/
export function generateItem<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
>(
name: string,
generator: (input: Input) => { keys: readonly [...Keys]; data: Data },
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
): OperatorFunction<Input, Item> {
return (input$) =>
input$.pipe(
generateItemsInternal(
name,
function* (input) {
yield generator(input);
},
factory,
(items) => items,
),
map(([item]) => item),
);
}
function generateItemsInternal< function generateItemsInternal<
Input, Input,
Keys extends [unknown, ...unknown[]], Keys extends [unknown, ...unknown[]],
@@ -214,9 +252,10 @@ function generateItemsInternal<
Item, Item,
Output, Output,
>( >(
name: string,
generator: ( generator: (
input: Input, input: Input,
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: ( factory: (
scope: ObservableScope, scope: ObservableScope,
data$: Behavior<Data>, data$: Behavior<Data>,
@@ -232,26 +271,34 @@ function generateItemsInternal<
Input, Input,
{ {
map: Map<any, any>; map: Map<any, any>;
items: Set<ItemHandle<Data, Item>>; items: Set<ItemHandle<Keys, Data, Item>>;
input: Input; input: Input;
}, },
{ map: Map<any, any>; items: Set<ItemHandle<Data, Item>> } { map: Map<any, any>; items: Set<ItemHandle<Keys, Data, Item>> }
>( >(
({ map: prevMap, items: prevItems }, input) => { ({ map: prevMap, items: prevItems }, input) => {
const nextMap = new Map(); const nextMap = new Map();
const nextItems = new Set<ItemHandle<Data, Item>>(); const nextItems = new Set<ItemHandle<Keys, Data, Item>>();
for (const { keys, data } of generator(input)) { for (const { keys, data } of generator(input)) {
// Disable type checks for a second to grab the item out of a nested map // Disable type checks for a second to grab the item out of a nested map
let i: any = prevMap; let i: any = prevMap;
for (const key of keys) i = i?.get(key); for (const key of keys) i = i?.get(key);
let item = i as ItemHandle<Data, Item> | undefined; let item = i as ItemHandle<Keys, Data, Item> | undefined;
if (item === undefined) { if (item === undefined) {
// First time requesting the key; create the item // First time requesting the key; create the item
const scope = new ObservableScope(); const scope = new ObservableScope();
const data$ = new BehaviorSubject(data); const data$ = new BehaviorSubject(data);
item = { scope, data$, item: factory(scope, data$, ...keys) }; logger.debug(
`[${name}] Creating item with keys ${keys.join(", ")}`,
);
item = {
scope,
keys,
data$,
item: factory(scope, data$, ...keys),
};
} else { } else {
item.data$.next(data); item.data$.next(data);
} }
@@ -269,7 +316,7 @@ function generateItemsInternal<
const finalKey = keys[keys.length - 1]; const finalKey = keys[keys.length - 1];
if (m.has(finalKey)) if (m.has(finalKey))
throw new Error( throw new Error(
`Keys must be unique (tried to generate multiple items for key ${keys})`, `Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`,
); );
m.set(keys[keys.length - 1], item); m.set(keys[keys.length - 1], item);
nextItems.add(item); nextItems.add(item);
@@ -277,7 +324,12 @@ function generateItemsInternal<
// Destroy all items that are no longer being requested // Destroy all items that are no longer being requested
for (const item of prevItems) for (const item of prevItems)
if (!nextItems.has(item)) item.scope.end(); if (!nextItems.has(item)) {
logger.debug(
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
);
item.scope.end();
}
return { map: nextMap, items: nextItems, input }; return { map: nextMap, items: nextItems, input };
}, },
@@ -285,7 +337,15 @@ function generateItemsInternal<
), ),
finalizeValue(({ items }) => { finalizeValue(({ items }) => {
// Destroy all remaining items when no longer subscribed // Destroy all remaining items when no longer subscribed
for (const { scope } of items) scope.end(); logger.debug(
`[${name}] End of scope, destroying all ${items.size} items…`,
);
for (const item of items) {
logger.debug(
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
);
item.scope.end();
}
}), }),
map(({ items, input }) => map(({ items, input }) =>
project( project(

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { beforeAll, describe, expect, vi, it } from "vitest"; import { describe, expect, vi, it, beforeEach } from "vitest";
import { createRoomWidgetClient, EventType } from "matrix-js-sdk"; import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
@@ -35,11 +35,14 @@ vi.mock("./UrlParams", () => ({
})), })),
})); }));
initializeWidget(); beforeEach(() => {
describe("widget", () => { createRoomWidgetClientSpy.mockClear();
beforeAll(() => {}); });
describe("widget", () => {
it("should create an embedded client with the correct params", () => { it("should create an embedded client with the correct params", () => {
initializeWidget("ANYRTCAPP");
expect(getUrlParams()).toStrictEqual({ expect(getUrlParams()).toStrictEqual({
widgetId: "id", widgetId: "id",
parentUrl: "http://parentUrl", parentUrl: "http://parentUrl",
@@ -66,13 +69,16 @@ describe("widget", () => {
]; ];
const sendState = [ const sendState = [
"myYser", // Legacy call membership events { eventType: "org.matrix.msc3401.call.member", stateKey: "myYser" }, // Legacy call membership events
`_myYser_AAAAA_m.call`, // Session membership events {
`myYser_AAAAA_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it eventType: "org.matrix.msc3401.call.member",
].map((stateKey) => ({ stateKey: `_myYser_AAAAA_ANYRTCAPP`,
eventType: EventType.GroupCallMemberPrefix, }, // Session membership events
stateKey, {
})); eventType: "org.matrix.msc3401.call.member",
stateKey: `myYser_AAAAA_ANYRTCAPP`,
}, // The above with no leading underscore, for room versions whose auth rules allow it
];
const receiveState = [ const receiveState = [
{ eventType: EventType.RoomCreate }, { eventType: EventType.RoomCreate },
{ eventType: EventType.RoomName }, { eventType: EventType.RoomName },
@@ -124,4 +130,32 @@ describe("widget", () => {
}); });
expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false); expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false);
}); });
it("should request send message permission if requested", () => {
initializeWidget("ANYRTCAPP", true);
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
expect.anything(),
// capabilities
expect.objectContaining({
sendEvent: expect.arrayContaining(["m.room.message"]),
}),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it("should not request send message permission when not requested", () => {
initializeWidget("", false);
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
expect.anything(),
// capabilities
expect.objectContaining({
sendEvent: expect.not.arrayContaining(["m.room.message"]),
}),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
}); });

View File

@@ -68,7 +68,10 @@ export let widget: WidgetHelpers | null;
*/ */
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before // this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
// execution. // execution.
export const initializeWidget = (): void => { export const initializeWidget = (
rtcApplication: string = "m.call",
sendRoomEvents = false,
): void => {
try { try {
const { const {
widgetId, widgetId,
@@ -116,6 +119,9 @@ export const initializeWidget = (): void => {
EventType.CallNotify, // Sent as a deprecated fallback EventType.CallNotify, // Sent as a deprecated fallback
EventType.RTCNotification, EventType.RTCNotification,
]; ];
if (sendRoomEvents) {
sendEvent.push(EventType.RoomMessage);
}
const sendRecvEvent = [ const sendRecvEvent = [
"org.matrix.rageshake_request", "org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix, EventType.CallEncryptionKeysPrefix,
@@ -128,8 +134,8 @@ export const initializeWidget = (): void => {
const sendState = [ const sendState = [
userId, // Legacy call membership events userId, // Legacy call membership events
`_${userId}_${deviceId}_m.call`, // Session membership events `_${userId}_${deviceId}_${rtcApplication}`, // Session membership events
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it `${userId}_${deviceId}_${rtcApplication}`, // The above with no leading underscore, for room versions whose auth rules allow it
].map((stateKey) => ({ ].map((stateKey) => ({
eventType: EventType.GroupCallMemberPrefix, eventType: EventType.GroupCallMemberPrefix,
stateKey, stateKey,

View File

@@ -8364,7 +8364,7 @@ __metadata:
livekit-client: "npm:^2.13.0" livekit-client: "npm:^2.13.0"
lodash-es: "npm:^4.17.21" lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1" loglevel: "npm:^1.9.1"
matrix-js-sdk: "matrix-org/matrix-js-sdk#develop" matrix-js-sdk: "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf"
matrix-widget-api: "npm:^1.16.1" matrix-widget-api: "npm:^1.16.1"
node-stdlib-browser: "npm:^1.3.1" node-stdlib-browser: "npm:^1.3.1"
normalize.css: "npm:^8.0.1" normalize.css: "npm:^8.0.1"
@@ -11452,9 +11452,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"matrix-js-sdk@matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf":
version: 40.1.0 version: 40.1.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=f2157f28bbadf2898fe21991f69ccb2af40df326" resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=6e3efef0c5f660df47cf00874927dec1c75cc3cf"
dependencies: dependencies:
"@babel/runtime": "npm:^7.12.5" "@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0"
@@ -11470,17 +11470,17 @@ __metadata:
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^3.0.0"
unhomoglyph: "npm:^1.0.6" unhomoglyph: "npm:^1.0.6"
uuid: "npm:13" uuid: "npm:13"
checksum: 10c0/d646b9214abbf0b9126760105edd9c57be7ffe8b53ae4acd5fefe841a51ad7d78fa57130922b3eac65ff2266b43f31ea60b4bdda9481e6bf8f1808d96726ed8a checksum: 10c0/2c4db56fd0164d801c2f125ab2a442e3659314d4cc2fd640ea152b829d0db8b05ff808020e387a761afde4ff7a07b271c25431337de9f7c765c523c8cd837e36
languageName: node languageName: node
linkType: hard linkType: hard
"matrix-widget-api@npm:^1.16.1": "matrix-widget-api@npm:^1.16.1":
version: 1.16.1 version: 1.17.0
resolution: "matrix-widget-api@npm:1.16.1" resolution: "matrix-widget-api@npm:1.17.0"
dependencies: dependencies:
"@types/events": "npm:^3.0.0" "@types/events": "npm:^3.0.0"
events: "npm:^3.2.0" events: "npm:^3.2.0"
checksum: 10c0/d88180f514104b84d3018055fc955138d65195465480a51e9afe5dbf2f3175b54e3483b4c4f1feab2dd27440f403051d9c8b293bd0532c09b136c6b23606e1ee checksum: 10c0/3651c860900149ecc2fe74640b47687bab8a347eb718a522085189e2b84efe462c9d81c1e8caff08d122f0b3e9cef8303a5802837673e5c9b465f7624c56a8f3
languageName: node languageName: node
linkType: hard linkType: hard