Merge branch 'livekit' into toger5/dont-trap-in-invalid-config

This commit is contained in:
Timo K
2026-01-05 14:37:48 +01:00
80 changed files with 5187 additions and 1708 deletions

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
`; `;
module.exports = { module.exports = {
plugins: ["matrix-org", "rxjs"], plugins: ["matrix-org", "rxjs", "jsdoc"],
extends: [ extends: [
"plugin:matrix-org/react", "plugin:matrix-org/react",
"plugin:matrix-org/a11y", "plugin:matrix-org/a11y",
@@ -26,6 +26,13 @@ module.exports = {
node: true, node: true,
}, },
rules: { rules: {
"jsdoc/no-types": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-values": "error",
"jsdoc/check-param-names": "warn",
// "jsdoc/require-param": "warn",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER], "matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off", "jsx-a11y/media-has-caption": "off",
"react/display-name": "error", "react/display-name": "error",
@@ -75,6 +82,23 @@ module.exports = {
"no-console": ["error"], "no-console": ["error"],
}, },
}, },
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**",
],
rules: {
"jsdoc/no-types": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-values": "off",
"jsdoc/check-param-names": "off",
"jsdoc/require-param-description": "off",
},
},
], ],
settings: { settings: {
react: { react: {

View File

@@ -61,6 +61,7 @@ jobs:
docker_tags: | docker_tags: |
type=sha,format=short,event=branch type=sha,format=short,event=branch
type=raw,value=${{ github.event.release.tag_name }} type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
# Like before, using ${{ env.VERSION }} above doesn't work # Like before, using ${{ env.VERSION }} above doesn't work
add_docker_release_note: add_docker_release_note:
needs: publish_docker needs: publish_docker

View File

@@ -47,7 +47,7 @@ services:
- ecbackend - ecbackend
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:v1.9.4
pull_policy: always pull_policy: always
hostname: livekit-sfu hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml command: --dev --config /etc/livekit.yaml
@@ -67,7 +67,7 @@ services:
- ecbackend - ecbackend
livekit-1: livekit-1:
image: livekit/livekit-server:latest image: livekit/livekit-server:v1.9.4
pull_policy: always pull_policy: always
hostname: livekit-sfu-1 hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml command: --dev --config /etc/livekit.yaml

View File

@@ -9,7 +9,7 @@ import { type KnipConfig } from "knip";
export default { export default {
vite: { vite: {
config: ["vite.config.ts", "vite-embedded.config.ts"], config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
}, },
entry: ["src/main.tsx", "i18next-parser.config.ts"], entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [ ignoreBinaries: [

View File

@@ -13,6 +13,8 @@
"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",
"serve": "vite preview", "serve": "vite preview",
"prettier:check": "prettier -c .", "prettier:check": "prettier -c .",
"prettier:format": "prettier -w .", "prettier:format": "prettier -w .",
@@ -54,7 +56,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.57.0",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3", "@radix-ui/react-visually-hidden": "^1.0.3",
@@ -93,6 +95,7 @@
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.8.2", "eslint-plugin-deprecate": "^0.8.2",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "2.1.0", "eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react": "^7.29.4",
@@ -109,8 +112,9 @@
"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": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21", "matrix-js-sdk": "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3",
"matrix-widget-api": "^1.13.0", "matrix-widget-api": "^1.14.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",
"pako": "^2.0.4", "pako": "^2.0.4",
@@ -133,6 +137,7 @@
"vite": "^7.0.0", "vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0", "vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-node-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0", "vite-plugin-svgr": "^4.0.0",
"vitest": "^3.0.0", "vitest": "^3.0.0",
"vitest-axe": "^1.0.0-pre.3" "vitest-axe": "^1.0.0-pre.3"

View File

@@ -38,6 +38,7 @@ export default defineConfig({
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
testIgnore: "**/mobile/**",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
permissions: [ permissions: [
@@ -56,9 +57,9 @@ export default defineConfig({
}, },
}, },
}, },
{ {
name: "firefox", name: "firefox",
testIgnore: "**/mobile/**",
use: { use: {
...devices["Desktop Firefox"], ...devices["Desktop Firefox"],
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
@@ -70,6 +71,27 @@ export default defineConfig({
}, },
}, },
}, },
{
name: "mobile",
testMatch: "**/mobile/**",
use: {
...devices["Pixel 7"],
ignoreHTTPSErrors: true,
permissions: [
"clipboard-write",
"clipboard-read",
"microphone",
"camera",
],
launchOptions: {
args: [
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--mute-audio",
],
},
},
},
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling // No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
// clear http to the homeserver // clear http to the homeserver

View File

@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createJTWToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => { test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/"); await page.goto("/");
@@ -93,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu", url: "wss://badurltotricktest/livekit/sfu",
jwt: "FAKE", jwt: createJTWToken("@fake:user", "!fake:room"),
}), }),
}), }),
); );

View File

@@ -0,0 +1,73 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Browser, type Page, test, expect } from "@playwright/test";
export interface MobileCreateFixtures {
asMobile: {
creatorPage: Page;
inviteLink: string;
};
}
export const mobileTest = test.extend<MobileCreateFixtures>({
asMobile: async ({ browser }, pUse) => {
const fixtures = await createCallAndInvite(browser);
await pUse({
creatorPage: fixtures.page,
inviteLink: fixtures.inviteLink,
});
},
});
/**
* Create a call and generate an invite link
*/
async function createCallAndInvite(
browser: Browser,
): Promise<{ page: Page; inviteLink: string }> {
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
await creatorPage.goto("/");
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
await creatorPage
.getByRole("button", { name: "Continue in browser" })
.click();
// join
await creatorPage.getByTestId("lobby_joinCall").click();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
return {
page: creatorPage,
inviteLink,
};
}

View File

@@ -0,0 +1,22 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
export function createJTWToken(sub: string, room: string): string {
return [
{}, // header
{
// payload
sub,
video: {
room,
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");
}

View File

@@ -67,7 +67,6 @@ const CONFIG_JSON = {
/** /**
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`. * Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
* @param page
*/ */
const setDevToolElementCallDevUrl = process.env.USE_DOCKER const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => { ? async (page: Page): Promise<void> => {
@@ -111,19 +110,27 @@ async function registerUser(
await page.getByRole("textbox", { name: "Confirm password" }).click(); await page.getByRole("textbox", { name: "Confirm password" }).click();
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD); await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click(); await page.getByRole("button", { name: "Register" }).click();
const continueButton = page.getByRole("button", { name: "Continue" });
try {
await expect(continueButton).toBeVisible({ timeout: 5000 });
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await continueButton.click();
} catch {
// continueButton not visible, continue as normal
}
await expect( await expect(
page.getByRole("heading", { name: `Welcome ${username}` }), page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible(); ).toBeVisible();
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// Dismiss incompatible browser toast
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
await setDevToolElementCallDevUrl(page); await setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() => const clientHandle = await page.evaluateHandle(() =>

View File

@@ -0,0 +1,115 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { mobileTest } from "../fixtures/fixture-mobile-create.ts";
test("@mobile Start a new call then leave and show the feedback screen", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// await page.pause();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Continue in browser" }).click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Ensure that the call is connected
await page
.locator("div")
.filter({ hasText: /^HelloCall$/ })
.click();
// Check the number of participants
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
// The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// leave the call
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(
"John Doe, your call has ended. How did it go?",
);
await expect(page.getByRole("main")).toContainText(
"Why not finish by setting up a password to keep your account?",
);
await expect(
page.getByRole("link", { name: "Not now, return to home screen" }),
).toBeVisible();
});
mobileTest(
"Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
const { creatorPage, inviteLink } = asMobile;
// ========
// ACT: The other user use the invite link to join the call as a guest
// ========
const guestInviteeContext = await browser.newContext({
reducedMotion: "reduce",
});
const guestPage = await guestInviteeContext.newPage();
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
await guestPage
.getByRole("button", { name: "Continue in browser" })
.click();
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
await guestPage.getByTestId("joincall_joincall").click();
await guestPage.getByTestId("lobby_joinCall").click();
// ========
// ASSERT: check that there are two members in the call
// ========
// There should be two participants now
await expect(
guestPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
// Same in creator page
await expect(
creatorPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
// TEST: control audio devices from the invitee page
await guestPage.evaluate(() => {
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
{ id: "earpiece", name: "Handset", isEarpiece: true },
{ id: "headphones", name: "Headphones" },
]);
window.controls.setAudioDevice("earpiece");
});
await expect(
guestPage.getByRole("heading", { name: "Handset Mode" }),
).toBeVisible();
await expect(
guestPage.getByRole("button", { name: "Back to Speaker Mode" }),
).toBeVisible();
// Should auto-mute the video when earpiece is selected
await expect(guestPage.getByTestId("incall_videomute")).toBeDisabled();
},
);

View File

@@ -68,11 +68,6 @@ test("When creator left, avoid reconnect to the same SFU", async ({
reducedMotion: "reduce", reducedMotion: "reduce",
}); });
const guestCPage = await guestC.newPage(); const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections // Track WebSocket connections
let wsConnectionCount = 0; let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => { await guestCPage.routeWebSocket("**", (ws) => {
@@ -100,5 +95,4 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// https://github.com/element-hq/element-call/issues/3344 // https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU // The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1); expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
}); });

35
sdk/README.md Normal file
View File

@@ -0,0 +1,35 @@
# SDK mode
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
It allows to use matrixRTC in combination with livekit without relying on element call.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver ellowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
## Widgets
The sdk mode is particularly interesting to be used in widgets where you do not need to pay attention to matrix login/cs api ...
To create a widget see the example index.html file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
```
widgetId = $matrix_widget_id
perParticipantE2EE = true
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)

55
sdk/helper.ts Normal file
View File

@@ -0,0 +1,55 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* This file contains helper functions and types for the MatrixRTC SDK.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { scan } from "rxjs";
import { widget as _widget } from "../src/widget";
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
if (!_widget) throw Error("No widget. This webapp can only start as a widget");
export const widget = _widget;
export const tryMakeSticky = (): void => {
logger.info("try making sticky MatrixRTCSdk");
void widget.api
.setAlwaysOnScreen(true)
.then(() => {
logger.info("sticky MatrixRTCSdk");
})
.catch((error) => {
logger.error("failed to make sticky MatrixRTCSdk", error);
});
};
export const TEXT_LK_TOPIC = "matrixRTC";
/**
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
*
* I think there should be a builtin for this but i did not find it...
*/
export const currentAndPrev = scan<
LivekitRoomItem[],
{
prev: LivekitRoomItem[];
current: LivekitRoomItem[];
}
>(
({ current: lastCurrentVal }, items) => ({
prev: lastCurrentVal,
current: items,
}),
{
prev: [],
current: [],
},
);

87
sdk/index.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html>
<head>
<title>Godot MatrixRTC Widget</title>
<meta charset="utf-8" />
<script type="module">
// TODO use the url where the matrixrtc-sdk.js file from dist is hosted
import { createMatrixRTCSdk } from "http://localhost:8123/matrixrtc-sdk.js";
try {
window.matrixRTCSdk = await createMatrixRTCSdk(
"com.github.toger5.godot-game",
);
console.info("createMatrixRTCSdk was created!");
} catch (e) {
console.error("createMatrixRTCSdk", e);
}
const sdk = window.matrixRTCSdk;
// This is the main bridging interface to godot
window.matrixRTCSdkGodot = {
dataObs: sdk.data$,
memberObs: sdk.members$,
// join: sdk.join, // lets stick with autojoin for now
sendData: sdk.sendData,
leave: sdk.leave,
connectedObs: sdk.connected$,
};
console.info("matrixRTCSdk join ", sdk);
const connectionState = sdk.join();
console.info("matrixRTCSdk joined");
const div = document.getElementById("data");
div.innerHTML = "<h3>Data:</h3>";
sdk.data$.subscribe((data) => {
const child = document.createElement("p");
child.innerHTML = JSON.stringify(data);
div.appendChild(child);
// TODO forward to godot
});
sdk.members$.subscribe((memberObjects) => {
// reset div
const div = document.getElementById("members");
div.innerHTML = "<h3>Members:</h3>";
// create member list
const members = memberObjects.map((member) => member.membership.sender);
console.info("members changed", members);
for (const m of members) {
console.info("member", m);
const child = document.createElement("p");
child.innerHTML = m;
div.appendChild(child);
}
});
sdk.connected$.subscribe((connected) => {
console.info("connected changed", connected);
const div = document.getElementById("connect_status");
div.innerHTML = connected ? "Connected" : "Disconnected";
});
let engine = new Engine($GODOT_CONFIG);
engine.startGame();
</script>
<!--// TODO use it as godot HTML template-->
<script src="$GODOT_URL"></script>
</head>
<body>
<canvas id="canvas"></canvas>
<div
id="overlay"
style="position: absolute; top: 0; right: 0; background-color: #ffffff10"
>
<div id="connect_status"></div>
<button onclick="window.matrixRTCSdk.leave();">Leave</button>
<button onclick="window.matrixRTCSdk.sendData({prop: 'Hello, world!'});">
Send Text
</button>
<div id="members"></div>
<div id="data"></div>
</div>
</body>
</html>

308
sdk/main.ts Normal file
View File

@@ -0,0 +1,308 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
* use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
* can be used to join a rtc session and exchange realtime data.
* It takes care of all the tricky bits:
* - sending delayed events
* - finding the right sfu
* - handling the media stream
* - sending join/leave state or sticky events
* - setting up encryption and scharing keys
*/
import {
combineLatest,
map,
type Observable,
of,
shareReplay,
Subject,
switchMap,
tap,
} from "rxjs";
import {
type CallMembership,
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
type Room as LivekitRoom,
type TextStreamReader,
type LocalParticipant,
type RemoteParticipant,
} from "livekit-client";
// TODO how can this get fixed? to just be part of `livekit-client`
// Can this be done in the tsconfig.json
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
import { type Behavior, constant } from "../src/state/Behavior";
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType";
import {
currentAndPrev,
logger,
TEXT_LK_TOPIC,
tryMakeSticky,
widget,
} from "./helper";
import { ElementWidgetActions } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
interface MatrixRTCSdk {
/**
* observe connected$ to track the state.
* @returns
*/
join: () => void;
/** @throws on leave errors */
leave: () => void;
data$: Observable<{ sender: string; data: string }>;
/**
* flattened list of members
*/
members$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | RemoteParticipant | null;
}[]
>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior<boolean>;
sendData?: (data: unknown) => Promise<void>;
}
export async function createMatrixRTCSdk(
application: string = "m.call",
id: string = "",
): Promise<MatrixRTCSdk> {
logger.info("Hello");
const client = await widget.client;
logger.info("client created");
const scope = new ObservableScope();
const { roomId } = getUrlParams();
if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client");
const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, constant(true));
const slot = { application, id };
const rtcSession = new MatrixRTCSession(
client,
room,
MatrixRTCSession.sessionMembershipsForSlot(room, slot),
slot,
);
const callViewModel = createCallViewModel$(
scope,
rtcSession,
room,
mediaDevices,
muteStates,
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
of({}),
of({}),
constant({ supported: false, processor: undefined }),
);
logger.info("CallViewModelCreated");
// create data listener
const data$ = new Subject<{ sender: string; data: string }>();
const lkTextStreamHandlerFunction = async (
reader: TextStreamReader,
participantInfo: { identity: string },
livekitRoom: LivekitRoom,
): Promise<void> => {
const info = reader.info;
logger.info(
`Received text stream from ${participantInfo.identity}\n` +
` Topic: ${info.topic}\n` +
` Timestamp: ${info.timestamp}\n` +
` ID: ${info.id}\n` +
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
);
const participants = callViewModel.livekitRoomItems$.value.find(
(i) => i.livekitRoom === livekitRoom,
)?.participants;
if (participants && participants.includes(participantInfo.identity)) {
const text = await reader.readAll();
logger.info(`Received text: ${text}`);
data$.next({ sender: participantInfo.identity, data: text });
} else {
logger.warn(
"Received text from unknown participant",
participantInfo.identity,
);
}
};
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
.pipe(
tap((beforecurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
beforecurrentAndPrev,
);
}),
currentAndPrev,
tap((aftercurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
aftercurrentAndPrev,
);
}),
)
.subscribe({
next: ({ prev, current }) => {
const prevRooms = prev.map((i) => i.livekitRoom);
const currentRooms = current.map((i) => i.livekitRoom);
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
addedRooms.forEach((r) => {
logger.info(`Registering text stream handler for room `);
r.registerTextStreamHandler(
TEXT_LK_TOPIC,
(reader, participantInfo) =>
void lkTextStreamHandlerFunction(reader, participantInfo, r),
);
});
removedRooms.forEach((r) => {
logger.info(`Unregistering text stream handler for room `);
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
});
},
complete: () => {
logger.info("Livekit room items subscription completed");
for (const item of callViewModel.livekitRoomItems$.value) {
logger.info("unregistering room item from room", item.url);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
}
},
});
// create sendData function
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
switchMap((m) => {
if (!m)
return of((data: string): never => {
throw Error("local membership not yet ready.");
});
return m.participant.value$.pipe(
map((p) => {
if (p === null) {
return (data: string): never => {
throw Error("local participant not yet ready to send data.");
};
} else {
return async (data: string): Promise<TextStreamInfo> =>
p.sendText(data, { topic: TEXT_LK_TOPIC });
}
}),
);
}),
),
);
const sendData = async (data: unknown): Promise<void> => {
const dataString = JSON.stringify(data);
logger.info("try sending: ", dataString);
try {
await Promise.resolve();
const info = await sendFn.value(dataString);
logger.info(`Sent text with stream ID: ${info.id}`);
} catch (e) {
logger.error("failed sending: ", dataString, e);
}
};
// after hangup gets called
const leaveSubs = callViewModel.leave$.subscribe(() => {
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
const leaveResolver = Promise.withResolvers<void>();
logger.info("waiting for RTC leave");
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
logger.info("received RTC join update: ", isJoined);
if (!isJoined) leaveResolver.resolve();
});
await leaveResolver.promise;
logger.info("send Unstick");
await widget.api
.setAlwaysOnScreen(false)
.catch((e) =>
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
),
);
logger.info("send Close");
await widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => logger.error("Failed to send close action", e));
};
// schedule close first and then leave (scope.end)
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");
return {
join: (): void => {
// first lets try making the widget sticky
tryMakeSticky();
callViewModel.join();
},
leave: (): void => {
callViewModel.hangup();
leaveSubs.unsubscribe();
livekitRoomItemsSub.unsubscribe();
},
data$,
connected$: callViewModel.connected$,
members$: scope.behavior(
callViewModel.matrixLivekitMembers$.pipe(
switchMap((members) => {
const listOfMemberObservables = members.map((member) =>
combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
// using shareReplay instead of a Behavior here because the behavior would need
// a tricky scope.end() setup.
shareReplay({ bufferSize: 1, refCount: true }),
),
);
return combineLatest(listOfMemberObservables);
}),
),
[],
),
sendData,
};
}

View File

@@ -332,6 +332,42 @@ describe("UrlParams", () => {
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false); expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
}); });
}); });
describe("noiseSuppression", () => {
it("defaults to true", () => {
expect(computeUrlParams().noiseSuppression).toBe(true);
});
it("is parsed", () => {
expect(
computeUrlParams("?intent=start_call&noiseSuppression=true")
.noiseSuppression,
).toBe(true);
expect(
computeUrlParams("?intent=start_call&noiseSuppression&bar=foo")
.noiseSuppression,
).toBe(true);
expect(computeUrlParams("?noiseSuppression=false").noiseSuppression).toBe(
false,
);
});
});
describe("echoCancellation", () => {
it("defaults to true", () => {
expect(computeUrlParams().echoCancellation).toBe(true);
});
it("is parsed", () => {
expect(computeUrlParams("?echoCancellation=true").echoCancellation).toBe(
true,
);
expect(computeUrlParams("?echoCancellation=false").echoCancellation).toBe(
false,
);
});
});
describe("header", () => { describe("header", () => {
it("uses header if provided", () => { it("uses header if provided", () => {
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe( expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(

View File

@@ -233,6 +233,17 @@ export interface UrlConfiguration {
*/ */
waitForCallPickup: boolean; waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent; callIntent?: RTCCallIntent;
} }
interface IntentAndPlatformDerivedConfiguration { interface IntentAndPlatformDerivedConfiguration {
@@ -525,6 +536,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
]), ]),
waitForCallPickup: parser.getFlag("waitForCallPickup"), waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
noiseSuppression: parser.getFlagParam("noiseSuppression", true),
echoCancellation: parser.getFlagParam("echoCancellation", true),
}; };
// Log the final configuration for debugging purposes. // Log the final configuration for debugging purposes.

View File

@@ -247,9 +247,8 @@ export class PosthogAnalytics {
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server // wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next // until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility. // page load). This will happen pretty infrequently, so we can tolerate the possibility.
const accountDataAnalyticsId = analyticsIdGenerator(); analyticsID = analyticsIdGenerator();
await this.setAccountAnalyticsId(accountDataAnalyticsId); await this.setAccountAnalyticsId(analyticsID);
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
} }
} catch (e) { } catch (e) {
// The above could fail due to network requests, but not essential to starting the application, // The above could fail due to network requests, but not essential to starting the application,
@@ -270,37 +269,14 @@ export class PosthogAnalytics {
private async getAnalyticsId(): Promise<string | null> { private async getAnalyticsId(): Promise<string | null> {
const client: MatrixClient = window.matrixclient; const client: MatrixClient = window.matrixclient;
let accountAnalyticsId: string | null;
if (widget) { if (widget) {
accountAnalyticsId = getUrlParams().posthogUserId; return getUrlParams().posthogUserId;
} else { } else {
const accountData = await client.getAccountDataFromServer( const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE, PosthogAnalytics.ANALYTICS_EVENT_TYPE,
); );
accountAnalyticsId = accountData?.id ?? null; return accountData?.id ?? null;
} }
if (accountAnalyticsId) {
// we dont just use the element web analytics ID because that would allow to associate
// users between the two posthog instances. By using a hash from the username and the element web analytics id
// it is not possible to conclude the element web posthog user id from the element call user id and vice versa.
return await this.hashedEcAnalyticsId(accountAnalyticsId);
}
return null;
}
private async hashedEcAnalyticsId(
accountAnalyticsId: string,
): Promise<string> {
const client: MatrixClient = window.matrixclient;
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
const bufferForPosthogId = await crypto.subtle.digest(
"sha-256",
new TextEncoder().encode(posthogIdMaterial),
);
const view = new Int32Array(bufferForPosthogId);
return Array.from(view)
.map((b) => Math.abs(b).toString(16).padStart(2, "0"))
.join("");
} }
private async setAccountAnalyticsId(analyticsID: string): Promise<void> { private async setAccountAnalyticsId(analyticsID: string): Promise<void> {

View File

@@ -34,8 +34,8 @@ const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`; `room-shared-key-${roomId}`;
/** /**
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`. * An up-to-date shared key for the room. Either from local storage or the value from `setInitialValue`.
* @param roomId * @param roomId The room ID we want the shared key for.
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this. * @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook. * @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
*/ */

View File

@@ -166,7 +166,11 @@ interface StereoPanAudioTrackProps {
* It main purpose is to remount the AudioTrack component when switching from * It main purpose is to remount the AudioTrack component when switching from
* audioContext to normal audio playback. * audioContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted. * As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0 * @param props The component props
* @param props.trackRef The track reference
* @param props.muted If the track should be muted
* @param props.audioContext The audio context to use
* @param props.audioNodes The audio nodes to use
* @returns * @returns
*/ */
function AudioTrackWithAudioNodes({ function AudioTrackWithAudioNodes({

View File

@@ -0,0 +1,112 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex) {
expect(((ex as Error).cause as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});

View File

@@ -11,9 +11,47 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../utils/errors"; import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig { export interface SFUConfig {
url: string; url: string;
jwt: string; jwt: string;
livekitAlias: string;
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
} }
// The bits we need from MatrixClient // The bits we need from MatrixClient
@@ -25,9 +63,9 @@ export type OpenIDClientParts = Pick<
* Gets a bearer token from the homeserver and then use it to authenticate * Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU. * to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy. * It has built-in retry for calls to the homeserver with a backoff policy.
* @param client * @param client The Matrix client
* @param serviceUrl * @param serviceUrl The URL of the livekit SFU service
* @param matrixRoomId * @param matrixRoomId The Matrix room ID for which to get the SFU config
* @returns Object containing the token information * @returns Object containing the token information
* @throws FailToGetOpenIdToken * @throws FailToGetOpenIdToken
*/ */
@@ -57,7 +95,17 @@ export async function getSFUConfigWithOpenID(
); );
logger.info(`Got JWT from call's active focus URL.`); logger.info(`Got JWT from call's active focus URL.`);
return sfuConfig; // Pull the details from the JWT
const [, payloadStr] = sfuConfig.jwt.split(".");
// TODO: Prefer Uint8Array.fromBase64 when widely available
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// NOTE: Currently unused.
livekitIdentity: payload.sub,
};
} }
async function getLiveKitJWT( async function getLiveKitJWT(
@@ -65,7 +113,7 @@ async function getLiveKitJWT(
livekitServiceURL: string, livekitServiceURL: string,
roomName: string, roomName: string,
openIDToken: IOpenIDToken, openIDToken: IOpenIDToken,
): Promise<SFUConfig> { ): Promise<{ url: string; jwt: string }> {
try { try {
const res = await fetch(livekitServiceURL + "/sfu/get", { const res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST", method: "POST",
@@ -83,6 +131,6 @@ async function getLiveKitJWT(
} }
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
throw new Error("SFU Config fetch failed with exception " + e); throw new Error("SFU Config fetch failed with exception", { cause: e });
} }
} }

View File

@@ -135,10 +135,10 @@ export class ReactionsReader {
} }
/** /**
* Fetchest any hand wave reactions by the given sender on the given * Fetches any hand wave reactions by the given sender on the given
* membership event. * membership event.
* @param membershipEventId * @param membershipEventId - The user membership event id.
* @param expectedSender * @param expectedSender - The expected sender of the reaction.
* @returns A MatrixEvent if one was found. * @returns A MatrixEvent if one was found.
*/ */
private getLastReactionEvent( private getLastReactionEvent(

View File

@@ -7,34 +7,17 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--cpd-space-2x); gap: var(--cpd-space-2x);
} transition: opacity 200ms;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
.overlay[data-show="true"] { .overlay[data-show="true"] {
animation: fade-in 200ms; opacity: 1;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
display: none;
}
} }
.overlay[data-show="false"] { .overlay[data-show="false"] {
animation: fade-out 130ms forwards; opacity: 0;
pointer-events: none; pointer-events: none;
transition-duration: 130ms;
} }
.overlay::before { .overlay::before {

View File

@@ -160,6 +160,7 @@ export const GroupCallView: FC<Props> = ({
}, [rtcSession]); }, [rtcSession]);
// TODO move this into the callViewModel LocalMembership.ts // TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,
@@ -313,6 +314,7 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate(); const navigate = useNavigate();
// TODO split this into leave and onDisconnect
const onLeft = useCallback( const onLeft = useCallback(
( (
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error", reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",

View File

@@ -24,7 +24,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { useObservable } from "observable-hooks"; import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
VoiceCallSolidIcon, VoiceCallSolidIcon,
VolumeOnSolidIcon, VolumeOnSolidIcon,
@@ -88,6 +88,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { import {
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
matrixRTCMode as matrixRTCModeSetting,
useSetting, useSetting,
} from "../settings/settings"; } from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader"; import { ReactionsReader } from "../reactions/ReactionsReader";
@@ -109,6 +110,8 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
import { type Layout } from "../state/layout-types.ts"; import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts"; import { ObservableScope } from "../state/ObservableScope.ts";
const logger = rootLogger.getChild("[InCallView]");
const maxTapDurationMs = 400; const maxTapDurationMs = 400;
export interface ActiveCallProps export interface ActiveCallProps
@@ -127,6 +130,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices(); const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$(); const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => { useEffect(() => {
logger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope(); const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession); const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
@@ -141,6 +145,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
encryptionSystem: props.e2eeSystem, encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
}, },
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
reactionsReader.reactions$, reactionsReader.reactions$,
@@ -151,7 +156,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
setVm(vm); setVm(vm);
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft); vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
return (): void => { return (): void => {
logger.info("END CALL VIEW SCOPE");
scope.end(); scope.end();
}; };
}, [ }, [
@@ -253,7 +260,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const audioParticipants = useBehavior(vm.audioParticipants$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
@@ -270,7 +277,10 @@ export const InCallView: FC<InCallViewProps> = ({
const ringOverlay = useBehavior(vm.ringOverlay$); const ringOverlay = useBehavior(vm.ringOverlay$);
const fatalCallError = useBehavior(vm.fatalError$); const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary // Stop the rendering and throw for the error boundary
if (fatalCallError) throw fatalCallError; if (fatalCallError) {
logger.debug("fatalCallError stop rendering", fatalCallError);
throw fatalCallError;
}
// We need to set the proper timings on the animation based upon the sound length. // We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;

View File

@@ -79,9 +79,9 @@ export const LobbyView: FC<Props> = ({
waitingForInvite, waitingForInvite,
}) => { }) => {
useEffect(() => { useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted"); logger.info("[Lifecycle] LobbyView Component mounted");
return (): void => { return (): void => {
logger.info("[Lifecycle] GroupCallView Component unmounted"); logger.info("[Lifecycle] LobbyView Component unmounted");
}; };
}, []); }, []);

View File

@@ -106,22 +106,18 @@ async function joinRoomAfterInvite(
export class CallTerminatedMessage extends Error { export class CallTerminatedMessage extends Error {
/** /**
* Creates a new CallTerminatedMessage.
*
* @param icon The icon to display with the message
* @param messageTitle The title of the call ended screen message (translated) * @param messageTitle The title of the call ended screen message (translated)
* @param messageBody The message explaining the kind of termination
* (kick, ban, knock reject, etc.) (translated)
* @param reason The user-provided reason for the termination (kick/ban)
*/ */
public constructor( public constructor(
/**
* The icon to display with the message.
*/
public readonly icon: ComponentType<SVGAttributes<SVGElement>>, public readonly icon: ComponentType<SVGAttributes<SVGElement>>,
messageTitle: string, messageTitle: string,
/**
* The message explaining the kind of termination (kick, ban, knock reject,
* etc.) (translated)
*/
public readonly messageBody: string, public readonly messageBody: string,
/**
* The user-provided reason for the termination (kick/ban)
*/
public readonly reason?: string, public readonly reason?: string,
) { ) {
super(messageTitle); super(messageTitle);

View File

@@ -99,7 +99,7 @@ class ConsoleLogger extends EventEmitter {
/** /**
* Returns the log lines to flush to disk and empties the internal log buffer * Returns the log lines to flush to disk and empties the internal log buffer
* @return {string} \n delimited log lines * @return \n delimited log lines
*/ */
public popLogs(): string { public popLogs(): string {
const logsToFlush = this.logs; const logsToFlush = this.logs;
@@ -109,7 +109,7 @@ class ConsoleLogger extends EventEmitter {
/** /**
* Returns lines currently in the log buffer without removing them * Returns lines currently in the log buffer without removing them
* @return {string} \n delimited log lines * @return \n delimited log lines
*/ */
public peekLogs(): string { public peekLogs(): string {
return this.logs; return this.logs;
@@ -139,7 +139,7 @@ class IndexedDBLogStore {
} }
/** /**
* @return {Promise} Resolves when the store is ready. * @return Resolves when the store is ready.
*/ */
public async connect(): Promise<void> { public async connect(): Promise<void> {
const req = this.indexedDB.open("logs"); const req = this.indexedDB.open("logs");
@@ -219,7 +219,7 @@ class IndexedDBLogStore {
* This guarantees that we will always eventually do a flush when flush() is * This guarantees that we will always eventually do a flush when flush() is
* called. * called.
* *
* @return {Promise} Resolved when the logs have been flushed. * @return Resolved when the logs have been flushed.
*/ */
public flush = async (): Promise<void> => { public flush = async (): Promise<void> => {
// check if a flush() operation is ongoing // check if a flush() operation is ongoing
@@ -270,7 +270,7 @@ class IndexedDBLogStore {
* returned are deleted at the same time, so this can be called at startup * returned are deleted at the same time, so this can be called at startup
* to do house-keeping to keep the logs from growing too large. * to do house-keeping to keep the logs from growing too large.
* *
* @return {Promise<Object[]>} Resolves to an array of objects. The array is * @return Resolves to an array of objects. The array is
* sorted in time (oldest first) based on when the log file was created (the * sorted in time (oldest first) based on when the log file was created (the
* log ID). The objects have said log ID in an "id" field and "lines" which * log ID). The objects have said log ID in an "id" field and "lines" which
* is a big string with all the new-line delimited logs. * is a big string with all the new-line delimited logs.
@@ -421,12 +421,12 @@ class IndexedDBLogStore {
/** /**
* Helper method to collect results from a Cursor and promiseify it. * Helper method to collect results from a Cursor and promiseify it.
* @param {ObjectStore|Index} store The store to perform openCursor on. * @param store - The store to perform openCursor on.
* @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor. * @param keyRange - Optional key range to apply on the cursor.
* @param {Function} resultMapper A function which is repeatedly called with a * @param resultMapper - A function which is repeatedly called with a
* Cursor. * Cursor.
* Return the data you want to keep. * Return the data you want to keep.
* @return {Promise<T[]>} Resolves to an array of whatever you returned from * @return Resolves to an array of whatever you returned from
* resultMapper. * resultMapper.
*/ */
async function selectQuery<T>( async function selectQuery<T>(
@@ -464,9 +464,7 @@ declare global {
/** /**
* Configure rage shaking support for sending bug reports. * Configure rage shaking support for sending bug reports.
* Modifies globals. * Modifies globals.
* @param {boolean} setUpPersistence When true (default), the persistence will * @return Resolves when set up.
* be set up immediately for the logs.
* @return {Promise} Resolves when set up.
*/ */
export async function init(): Promise<void> { export async function init(): Promise<void> {
global.mx_rage_logger = new ConsoleLogger(); global.mx_rage_logger = new ConsoleLogger();
@@ -503,7 +501,7 @@ export async function init(): Promise<void> {
/** /**
* Try to start up the rageshake storage for logs. If not possible (client unsupported) * Try to start up the rageshake storage for logs. If not possible (client unsupported)
* then this no-ops. * then this no-ops.
* @return {Promise} Resolves when complete. * @return Resolves when complete.
*/ */
async function tryInitStorage(): Promise<void> { async function tryInitStorage(): Promise<void> {
if (global.mx_rage_initStoragePromise) { if (global.mx_rage_initStoragePromise) {
@@ -536,7 +534,7 @@ async function tryInitStorage(): Promise<void> {
/** /**
* Get a recent snapshot of the logs, ready for attaching to a bug report * Get a recent snapshot of the logs, ready for attaching to a bug report
* *
* @return {LogEntry[]} list of log data * @return list of log data
*/ */
export async function getLogsForReport(): Promise<LogEntry[]> { export async function getLogsForReport(): Promise<LogEntry[]> {
if (!global.mx_rage_logger) { if (!global.mx_rage_logger) {

View File

@@ -81,7 +81,7 @@ export interface Props {
localUser: { deviceId: string; userId: string }; localUser: { deviceId: string; userId: string };
} }
/** /**
* @returns {callPickupState$, autoLeave$} * @returns two observables:
* `callPickupState$` The current call pickup state of the call. * `callPickupState$` The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not. * Then we can conclude if we were the first one to join or not.

View File

@@ -60,7 +60,8 @@ import {
import { MediaDevices } from "../MediaDevices.ts"; import { MediaDevices } from "../MediaDevices.ts";
import { getValue } from "../../utils/observable.ts"; import { getValue } from "../../utils/observable.ts";
import { type Behavior, constant } from "../Behavior.ts"; import { type Behavior, constant } from "../Behavior.ts";
import { withCallViewModel } from "./CallViewModelTestUtils.ts"; import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
import { MatrixRTCMode } from "../../settings/settings.ts";
vi.mock("rxjs", async (importOriginal) => ({ vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()), ...(await importOriginal()),
@@ -229,7 +230,13 @@ function mockRingEvent(
// need a value to fill in for them when emitting notifications // need a value to fill in for them when emitting notifications
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
describe("CallViewModel", () => { describe.each([
[MatrixRTCMode.Legacy],
[MatrixRTCMode.Compatibil],
[MatrixRTCMode.Matrix_2_0],
])("CallViewModel (%s mode)", (mode) => {
const withCallViewModel = withCallViewModelInMode(mode);
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3
@@ -502,6 +509,48 @@ describe("CallViewModel", () => {
}); });
}); });
test("layout reacts to window size", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const windowSizeInputMarbles = "abc";
const expectedLayoutMarbles = " abc";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
windowSize$: behavior(windowSizeInputMarbles, {
a: { width: 300, height: 600 }, // Start very narrow, like a phone
b: { width: 1000, height: 800 }, // Go to normal desktop window size
c: { width: 200, height: 180 }, // Go to PiP size
}),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
// This is the expected one-on-one layout for a narrow window
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
},
b: {
// In a larger window, expect the normal one-on-one layout
type: "one-on-one",
local: `${localId}:0`,
remote: `${aliceId}:0`,
},
c: {
// In a PiP-sized window, we of course expect a PiP layout
type: "pip",
spotlight: [`${aliceId}:0`],
},
},
);
},
);
});
});
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test
@@ -1207,7 +1256,9 @@ describe("CallViewModel", () => {
rtcSession.membershipStatus = Status.Connected; rtcSession.membershipStatus = Status.Connected;
}, },
n: () => { n: () => {
rtcSession.membershipStatus = Status.Reconnecting; // NOTE: This was removed in https://github.com/matrix-org/matrix-js-sdk/pull/5103 accidentally.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rtcSession.membershipStatus = "Reconnecting" as any;
}, },
}); });
schedule(probablyLeftMarbles, { schedule(probablyLeftMarbles, {

View File

@@ -15,7 +15,7 @@ import {
} from "livekit-client"; } from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk"; import { type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
BehaviorSubject, catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
@@ -53,11 +53,15 @@ import {
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
} from "../MediaViewModel"; } from "../MediaViewModel";
import { accumulate, generateItems, pauseWhen } from "../../utils/observable"; import {
accumulate,
filterBehavior,
generateItems,
pauseWhen,
} from "../../utils/observable";
import { import {
duplicateTiles, duplicateTiles,
MatrixRTCMode, MatrixRTCMode,
matrixRTCMode,
playReactionsSound, playReactionsSound,
showReactions, showReactions,
} from "../../settings/settings"; } from "../../settings/settings";
@@ -76,7 +80,7 @@ import {
} from "../../reactions"; } from "../../reactions";
import { shallowEquals } from "../../utils/array"; import { shallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevices } from "../MediaDevices";
import { type Behavior } from "../Behavior"; import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType"; import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates"; import { type MuteStates } from "../MuteStates";
@@ -94,14 +98,14 @@ import {
type SpotlightLandscapeLayoutMedia, type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia, type SpotlightPortraitLayoutMedia,
} from "../layout-types.ts"; } from "../layout-types.ts";
import { type ElementCallError } from "../../utils/errors.ts"; import { ElementCallError } from "../../utils/errors.ts";
import { type ObservableScope } from "../ObservableScope.ts"; import { type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import { import {
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
RTCBackendState, TransportState,
} from "./localMember/LocalMembership.ts"; } from "./localMember/LocalMember.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import { import {
createMemberships$, createMemberships$,
@@ -111,7 +115,9 @@ import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts"; import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts";
import { import {
createMatrixLivekitMembers$, createMatrixLivekitMembers$,
type MatrixLivekitMember, type TaggedParticipant,
type LocalMatrixLivekitMember,
type RemoteMatrixLivekitMember,
} from "./remoteMembers/MatrixLivekitMembers.ts"; } from "./remoteMembers/MatrixLivekitMembers.ts";
import { import {
type AutoLeaveReason, type AutoLeaveReason,
@@ -126,6 +132,7 @@ import {
} from "./remoteMembers/MatrixMemberMetadata.ts"; } from "./remoteMembers/MatrixMemberMetadata.ts";
import { Publisher } from "./localMember/Publisher.ts"; import { Publisher } from "./localMember/Publisher.ts";
import { type Connection } from "./remoteMembers/Connection.ts"; import { type Connection } from "./remoteMembers/Connection.ts";
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
const logger = rootLogger.getChild("[CallViewModel]"); const logger = rootLogger.getChild("[CallViewModel]");
//TODO //TODO
@@ -147,6 +154,10 @@ export interface CallViewModelOptions {
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom; livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
/** Optional behavior overriding the local connection state, mainly for testing purposes. */ /** Optional behavior overriding the local connection state, mainly for testing purposes. */
connectionState$?: Behavior<ConnectionState>; connectionState$?: Behavior<ConnectionState>;
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
windowSize$?: Behavior<{ width: number; height: number }>;
/** The version & compatibility mode of MatrixRTC that we should use. */
matrixRTCMode$?: Behavior<MatrixRTCMode>;
} }
// Do not play any sounds if the participant count has exceeded this // Do not play any sounds if the participant count has exceeded this
@@ -172,7 +183,7 @@ interface LayoutScanState {
} }
type MediaItem = UserMedia | ScreenShare; type MediaItem = UserMedia | ScreenShare;
type AudioLivekitItem = { export type LivekitRoomItem = {
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
participants: string[]; participants: string[];
url: string; url: string;
@@ -195,8 +206,11 @@ export interface CallViewModel {
callPickupState$: Behavior< callPickupState$: Behavior<
"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).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope.
*/
leave$: Observable<"user" | AutoLeaveReason>; leave$: Observable<"user" | AutoLeaveReason>;
/** Call to initiate hangup. Use in conbination with connectino state track the async hangup process. */ /** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */
hangup: () => void; hangup: () => void;
// joining // joining
@@ -248,7 +262,11 @@ export interface CallViewModel {
*/ */
participantCount$: Behavior<number>; participantCount$: Behavior<number>;
/** Participants sorted by livekit room so they can be used in the audio rendering */ /** Participants sorted by livekit room so they can be used in the audio rendering */
audioParticipants$: Behavior<AudioLivekitItem[]>; livekitRoomItems$: Behavior<LivekitRoomItem[]>;
userMedia$: Behavior<UserMedia[]>;
/** use the layout instead, this is just for the sdk export. */
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
/** List of participants raising their hand */ /** List of participants raising their hand */
handsRaised$: Behavior<Record<string, RaisedHandInfo>>; handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
@@ -331,18 +349,17 @@ export interface CallViewModel {
switch: () => void; switch: () => void;
} | null>; } | null>;
// connection state
/** /**
* Whether various media/event sources should pretend to be disconnected from * Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state.
* all network input, even if their connection still technically works.
*/ */
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
reconnecting$: Behavior<boolean>; reconnecting$: Behavior<boolean>;
/**
* Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit
*/
connected$: Behavior<boolean>;
} }
/** /**
* A view model providing all the application logic needed to show the in-call * A view model providing all the application logic needed to show the in-call
* UI (may eventually be expanded to cover the lobby and feedback screens in the * UI (may eventually be expanded to cover the lobby and feedback screens in the
@@ -370,6 +387,8 @@ export function createCallViewModel$(
options.encryptionSystem, options.encryptionSystem,
matrixRTCSession, matrixRTCSession,
); );
const matrixRTCMode$ =
options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy);
// Each hbar seperates a block of input variables required for the CallViewModel to function. // Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar. // The outputs of this block is written under the hbar.
@@ -402,7 +421,7 @@ export function createCallViewModel$(
client, client,
roomId: matrixRoom.roomId, roomId: matrixRoom.roomId,
useOldestMember$: scope.behavior( useOldestMember$: scope.behavior(
matrixRTCMode.value$.pipe(map((v) => v === MatrixRTCMode.Legacy)), matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
), ),
}); });
@@ -413,6 +432,8 @@ export function createCallViewModel$(
livekitKeyProvider, livekitKeyProvider,
getUrlParams().controlledAudioDevices, getUrlParams().controlledAudioDevices,
options.livekitRoomFactory, options.livekitRoomFactory,
getUrlParams().echoCancellation,
getUrlParams().noiseSuppression,
); );
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({
@@ -420,7 +441,18 @@ export function createCallViewModel$(
connectionFactory: connectionFactory, connectionFactory: connectionFactory,
inputTransports$: scope.behavior( inputTransports$: scope.behavior(
combineLatest( combineLatest(
[localTransport$, membershipsAndTransports.transports$], [
localTransport$.pipe(
catchError((e: unknown) => {
logger.info(
"dont pass local transport to createConnectionManager$. localTransport$ threw an error",
e,
);
return of(null);
}),
),
membershipsAndTransports.transports$,
],
(localTransport, transports) => { (localTransport, transports) => {
const localTransportAsArray = localTransport ? [localTransport] : []; const localTransportAsArray = localTransport ? [localTransport] : [];
return transports.mapInner((transports) => [ return transports.mapInner((transports) => [
@@ -430,7 +462,7 @@ export function createCallViewModel$(
}, },
), ),
), ),
logger: logger, logger,
}); });
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
@@ -441,7 +473,7 @@ export function createCallViewModel$(
}); });
const connectOptions$ = scope.behavior( const connectOptions$ = scope.behavior(
matrixRTCMode.value$.pipe( matrixRTCMode$.pipe(
map((mode) => ({ map((mode) => ({
encryptMedia: livekitKeyProvider !== undefined, encryptMedia: livekitKeyProvider !== undefined,
// TODO. This might need to get called again on each change of matrixRTCMode... // TODO. This might need to get called again on each change of matrixRTCMode...
@@ -452,13 +484,13 @@ export function createCallViewModel$(
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope: scope, scope: scope,
homeserverConnected$: createHomeserverConnected$( homeserverConnected: createHomeserverConnected$(
scope, scope,
client, client,
matrixRTCSession, matrixRTCSession,
), ),
muteStates: muteStates, muteStates: muteStates,
joinMatrixRTC: async (transport: LivekitTransport) => { joinMatrixRTC: (transport: LivekitTransport) => {
return enterRTCSession( return enterRTCSession(
matrixRTCSession, matrixRTCSession,
transport, transport,
@@ -473,7 +505,7 @@ export function createCallViewModel$(
muteStates, muteStates,
trackProcessorState$, trackProcessorState$,
logger.getChild( logger.getChild(
"[Publisher" + connection.transport.livekit_service_url + "]", "[Publisher " + connection.transport.livekit_service_url + "]",
), ),
); );
}, },
@@ -495,22 +527,21 @@ export function createCallViewModel$(
), ),
); );
const localMatrixLivekitMemberUninitialized = { const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
membership$: localRtcMembership$,
participant$: localMembership.participant$,
connection$: localMembership.connection$,
userId: userId,
};
const localMatrixLivekitMember$: Behavior<MatrixLivekitMember | null> =
scope.behavior( scope.behavior(
localRtcMembership$.pipe( localRtcMembership$.pipe(
switchMap((membership) => { filterBehavior((membership) => membership !== null),
if (!membership) return of(null); map((membership$) => {
return of( if (membership$ === null) return null;
// casting is save here since we know that localRtcMembership$ is !== null since we reached this case. return {
localMatrixLivekitMemberUninitialized as MatrixLivekitMember, membership$,
); participant: {
type: "local" as const,
value$: localMembership.participant$,
},
connection$: localMembership.connection$,
userId,
};
}), }),
), ),
); );
@@ -573,24 +604,16 @@ export function createCallViewModel$(
), ),
); );
/** const livekitRoomItems$ = scope.behavior(
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
const reconnecting$ = localMembership.reconnecting$;
const audioParticipants$ = scope.behavior(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
tap((val) => {
logger.debug("matrixLivekitMembers$ updated", val.value);
}),
switchMap((membersWithEpoch) => { switchMap((membersWithEpoch) => {
const members = membersWithEpoch.value; const members = membersWithEpoch.value;
const a$ = combineLatest( const a$ = combineLatest(
members.map((member) => members.map((member) =>
combineLatest([member.connection$, member.participant$]).pipe( combineLatest([member.connection$, member.participant.value$]).pipe(
map(([connection, participant]) => { map(([connection, participant]) => {
// do not render audio for local participant // do not render audio for local participant
if (!connection || !participant || participant.isLocal) if (!connection || !participant || participant.isLocal)
@@ -610,7 +633,7 @@ export function createCallViewModel$(
return a$; return a$;
}), }),
map((members) => map((members) =>
members.reduce<AudioLivekitItem[]>((acc, curr) => { members.reduce<LivekitRoomItem[]>((acc, curr) => {
if (!curr) return acc; if (!curr) return acc;
const existing = acc.find((item) => item.url === curr.url); const existing = acc.find((item) => item.url === curr.url);
@@ -631,7 +654,7 @@ export function createCallViewModel$(
); );
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(reconnecting$)), handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
); );
const reactions$ = scope.behavior( const reactions$ = scope.behavior(
@@ -644,7 +667,7 @@ export function createCallViewModel$(
]), ]),
), ),
), ),
pauseWhen(reconnecting$), pauseWhen(localMembership.reconnecting$),
), ),
); );
@@ -668,7 +691,7 @@ export function createCallViewModel$(
let localParticipantId: string | undefined = undefined; let localParticipantId: string | undefined = undefined;
// add local member if available // add local member if available
if (localMatrixLivekitMember) { if (localMatrixLivekitMember) {
const { userId, participant$, connection$, membership$ } = const { userId, participant, connection$, membership$ } =
localMatrixLivekitMember; localMatrixLivekitMember;
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID; // const participantId = membership$.value.membershipID;
@@ -679,7 +702,7 @@ export function createCallViewModel$(
dup, dup,
localParticipantId, localParticipantId,
userId, userId,
participant$, participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely
connection$, connection$,
], ],
data: undefined, data: undefined,
@@ -690,7 +713,7 @@ export function createCallViewModel$(
// add remote members that are available // add remote members that are available
for (const { for (const {
userId, userId,
participant$, participant,
connection$, connection$,
membership$, membership$,
} of matrixLivekitMembers) { } of matrixLivekitMembers) {
@@ -699,7 +722,7 @@ export function createCallViewModel$(
// const participantId = membership$.value?.identity; // const participantId = membership$.value?.identity;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [dup, participantId, userId, participant$, connection$], keys: [dup, participantId, userId, participant, connection$],
data: undefined, data: undefined,
}; };
} }
@@ -711,7 +734,7 @@ export function createCallViewModel$(
dup, dup,
participantId, participantId,
userId, userId,
participant$, participant,
connection$, connection$,
) => { ) => {
const livekitRoom$ = scope.behavior( const livekitRoom$ = scope.behavior(
@@ -730,12 +753,12 @@ export function createCallViewModel$(
scope, scope,
`${participantId}:${dup}`, `${participantId}:${dup}`,
userId, userId,
participant$, participant,
options.encryptionSystem, options.encryptionSystem,
livekitRoom$, livekitRoom$,
focusUrl$, focusUrl$,
mediaDevices, mediaDevices,
reconnecting$, localMembership.reconnecting$,
displayName$, displayName$,
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)), handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
@@ -940,20 +963,29 @@ export function createCallViewModel$(
), ),
); );
const hasRemoteScreenShares$: Observable<boolean> = spotlight$.pipe( const hasRemoteScreenShares$ = scope.behavior<boolean>(
map((spotlight) => spotlight$.pipe(
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel), map((spotlight) =>
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
),
), ),
distinctUntilChanged(),
); );
const pipEnabled$ = scope.behavior(setPipEnabled$, false); const pipEnabled$ = scope.behavior(setPipEnabled$, false);
const windowSize$ =
options.windowSize$ ??
scope.behavior<{ width: number; height: number }>(
fromEvent(window, "resize").pipe(
startWith(null),
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
),
);
// A guess at what the window's mode should be based on its size and shape.
const naturalWindowMode$ = scope.behavior<WindowMode>( const naturalWindowMode$ = scope.behavior<WindowMode>(
fromEvent(window, "resize").pipe( windowSize$.pipe(
map(() => { map(({ width, height }) => {
const height = window.innerHeight;
const width = window.innerWidth;
if (height <= 400 && width <= 340) return "pip"; if (height <= 400 && width <= 340) return "pip";
// Our layouts for flat windows are better at adapting to a small width // Our layouts for flat windows are better at adapting to a small width
// than our layouts for narrow windows are at adapting to a small height, // than our layouts for narrow windows are at adapting to a small height,
@@ -963,7 +995,6 @@ export function createCallViewModel$(
return "normal"; return "normal";
}), }),
), ),
"normal",
); );
/** /**
@@ -980,49 +1011,11 @@ export function createCallViewModel$(
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)), spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
); );
const gridModeUserSelection$ = new BehaviorSubject<GridMode>("grid"); const { setGridMode, gridMode$ } = createLayoutModeSwitch(
scope,
// Callback to set the grid mode desired by the user. windowMode$,
// Notice that this is only a preference, the actual grid mode can be overridden hasRemoteScreenShares$,
// if there is a remote screen share active. );
const setGridMode = (value: GridMode): void => {
gridModeUserSelection$.next(value);
};
/**
* The layout mode of the media tile grid.
*/
const gridMode$ =
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
scope.behavior<GridMode>(
gridModeUserSelection$.pipe(
switchMap((userSelection): Observable<GridMode> => {
if (userSelection === "spotlight") {
// If already in spotlight mode, stay there
return of("spotlight");
} else {
// Otherwise, check if there is a remote screen share active
// as this could force us into spotlight mode.
return combineLatest([hasRemoteScreenShares$, windowMode$]).pipe(
map(([hasScreenShares, windowMode]): GridMode => {
const isFlatMode = windowMode === "flat";
if (hasScreenShares || isFlatMode) {
logger.debug(
`Forcing spotlight mode, hasScreenShares=${hasScreenShares} windowMode=${windowMode}`,
);
// override to spotlight mode
return "spotlight";
} else {
// respect user choice
return "grid";
}
}),
);
}
}),
),
"grid",
);
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest( const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
[grid$, spotlight$], [grid$, spotlight$],
@@ -1449,13 +1442,44 @@ export function createCallViewModel$(
// reassigned here to make it publicly accessible // reassigned here to make it publicly accessible
const toggleScreenSharing = localMembership.toggleScreenSharing; const toggleScreenSharing = localMembership.toggleScreenSharing;
const errors$ = scope.behavior<{
transportError?: ElementCallError;
matrixError?: ElementCallError;
connectionError?: ElementCallError;
publishError?: ElementCallError;
} | null>(
localMembership.localMemberState$.pipe(
map((value) => {
const returnObject: {
transportError?: ElementCallError;
matrixError?: ElementCallError;
connectionError?: ElementCallError;
publishError?: ElementCallError;
} = {};
if (value instanceof ElementCallError) return { transportError: value };
if (value === TransportState.Waiting) return null;
if (value.matrix instanceof ElementCallError)
returnObject.matrixError = value.matrix;
if (value.media instanceof ElementCallError)
returnObject.publishError = value.media;
else if (
typeof value.media === "object" &&
value.media.connection instanceof ElementCallError
)
returnObject.connectionError = value.media.connection;
return returnObject;
}),
),
null,
);
return { return {
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
callPickupState$: callPickupState$, callPickupState$: callPickupState$,
ringOverlay$: ringOverlay$, ringOverlay$: ringOverlay$,
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join: localMembership.requestConnect, join: localMembership.requestJoinAndPublish,
toggleScreenSharing: toggleScreenSharing, toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$, sharingScreen$: sharingScreen$,
@@ -1465,16 +1489,21 @@ export function createCallViewModel$(
unhoverScreen: (): void => screenUnhover$.next(), unhoverScreen: (): void => screenUnhover$.next(),
fatalError$: scope.behavior( fatalError$: scope.behavior(
localMembership.connectionState.livekit$.pipe( errors$.pipe(
filter((v) => v.state === RTCBackendState.Error), map((errors) => {
map((s) => s.error), logger.debug("errors$ to compute any fatal errors:", errors);
return (
errors?.transportError ??
errors?.matrixError ??
errors?.connectionError ??
null
);
}),
filter((error) => error !== null),
), ),
null, null,
), ),
participantCount$: participantCount$, participantCount$: participantCount$,
audioParticipants$: audioParticipants$,
handsRaised$: handsRaised$, handsRaised$: handsRaised$,
reactions$: reactions$, reactions$: reactions$,
joinSoundEffect$: joinSoundEffect$, joinSoundEffect$: joinSoundEffect$,
@@ -1493,6 +1522,16 @@ export function createCallViewModel$(
spotlight$: spotlight$, spotlight$: spotlight$,
pip$: pip$, pip$: pip$,
layout$: layout$, layout$: layout$,
userMedia$,
localMatrixLivekitMember$,
matrixLivekitMembers$: scope.behavior(
matrixLivekitMembers$.pipe(
map((members) => members.value),
tap((v) => {
logger.debug("matrixLivekitMembers$ updated (exported)", v);
}),
),
),
tileStoreGeneration$: tileStoreGeneration$, tileStoreGeneration$: tileStoreGeneration$,
showSpotlightIndicators$: showSpotlightIndicators$, showSpotlightIndicators$: showSpotlightIndicators$,
showSpeakingIndicators$: showSpeakingIndicators$, showSpeakingIndicators$: showSpeakingIndicators$,
@@ -1500,7 +1539,9 @@ export function createCallViewModel$(
showFooter$: showFooter$, showFooter$: showFooter$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: reconnecting$, reconnecting$: localMembership.reconnecting$,
livekitRoomItems$,
connected$: localMembership.connected$,
}; };
} }

View File

@@ -53,6 +53,7 @@ import {
import { type Behavior, constant } from "../Behavior"; import { type Behavior, constant } from "../Behavior";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevices } from "../MediaDevices";
import { type MatrixRTCMode } from "../../settings/settings";
mockConfig({ mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" }, livekit: { livekit_service_url: "http://my-default-service-url.com" },
@@ -75,119 +76,130 @@ export interface CallViewModelInputs {
speaking: Map<Participant, Observable<boolean>>; speaking: Map<Participant, Observable<boolean>>;
mediaDevices: MediaDevices; mediaDevices: MediaDevices;
initialSyncState: SyncState; initialSyncState: SyncState;
windowSize$: Behavior<{ width: number; height: number }>;
} }
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
export function withCallViewModel( export function withCallViewModel(mode: MatrixRTCMode) {
{ return (
remoteParticipants$ = constant([]), {
rtcMembers$ = constant([localRtcMember]), remoteParticipants$ = constant([]),
livekitConnectionState$: connectionState$ = constant( rtcMembers$ = constant([localRtcMember]),
ConnectionState.Connected, livekitConnectionState$: connectionState$ = constant(
), ConnectionState.Connected,
speaking = new Map(), ),
mediaDevices = mockMediaDevices({}), speaking = new Map(),
initialSyncState = SyncState.Syncing, mediaDevices = mockMediaDevices({}),
}: Partial<CallViewModelInputs> = {}, initialSyncState = SyncState.Syncing,
continuation: ( windowSize$ = constant({ width: 1000, height: 800 }),
vm: CallViewModel, }: Partial<CallViewModelInputs> = {},
rtcSession: MockRTCSession, continuation: (
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> }, vm: CallViewModel,
setSyncState: (value: SyncState) => void, rtcSession: MockRTCSession,
) => void, subjects: {
options: CallViewModelOptions = { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, },
autoLeaveWhenOthersLeft: false, setSyncState: (value: SyncState) => void,
}, ) => void,
): void { options: Partial<CallViewModelOptions> = {},
let syncState = initialSyncState; ): void => {
const setSyncState = (value: SyncState): void => { let syncState = initialSyncState;
const prev = syncState; const setSyncState = (value: SyncState): void => {
syncState = value; const prev = syncState;
room.client.emit(ClientEvent.Sync, value, prev); syncState = value;
}; room.client.emit(ClientEvent.Sync, value, prev);
const room = mockMatrixRoom({ };
client: new (class extends EventEmitter { const room = mockMatrixRoom({
public getUserId(): string | undefined { client: new (class extends EventEmitter {
return localRtcMember.userId; public getUserId(): string | undefined {
} return localRtcMember.userId;
}
public getDeviceId(): string { public getDeviceId(): string {
return localRtcMember.deviceId; return localRtcMember.deviceId;
} }
public getDomain(): string { public getDomain(): string {
return "example.com"; return "example.com";
} }
public getSyncState(): SyncState { public getSyncState(): SyncState {
return syncState; return syncState;
} }
})() as Partial<MatrixClient> as MatrixClient, })() as Partial<MatrixClient> as MatrixClient,
getMembers: () => Array.from(roomMembers.values()), getMembers: () => Array.from(roomMembers.values()),
getMembersWithMembership: () => Array.from(roomMembers.values()), getMembersWithMembership: () => Array.from(roomMembers.values()),
}); });
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$); const rtcSession = new MockRTCSession(room, []).withMemberships(
const participantsSpy = vi rtcMembers$,
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants$);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
of({ participant: p } as Partial<
ComponentsCore.ParticipantMedia<LocalParticipant>
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
); );
const eventsSpy = vi const participantsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents") .spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockImplementation((p, ...eventTypes) => { .mockReturnValue(remoteParticipants$);
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) { const mediaSpy = vi
return (speaking.get(p) ?? of(false)).pipe( .spyOn(ComponentsCore, "observeParticipantMedia")
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant), .mockImplementation((p) =>
); of({ participant: p } as Partial<
} else { ComponentsCore.ParticipantMedia<LocalParticipant>
return of(p); > as ComponentsCore.ParticipantMedia<LocalParticipant>),
} );
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p, ...eventTypes) => {
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
return (speaking.get(p) ?? of(false)).pipe(
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
);
} else {
return of(p);
}
});
const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector")
.mockImplementation((_room, _eventType) => of());
const muteStates = mockMuteStates();
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>(
{},
);
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
mediaDevices,
muteStates,
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
connectionState$,
windowSize$,
matrixRTCMode$: constant(mode),
...options,
},
raisedHands$,
reactions$,
new BehaviorSubject<ProcessorState>({
processor: undefined,
supported: undefined,
}),
);
onTestFinished(() => {
participantsSpy.mockRestore();
mediaSpy.mockRestore();
eventsSpy.mockRestore();
roomEventSelectorSpy.mockRestore();
}); });
const roomEventSelectorSpy = vi continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
.spyOn(ComponentsCore, "roomEventSelector") };
.mockImplementation((_room, _eventType) => of());
const muteStates = mockMuteStates();
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
mediaDevices,
muteStates,
{
...options,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
connectionState$,
},
raisedHands$,
reactions$,
new BehaviorSubject<ProcessorState>({
processor: undefined,
supported: undefined,
}),
);
onTestFinished(() => {
participantsSpy.mockRestore();
mediaSpy.mockRestore();
eventsSpy.mockRestore();
roomEventSelectorSpy.mockRestore();
});
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
} }

View File

@@ -0,0 +1,132 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, test } from "vitest";
import { createLayoutModeSwitch } from "./LayoutSwitch";
import { testScope, withTestScheduler } from "../../utils/test";
function testLayoutSwitch({
windowMode = "n",
hasScreenShares = "n",
userSelection = "",
expectedGridMode,
}: {
windowMode?: string;
hasScreenShares?: string;
userSelection?: string;
expectedGridMode: string;
}): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const { gridMode$, setGridMode } = createLayoutModeSwitch(
testScope(),
behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }),
behavior(hasScreenShares, { y: true, n: false }),
);
schedule(userSelection, {
g: () => setGridMode("grid"),
s: () => setGridMode("spotlight"),
});
expectObservable(gridMode$).toBe(expectedGridMode, {
g: "grid",
s: "spotlight",
});
});
}
describe("default mode", () => {
test("uses grid layout by default", () =>
testLayoutSwitch({
expectedGridMode: "g",
}));
test("uses spotlight mode when window mode is flat", () =>
testLayoutSwitch({
windowMode: " f",
expectedGridMode: "s",
}));
});
test("allows switching modes manually", () =>
testLayoutSwitch({
userSelection: " --sgs",
expectedGridMode: "g-sgs",
}));
test("switches to spotlight mode when there is a remote screen share", () =>
testLayoutSwitch({
hasScreenShares: " n--y",
expectedGridMode: "g--s",
}));
test("can manually switch to grid when there is a screenshare", () =>
testLayoutSwitch({
hasScreenShares: " n-y",
userSelection: " ---g",
expectedGridMode: "g-sg",
}));
test("auto-switches after manually selecting grid", () =>
testLayoutSwitch({
// Two screenshares will happen in sequence. There is a screen share that
// forces spotlight, then the user manually switches back to grid.
hasScreenShares: " n-y-ny",
userSelection: " ---g",
expectedGridMode: "g-sg-s",
// If we did want to respect manual selection, the expectation would be: g-sg
}));
test("switches back to grid mode when the remote screen share ends", () =>
testLayoutSwitch({
hasScreenShares: " n--y--n",
expectedGridMode: "g--s--g",
}));
test("auto-switches to spotlight again after first screen share ends", () =>
testLayoutSwitch({
hasScreenShares: " nyny",
expectedGridMode: "gsgs",
}));
test("switches manually to grid after screen share while manually in spotlight", () =>
testLayoutSwitch({
// Initially, no one is sharing. Then the user manually switches to spotlight.
// After a screen share starts, the user manually switches to grid.
hasScreenShares: " n-y",
userSelection: " -s-g",
expectedGridMode: "gs-g",
}));
test("auto-switches to spotlight when in flat window mode", () =>
testLayoutSwitch({
// First normal, then narrow, then flat.
windowMode: " nNf",
expectedGridMode: "g-s",
}));
test("allows switching modes manually when in flat window mode", () =>
testLayoutSwitch({
// Window becomes flat, then user switches to grid and back.
// Finally the window returns to a normal shape.
windowMode: " nf--n",
userSelection: " --gs",
expectedGridMode: "gsgsg",
}));
test("stays in spotlight while there are screen shares even when window mode changes", () =>
testLayoutSwitch({
windowMode: " nfn",
hasScreenShares: " y",
expectedGridMode: "s",
}));
test("ignores end of screen share until window mode returns to normal", () =>
testLayoutSwitch({
windowMode: " nf-n",
hasScreenShares: " y-n",
expectedGridMode: "s--g",
}));

View File

@@ -0,0 +1,93 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
combineLatest,
map,
Subject,
startWith,
skipWhile,
switchMap,
} from "rxjs";
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
import { constant, type Behavior } from "../Behavior.ts";
import { type ObservableScope } from "../ObservableScope.ts";
/**
* Creates a layout mode switch that allows switching between grid and spotlight modes.
* The actual layout mode might switch automatically to spotlight if there is a
* remote screen share active or if the window mode is flat.
*
* @param scope - The observable scope to manage subscriptions.
* @param windowMode$ - The current window mode.
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
*/
export function createLayoutModeSwitch(
scope: ObservableScope,
windowMode$: Behavior<WindowMode>,
hasRemoteScreenShares$: Behavior<boolean>,
): {
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
} {
const userSelection$ = new Subject<GridMode>();
// Callback to set the grid mode desired by the user.
// Notice that this is only a preference, the actual grid mode can be overridden
// if there is a remote screen share active.
const setGridMode = (value: GridMode): void => userSelection$.next(value);
/**
* The natural grid mode - the mode that the grid would prefer to be in,
* not accounting for the user's manual selections.
*/
const naturalGridMode$ = scope.behavior<GridMode>(
combineLatest(
[hasRemoteScreenShares$, windowMode$],
(hasRemoteScreenShares, windowMode) =>
// When there are screen shares or the window is flat (as with a phone
// in landscape orientation), spotlight is a better experience.
// We want screen shares to be big and readable, and we want flipping
// your phone into landscape to be a quick way of maximising the
// spotlight tile.
hasRemoteScreenShares || windowMode === "flat" ? "spotlight" : "grid",
),
);
/**
* The layout mode of the media tile grid.
*/
const gridMode$ = scope.behavior<GridMode>(
// Whenever the user makes a selection, we enter a new mode of behavior:
userSelection$.pipe(
map((selection) => {
if (selection === "grid")
// The user has selected grid mode. Start by respecting their choice,
// but then follow the natural mode again as soon as it matches.
return naturalGridMode$.pipe(
skipWhile((naturalMode) => naturalMode !== selection),
startWith(selection),
);
// The user has selected spotlight mode. If this matches the natural
// mode, then follow the natural mode going forward.
return selection === naturalGridMode$.value
? naturalGridMode$
: constant(selection);
}),
// Initially the mode of behavior is to just follow the natural grid mode.
startWith(naturalGridMode$),
// Switch between each mode of behavior.
switchMap((mode$) => mode$),
),
);
return {
gridMode$,
setGridMode,
};
}

View File

@@ -97,106 +97,106 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is // LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay. // easy enough to read them so I think they can stay.
it("is false when sync state is not Syncing", () => { it("is false when sync state is not Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("remains false while membership status is not Connected even if sync is Syncing", () => { it("remains false while membership status is not Connected even if sync is Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // membership still disconnected expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
}); });
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Make sync loop OK // Make sync loop OK
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection // Indicate probable leave before connection
session.setProbablyLeft(true); session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("becomes true only when all three conditions are satisfied", () => { it("becomes true only when all three conditions are satisfied", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// 1. Sync loop connected // 1. Sync loop connected
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // not yet membership connected expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
// 2. Membership connected // 2. Membership connected
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); // probablyLeft is false expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
}); });
it("drops back to false when sync loop leaves Syncing", () => { it("drops back to false when sync loop leaves Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Reach connected state // Reach connected state
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Sync loop error => should flip false // Sync loop error => should flip false
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("drops back to false when membership status becomes disconnected", () => { it("drops back to false when membership status becomes disconnected", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setMembershipStatus(Status.Disconnected); session.setMembershipStatus(Status.Disconnected);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("drops to false when ProbablyLeft is emitted after being true", () => { it("drops to false when ProbablyLeft is emitted after being true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => { it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Simulate clearing the flag (in realistic scenario membership manager would update) // Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
}); });
it("composite sequence reflects each individual failure reason", () => { it("composite sequence reflects each individual failure reason", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Initially false (sync error + disconnected + not probably left) // Initially false (sync error + disconnected + not probably left)
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Fix sync only // Fix sync only
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Fix membership // Fix membership
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Introduce probablyLeft -> false // Introduce probablyLeft -> false
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Restore notProbablyLeft -> true again // Restore notProbablyLeft -> true again
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Drop sync -> false // Drop sync -> false
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
}); });

View File

@@ -25,6 +25,11 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
*/ */
const logger = rootLogger.getChild("[HomeserverConnected]"); const logger = rootLogger.getChild("[HomeserverConnected]");
export interface HomeserverConnected {
combined$: Behavior<boolean>;
rtsSession$: Behavior<Status>;
}
/** /**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver * Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session. * for the purposes of a MatrixRTC session.
@@ -39,7 +44,7 @@ export function createHomeserverConnected$(
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">, client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter & matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">, Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
): Behavior<boolean> { ): HomeserverConnected {
const syncing$ = ( const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe( ).pipe(
@@ -47,12 +52,15 @@ export function createHomeserverConnected$(
map(([state]) => state === SyncState.Syncing), map(([state]) => state === SyncState.Syncing),
); );
const membershipConnected$ = fromEvent( const rtsSession$ = scope.behavior<Status>(
matrixRTCSession, fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
MembershipManagerEvent.StatusChanged, map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
).pipe( ),
startWith(null), Status.Unknown,
map(() => matrixRTCSession.membershipStatus === Status.Connected), );
const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
); );
// This is basically notProbablyLeft$ // This is basically notProbablyLeft$
@@ -71,15 +79,13 @@ export function createHomeserverConnected$(
map(() => matrixRTCSession.probablyLeft !== true), map(() => matrixRTCSession.probablyLeft !== true),
); );
const connectedCombined$ = and$( const combined$ = scope.behavior(
syncing$, and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
membershipConnected$, tap((connected) => {
certainlyConnected$, logger.info(`Homeserver connected update: ${connected}`);
).pipe( }),
tap((connected) => { ),
logger.info(`Homeserver connected update: ${connected}`);
}),
); );
return scope.behavior(connectedCombined$); return { combined$, rtsSession$ };
} }

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
Status as RTCMemberStatus,
type LivekitTransport, type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -14,11 +15,7 @@ import { describe, expect, it, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { BehaviorSubject, map, of } from "rxjs"; import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import { type LocalParticipant, type LocalTrack } from "livekit-client";
ConnectionState as LivekitConnectionState,
type LocalParticipant,
type LocalTrack,
} from "livekit-client";
import { MatrixRTCMode } from "../../../settings/settings"; import { MatrixRTCMode } from "../../../settings/settings";
import { import {
@@ -29,15 +26,17 @@ import {
withTestScheduler, withTestScheduler,
} from "../../../utils/test"; } from "../../../utils/test";
import { import {
TransportState,
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
RTCBackendState, PublishState,
} from "./LocalMembership"; TrackState,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors"; import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { type Connection } from "../remoteMembers/Connection"; import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy; const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
@@ -51,7 +50,7 @@ vi.mock("@livekit/components-core", () => ({
describe("LocalMembership", () => { describe("LocalMembership", () => {
describe("enterRTCSession", () => { describe("enterRTCSession", () => {
it("It joins the correct Session", async () => { it("It joins the correct Session", () => {
const focusFromOlderMembership = { const focusFromOlderMembership = {
type: "livekit", type: "livekit",
livekit_service_url: "http://my-oldest-member-service-url.com", livekit_service_url: "http://my-oldest-member-service-url.com",
@@ -107,7 +106,7 @@ describe("LocalMembership", () => {
joinRoomSession: vi.fn(), joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
await enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
@@ -136,7 +135,7 @@ describe("LocalMembership", () => {
); );
}); });
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => { it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
mockConfig({}); mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({ vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [ "org.matrix.msc4143.rtc_foci": [
@@ -165,7 +164,7 @@ describe("LocalMembership", () => {
joinRoomSession: vi.fn(), joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
await enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
@@ -190,7 +189,6 @@ describe("LocalMembership", () => {
leaveRoomSession: () => {}, leaveRoomSession: () => {},
} as unknown as MatrixRTCSession, } as unknown as MatrixRTCSession,
muteStates: mockMuteStates(), muteStates: mockMuteStates(),
isHomeserverConnected: constant(true),
trackProcessorState$: constant({ trackProcessorState$: constant({
supported: false, supported: false,
processor: undefined, processor: undefined,
@@ -198,20 +196,20 @@ describe("LocalMembership", () => {
logger: logger, logger: logger,
createPublisherFactory: vi.fn(), createPublisherFactory: vi.fn(),
joinMatrixRTC: async (): Promise<void> => {}, joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected$: constant(true), homeserverConnected: {
combined$: constant(true),
rtsSession$: constant(RTCMemberStatus.Connected),
},
}; };
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 goodTransport = { const localTransport$ = scope.behavior<null | LivekitTransport>(
livekit_service_url: "other",
} as LivekitTransport;
const localTransport$ = scope.behavior<LivekitTransport>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
goodTransport, null,
); );
// we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = { const mockConnectionManager = {
transports$: scope.behavior( transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))), localTransport$.pipe(map((t) => new Epoch([t]))),
@@ -227,15 +225,11 @@ describe("LocalMembership", () => {
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport$, localTransport$,
}); });
localMembership.requestJoinAndPublish();
expectObservable(localMembership.connectionState.livekit$).toBe("ne", { expectObservable(localMembership.localMemberState$).toBe("ne", {
n: { state: RTCBackendState.WaitingForConnection }, n: TransportState.Waiting,
e: { e: expect.toSatisfy((e) => e instanceof MatrixRTCTransportMissingError),
state: RTCBackendState.Error,
error: expect.toSatisfy(
(e) => e instanceof MatrixRTCTransportMissingError,
),
},
}); });
}); });
}); });
@@ -247,33 +241,26 @@ describe("LocalMembership", () => {
livekit_service_url: "b", livekit_service_url: "b",
} as LivekitTransport; } as LivekitTransport;
const connectionManagerData = new ConnectionManagerData(); const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
connectionManagerData.add( localParticipant: {
{ isScreenShareEnabled: false,
livekitRoom: mockLivekitRoom({ trackPublications: [],
localParticipant: { } as unknown as LocalParticipant,
isScreenShareEnabled: false, }),
trackPublications: [], state$: constant(ConnectionState.LivekitConnected),
} as unknown as LocalParticipant, transport: aTransport,
}), } as unknown as Connection;
state$: constant({ const connectionTransportAConnecting = {
state: "ConnectedToLkRoom", ...connectionTransportAConnected,
livekitConnectionState$: constant(LivekitConnectionState.Connected), state$: constant(ConnectionState.LivekitConnecting),
}), livekitRoom: mockLivekitRoom({}),
transport: aTransport, } as unknown as Connection;
} as unknown as Connection, const connectionTransportBConnected = {
[], state$: constant(ConnectionState.LivekitConnected),
); transport: bTransport,
connectionManagerData.add( livekitRoom: mockLivekitRoom({}),
{ } as unknown as Connection;
state$: constant({
state: "ConnectedToLkRoom",
}),
transport: bTransport,
} as unknown as Connection,
[],
);
it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => { it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
@@ -281,13 +268,17 @@ describe("LocalMembership", () => {
const localTransport$ = new BehaviorSubject(aTransport); const localTransport$ = new BehaviorSubject(aTransport);
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
let seed = 0;
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => { () => {
const a = seed;
seed += 1;
logger.info(`creating [${a}]`);
const p = { const p = {
stopPublishing: vi.fn(), stopPublishing: vi.fn().mockImplementation(() => {
logger.info(`stopPublishing [${a}]`);
}),
stopTracks: vi.fn(), stopTracks: vi.fn(),
publishing$: constant(false),
}; };
publishers.push(p as unknown as Publisher); publishers.push(p as unknown as Publisher);
return p; return p;
@@ -298,6 +289,9 @@ describe("LocalMembership", () => {
typeof vi.fn typeof vi.fn
>; >;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
connectionManagerData.add(connectionTransportBConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
@@ -322,7 +316,7 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// stop all tracks after ending scopes // stop all tracks after ending scopes
expect(publishers[1].stopPublishing).toHaveBeenCalled(); expect(publishers[1].stopPublishing).toHaveBeenCalled();
expect(publishers[1].stopTracks).toHaveBeenCalled(); // expect(publishers[1].stopTracks).toHaveBeenCalled();
defaultCreateLocalMemberValues.createPublisherFactory.mockReset(); defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
}); });
@@ -357,6 +351,9 @@ describe("LocalMembership", () => {
typeof vi.fn typeof vi.fn
>; >;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
@@ -367,15 +364,17 @@ describe("LocalMembership", () => {
}); });
await flushPromises(); await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
expect(localMembership.tracks$.value.length).toBe(0); // expect(localMembership.tracks$.value.length).toBe(0);
expect(publishers[0].createAndSetupTracks).not.toHaveBeenCalled();
localMembership.startTracks(); localMembership.startTracks();
await flushPromises(); await flushPromises();
expect(localMembership.tracks$.value.length).toBe(2); expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
// expect(localMembership.tracks$.value.length).toBe(2);
scope.end(); scope.end();
await flushPromises(); await flushPromises();
// stop all tracks after ending scopes // stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled(); expect(publishers[0].stopPublishing).toHaveBeenCalled();
expect(publishers[0].stopTracks).toHaveBeenCalled(); // expect(publishers[0].stopTracks).toHaveBeenCalled();
publisherFactory.mockClear(); publisherFactory.mockClear();
}); });
// TODO add an integration test combining publisher and localMembership // TODO add an integration test combining publisher and localMembership
@@ -383,10 +382,11 @@ describe("LocalMembership", () => {
it("tracks livekit state correctly", async () => { it("tracks livekit state correctly", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new BehaviorSubject<null | LivekitTransport>(null); const localTransport$ = new BehaviorSubject<null | LivekitTransport>(null);
const connectionManagerData$ = new BehaviorSubject< const connectionManagerData$ = new BehaviorSubject(
Epoch<ConnectionManagerData> new Epoch(connectionManagerData),
>(new Epoch(new ConnectionManagerData())); );
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]); const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
@@ -432,61 +432,96 @@ describe("LocalMembership", () => {
}); });
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual(
state: RTCBackendState.WaitingForTransport, TransportState.Waiting,
}); );
localTransport$.next(aTransport); localTransport$.next(aTransport);
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
state: RTCBackendState.WaitingForConnection, matrix: RTCMemberStatus.Connected,
media: { connection: null, tracks: TrackState.WaitingForUser },
}); });
connectionManagerData$.next(new Epoch(connectionManagerData));
const connectionManagerData2 = new ConnectionManagerData();
connectionManagerData2.add(
// clone because we will mutate this later.
{ ...connectionTransportAConnecting } as unknown as Connection,
[],
);
connectionManagerData$.next(new Epoch(connectionManagerData2));
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
state: RTCBackendState.Initialized, matrix: RTCMemberStatus.Connected,
media: {
connection: ConnectionState.LivekitConnecting,
tracks: TrackState.WaitingForUser,
},
}); });
(
connectionManagerData2.getConnectionForTransport(aTransport)!
.state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
media: {
connection: ConnectionState.LivekitConnected,
tracks: TrackState.WaitingForUser,
},
});
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
expect(localMembership.tracks$.value.length).toBe(0); // expect(localMembership.tracks$.value.length).toBe(0);
// ------- // -------
localMembership.startTracks(); localMembership.startTracks();
// ------- // -------
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ // expect(localMembership.localMemberState$.value).toStrictEqual({
state: RTCBackendState.CreatingTracks, // matrix: RTCMemberStatus.Connected,
}); // media: {
// tracks: TrackState.Creating,
// connection: ConnectionState.LivekitConnected,
// },
// });
createTrackResolver.resolve(); createTrackResolver.resolve();
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(
state: RTCBackendState.ReadyToPublish, // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); (localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.WaitingForUser);
// ------- // -------
localMembership.requestConnect(); localMembership.requestJoinAndPublish();
// ------- // -------
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(
state: RTCBackendState.WaitingToPublish, // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); (localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.Publishing);
publishResolver.resolve(); publishResolver.resolve();
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(
state: RTCBackendState.Connected, // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); (localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.Publishing);
expect(publishers[0].stopPublishing).not.toHaveBeenCalled(); expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
expect(localMembership.connectionState.livekit$.isStopped).toBe(false); expect(localMembership.localMemberState$.isStopped).toBe(false);
scope.end(); scope.end();
await flushPromises(); await flushPromises();
// stays in connected state because it is stopped before the update to tracks update the state. // stays in connected state because it is stopped before the update to tracks update the state.
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(
state: RTCBackendState.Connected, // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); (localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.Publishing);
// stop all tracks after ending scopes // stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled(); expect(publishers[0].stopPublishing).toHaveBeenCalled();
expect(publishers[0].stopTracks).toHaveBeenCalled(); // expect(publishers[0].stopTracks).toHaveBeenCalled();
}); });
// TODO add tests for matrix local matrix participation. // TODO add tests for matrix local matrix participation.
}); });

View File

@@ -6,15 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type LocalTrack,
type Participant, type Participant,
ParticipantEvent, ParticipantEvent,
type LocalParticipant, type LocalParticipant,
type ScreenShareCaptureOptions, type ScreenShareCaptureOptions,
ConnectionState, RoomEvent,
MediaDeviceFailure,
} from "livekit-client"; } from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core"; import { observeParticipantEvents } from "@livekit/components-core";
import { import {
Status as RTCSessionStatus,
type LivekitTransport, type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -24,10 +25,11 @@ import {
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
from, from,
fromEvent,
map, map,
type Observable, type Observable,
of, of,
scan, pairwise,
startWith, startWith,
switchMap, switchMap,
tap, tap,
@@ -35,74 +37,73 @@ import {
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { constant, type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
import { ObservableScope } from "../../ObservableScope"; import { type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates"; import { type MuteStates } from "../../MuteStates.ts";
import { and$ } from "../../../utils/observable";
import { import {
ElementCallError, ElementCallError,
FailToStartLivekitConnection,
MembershipManagerError, MembershipManagerError,
UnknownCallError, UnknownCallError,
} from "../../../utils/errors"; } from "../../../utils/errors.ts";
import { ElementWidgetActions, widget } from "../../../widget"; import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts"; import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { type Connection } from "../remoteMembers/Connection.ts"; import {
ConnectionState,
type Connection,
type FailedToStartError,
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { and$ } from "../../../utils/observable.ts";
export enum RTCBackendState { export enum TransportState {
Error = "error",
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
WaitingForTransport = "waiting_for_transport", Waiting = "transport_waiting",
/** A connection appeared so we can initialise the publisher */
WaitingForConnection = "waiting_for_connection",
/** Connection and transport arrived, publisher Initialized */
Initialized = "Initialized",
CreatingTracks = "creating_tracks",
ReadyToPublish = "ready_to_publish",
WaitingToPublish = "waiting_to_publish",
Connected = "connected",
Disconnected = "disconnected",
Disconnecting = "disconnecting",
} }
type LocalMemberRtcBackendState = export enum PublishState {
| { state: RTCBackendState.Error; error: ElementCallError } WaitingForUser = "publish_waiting_for_user",
| { state: RTCBackendState.WaitingForTransport } // XXX: This state is removed for now since we do not have full control over
| { state: RTCBackendState.WaitingForConnection } // track publication anymore with the publisher abstraction, might come back in the future?
| { state: RTCBackendState.Initialized } // /** Implies lk connection is connected */
| { state: RTCBackendState.CreatingTracks } // Starting = "publish_start_publishing",
| { state: RTCBackendState.ReadyToPublish } /** Implies lk connection is connected */
| { state: RTCBackendState.WaitingToPublish } Publishing = "publish_publishing",
| { state: RTCBackendState.Connected }
| { state: RTCBackendState.Disconnected }
| { state: RTCBackendState.Disconnecting };
export enum MatrixState {
WaitingForTransport = "waiting_for_transport",
Ready = "ready",
Connecting = "connecting",
Connected = "connected",
Disconnected = "disconnected",
Error = "Error",
} }
type LocalMemberMatrixState = // TODO not sure how to map that correctly with the
| { state: MatrixState.Connected } // new publisher that does not manage tracks itself anymore
| { state: MatrixState.WaitingForTransport } export enum TrackState {
| { state: MatrixState.Ready } /** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
| { state: MatrixState.Connecting } WaitingForUser = "tracks_waiting_for_user",
| { state: MatrixState.Disconnected } // XXX: This state is removed for now since we do not have full control over
| { state: MatrixState.Error; error: Error }; // track creation anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
export interface LocalMemberConnectionState { // Creating = "tracks_creating",
livekit$: Behavior<LocalMemberRtcBackendState>; /** Implies lk connection is connected */
matrix$: Behavior<LocalMemberMatrixState>; Ready = "tracks_ready",
} }
export type LocalMemberMediaState =
| {
tracks: TrackState;
connection: ConnectionState | FailedToStartError;
}
| PublishState
| ElementCallError;
export type LocalMemberState =
| ElementCallError
| TransportState.Waiting
| {
media: LocalMemberMediaState;
matrix: ElementCallError | RTCSessionStatus;
};
/* /*
* - get well known * - get well known
* - get oldest membership * - get oldest membership
@@ -122,8 +123,8 @@ interface Props {
muteStates: MuteStates; muteStates: MuteStates;
connectionManager: IConnectionManager; connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransport) => Promise<void>; joinMatrixRTC: (transport: LivekitTransport) => void;
homeserverConnected$: Behavior<boolean>; homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LivekitTransport | null>; localTransport$: Behavior<LivekitTransport | null>;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
@@ -137,7 +138,16 @@ interface Props {
* We want * We want
* - a publisher * - a publisher
* - * -
* @param param0 * @param props The properties required to create the local membership.
* @param props.scope The observable scope to use.
* @param props.connectionManager The connection manager to get connections from.
* @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.homeserverConnected The homeserver connected state.
* @param props.localTransport$ The local transport to use for publishing.
* @param props.logger The logger to use.
* @param props.muteStates The mute states for video and audio.
* @param props.matrixRTCSession The matrix RTC session to join.
* @returns * @returns
* - publisher: The handle to create tracks and publish them to the room. * - publisher: The handle to create tracks and publish them to the room.
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
@@ -149,7 +159,7 @@ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionManager,
localTransport$: localTransportCanThrow$, localTransport$: localTransportCanThrow$,
homeserverConnected$, homeserverConnected,
createPublisherFactory, createPublisherFactory,
joinMatrixRTC, joinMatrixRTC,
logger: parentLogger, logger: parentLogger,
@@ -157,28 +167,37 @@ export const createLocalMembership$ = ({
matrixRTCSession, matrixRTCSession,
}: Props): { }: Props): {
/** /**
* This starts audio and video tracks. They will be reused when calling `requestConnect`. * This request to start audio and video tracks.
* Can be called early to pre-emptively get media permissions and start devices.
*/ */
startTracks: () => Behavior<LocalTrack[]>; startTracks: () => void;
/** /**
* This sets a inner state (shouldConnect) to true and instructs the js-sdk and livekit to keep the user * This sets a inner state (shouldPublish) to true and instructs the js-sdk and livekit to keep the user
* connected to matrix and livekit. * connected to matrix and livekit.
*/ */
requestConnect: () => void; requestJoinAndPublish: () => void;
requestDisconnect: () => void; requestDisconnect: () => void;
connectionState: LocalMemberConnectionState; localMemberState$: Behavior<LocalMemberState>;
sharingScreen$: Behavior<boolean>; sharingScreen$: Behavior<boolean>;
/** /**
* Callback to toggle screen sharing. If null, screen sharing is not possible. * Callback to toggle screen sharing. If null, screen sharing is not possible.
*/ */
toggleScreenSharing: (() => void) | null; toggleScreenSharing: (() => void) | null;
tracks$: Behavior<LocalTrack[]>; // tracks$: Behavior<LocalTrack[]>;
participant$: Behavior<LocalParticipant | null>; participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>; connection$: Behavior<Connection | null>;
homeserverConnected$: Behavior<boolean>; /**
// this needs to be discussed * Tracks the homserver and livekit connected state and based on that computes reconnecting.
/** @deprecated use state instead*/ */
reconnecting$: Behavior<boolean>; reconnecting$: Behavior<boolean>;
/** Shorthand for homeserverConnected.rtcSession === Status.Disconnected
* Direct translation to the js-sdk membership manager connection `Status`.
*/
disconnected$: Behavior<boolean>;
/**
* Fully connected
*/
connected$: Behavior<boolean>;
} => { } => {
const logger = parentLogger.getChild("[LocalMembership]"); const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`); logger.debug(`Creating local membership..`);
@@ -197,7 +216,7 @@ export const createLocalMembership$ = ({
: new Error("Unknown error from localTransport"), : new Error("Unknown error from localTransport"),
); );
} }
setLivekitError(error); setTransportError(error);
return of(null); return of(null);
}), }),
), ),
@@ -224,94 +243,59 @@ export const createLocalMembership$ = ({
), ),
); );
const localConnectionState$ = localConnection$.pipe( // Tracks error that happen when creating the local tracks.
switchMap((connection) => (connection ? connection.state$ : of(null))), const mediaErrors$ = localConnection$.pipe(
); switchMap((connection) => {
if (!connection) {
// /** return of(null);
// * Whether we are "fully" connected to the call. Accounts for both the } else {
// * connection to the MatrixRTC session and the LiveKit publish connection. return fromEvent(
// */ connection.livekitRoom,
const connected$ = scope.behavior( RoomEvent.MediaDevicesError,
and$( (error: Error) => {
homeserverConnected$.pipe( return MediaDeviceFailure.getFailure(error) ?? null;
tap((v) => logger.debug("matrix: Connected state changed", v)), },
), );
localConnectionState$.pipe( }
switchMap((state) => { }),
logger.debug("livekit: Connected state changed", state);
if (!state) return of(false);
if (state.state === "ConnectedToLkRoom") {
logger.debug(
"livekit: Connected state changed (inner livekitConnectionState$)",
state.livekitConnectionState$.value,
);
return state.livekitConnectionState$.pipe(
map((lkState) => lkState === ConnectionState.Connected),
);
}
return of(false);
}),
),
).pipe(tap((v) => logger.debug("combined: Connected state changed", v))),
); );
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
if (error) {
logger.error(`Failed to create local tracks:`, error);
setMatrixError(
// TODO is it fatal? Do we need to create a new Specialized Error?
new UnknownCallError(new Error(`Media device error: ${error}`)),
);
}
});
// MATRIX RELATED // MATRIX RELATED
// /**
// * Whether we should tell the user that we're reconnecting to the call.
// */
// DISCUSSION is there a better way to do this?
// sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
const reconnecting$ = scope.behavior(
connected$.pipe(
// We are reconnecting if we previously had some successful initial
// connection but are now disconnected
scan(
({ connectedPreviously }, connectedNow) => ({
connectedPreviously: connectedPreviously || connectedNow,
reconnecting: connectedPreviously && !connectedNow,
}),
{ connectedPreviously: false, reconnecting: false },
),
map(({ reconnecting }) => reconnecting),
),
);
// This should be used in a combineLatest with publisher$ to connect. // This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved. // to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested = Promise.withResolvers<void>(); const trackStartRequested = Promise.withResolvers<void>();
// This should be used in a combineLatest with publisher$ to connect. // This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved. // to make it possible to call startTracks before the preferredTransport$ has resolved.
const connectRequested$ = new BehaviorSubject(false); const joinAndPublishRequested$ = new BehaviorSubject(false);
/** /**
* The publisher is stored in here an abstracts creating and publishing tracks. * The publisher is stored in here an abstracts creating and publishing tracks.
*/ */
const publisher$ = new BehaviorSubject<Publisher | null>(null); const publisher$ = new BehaviorSubject<Publisher | null>(null);
/**
* Extract the tracks from the published. Also reacts to changing publishers.
*/
const tracks$ = scope.behavior(
publisher$.pipe(switchMap((p) => (p?.tracks$ ? p.tracks$ : constant([])))),
);
const publishing$ = scope.behavior(
publisher$.pipe(switchMap((p) => p?.publishing$ ?? constant(false))),
);
const startTracks = (): Behavior<LocalTrack[]> => { const startTracks = (): void => {
trackStartRequested.resolve(); trackStartRequested.resolve();
return tracks$; // This used to return the tracks, but now they are only accessible via the publisher.
}; };
const requestConnect = (): void => { const requestJoinAndPublish = (): void => {
trackStartRequested.resolve(); trackStartRequested.resolve();
connectRequested$.next(true); joinAndPublishRequested$.next(true);
}; };
const requestDisconnect = (): void => { const requestDisconnect = (): void => {
connectRequested$.next(false); joinAndPublishRequested$.next(false);
}; };
// Take care of the publisher$ // Take care of the publisher$
@@ -323,25 +307,30 @@ export const createLocalMembership$ = ({
// - overwrite current publisher // - overwrite current publisher
scope.reconcile(localConnection$, async (connection) => { scope.reconcile(localConnection$, async (connection) => {
if (connection !== null) { if (connection !== null) {
publisher$.next(createPublisherFactory(connection)); const publisher = createPublisherFactory(connection);
publisher$.next(publisher);
// Clean-up callback
return Promise.resolve(async (): Promise<void> => {
await publisher.stopPublishing();
await publisher.stopTracks();
});
} }
return Promise.resolve(async (): Promise<void> => {
await publisher$?.value?.stopPublishing();
publisher$?.value?.stopTracks();
});
}); });
// Use reconcile here to not run concurrent createAndSetupTracks calls // Use reconcile here to not run concurrent createAndSetupTracks calls
// `tracks$` will update once they are ready. // `tracks$` will update once they are ready.
scope.reconcile( scope.reconcile(
scope.behavior( scope.behavior(
combineLatest([publisher$, tracks$, from(trackStartRequested.promise)]), combineLatest([
publisher$ /*, tracks$*/,
from(trackStartRequested.promise),
]),
null, null,
), ),
async (valueIfReady) => { async (valueIfReady) => {
if (!valueIfReady) return; if (!valueIfReady) return;
const [publisher, tracks] = valueIfReady; const [publisher] = valueIfReady;
if (publisher && tracks.length === 0) { if (publisher) {
await publisher.createAndSetupTracks().catch((e) => logger.error(e)); await publisher.createAndSetupTracks().catch((e) => logger.error(e));
} }
}, },
@@ -349,140 +338,215 @@ export const createLocalMembership$ = ({
// Based on `connectRequested$` we start publishing tracks. (once they are there!) // Based on `connectRequested$` we start publishing tracks. (once they are there!)
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([publisher$, tracks$, connectRequested$])), scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
async ([publisher, tracks, shouldConnect]) => { async ([publisher, shouldJoinAndPublish]) => {
if (shouldConnect === publisher?.publishing$.value) return; // Get the current publishing state to avoid redundant calls.
if (tracks.length !== 0 && shouldConnect) { const isPublishing = publisher?.shouldPublish === true;
if (shouldJoinAndPublish && !isPublishing) {
try { try {
await publisher?.startPublishing(); await publisher?.startPublishing();
} catch (error) { } catch (error) {
setLivekitError(error as ElementCallError); const message =
error instanceof Error ? error.message : String(error);
setPublishError(new FailToStartLivekitConnection(message));
} }
} else if (tracks.length !== 0 && !shouldConnect) { } else if (isPublishing) {
try { try {
await publisher?.stopPublishing(); await publisher?.stopPublishing();
} catch (error) { } catch (error) {
setLivekitError(new UnknownCallError(error as Error)); setPublishError(new UnknownCallError(error as Error));
} }
} }
}, },
); );
const fatalLivekitError$ = new BehaviorSubject<ElementCallError | null>(null); // STATE COMPUTATION
const setLivekitError = (e: ElementCallError): void => {
if (fatalLivekitError$.value !== null) // These are non fatal since we can join a room and concume media even though publishing failed.
logger.error("Multiple Livkit Errors:", e); const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
else fatalLivekitError$.next(e); const setPublishError = (e: ElementCallError): void => {
if (publishError$.value !== null) {
logger.error("Multiple Media Errors:", e);
} else {
publishError$.next(e);
}
}; };
const livekitState$: Behavior<LocalMemberRtcBackendState> = scope.behavior(
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
null,
);
const setTransportError = (e: ElementCallError): void => {
if (fatalTransportError$.value !== null) {
logger.error("Multiple Transport Errors:", e);
} else {
fatalTransportError$.next(e);
}
};
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([ combineLatest([
publisher$, localConnectionState$,
localTransport$, localTransport$,
tracks$.pipe( joinAndPublishRequested$,
tap((t) => {
logger.info("tracks$: ", t);
}),
),
publishing$,
connectRequested$,
from(trackStartRequested.promise).pipe( from(trackStartRequested.promise).pipe(
map(() => true), map(() => true),
startWith(false), startWith(false),
), ),
fatalLivekitError$,
]).pipe( ]).pipe(
map( map(
([ ([
publisher, localConnectionState,
localTransport, localTransport,
tracks, shouldPublish,
publishing,
shouldConnect,
shouldStartTracks, shouldStartTracks,
error,
]) => { ]) => {
// read this: if (!localTransport) return null;
// if(!<A>) return {state: ...} const trackState: TrackState = shouldStartTracks
// if(!<B>) return {state: <MyState>} ? TrackState.Ready
// : TrackState.WaitingForUser;
// as:
// We do have <A> but not yet <B> so we are in <MyState> if (
if (error !== null) return { state: RTCBackendState.Error, error }; localConnectionState !== ConnectionState.LivekitConnected ||
const hasTracks = tracks.length > 0; trackState !== TrackState.Ready
if (!localTransport) )
return { state: RTCBackendState.WaitingForTransport }; return {
if (!publisher) connection: localConnectionState,
return { state: RTCBackendState.WaitingForConnection }; tracks: trackState,
if (!shouldStartTracks) return { state: RTCBackendState.Initialized }; };
if (!hasTracks) return { state: RTCBackendState.CreatingTracks }; if (!shouldPublish) return PublishState.WaitingForUser;
if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish }; // if (!publishing) return PublishState.Starting;
if (!publishing) return { state: RTCBackendState.WaitingToPublish }; return PublishState.Publishing;
return { state: RTCBackendState.Connected };
}, },
), ),
distinctUntilChanged(deepCompare), distinctUntilChanged(deepCompare),
), ),
); );
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null); const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
const setMatrixError = (e: ElementCallError): void => { const setMatrixError = (e: ElementCallError): void => {
if (fatalMatrixError$.value !== null) if (fatalMatrixError$.value !== null) {
logger.error("Multiple Matrix Errors:", e); logger.error("Multiple Matrix Errors:", e);
else fatalMatrixError$.next(e); } else {
fatalMatrixError$.next(e);
}
}; };
const matrixState$: Behavior<LocalMemberMatrixState> = scope.behavior(
const localMemberState$ = scope.behavior<LocalMemberState>(
combineLatest([ combineLatest([
localTransport$, mediaState$,
connectRequested$, homeserverConnected.rtsSession$,
homeserverConnected$, fatalMatrixError$,
fatalTransportError$,
publishError$,
]).pipe( ]).pipe(
map(([localTransport, connectRequested, homeserverConnected]) => { map(
if (!localTransport) return { state: MatrixState.WaitingForTransport }; ([
if (!connectRequested) return { state: MatrixState.Ready }; mediaState,
if (!homeserverConnected) return { state: MatrixState.Connecting }; rtcSessionStatus,
return { state: MatrixState.Connected }; fatalMatrixError,
}), fatalTransportError,
publishError,
]) => {
if (fatalTransportError !== null) return fatalTransportError;
// `mediaState` will be 'null' until the transport/connection appears.
if (mediaState && rtcSessionStatus)
return {
matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
};
return TransportState.Waiting;
},
),
), ),
); );
// Keep matrix rtc session in sync with localTransport$, connectRequested$ and muteStates.video.enabled$ /**
* Whether we are "fully" connected to the call. Accounts for both the
* connection to the MatrixRTC session and the LiveKit publish connection.
*/
const matrixAndLivekitConnected$ = scope.behavior(
and$(
homeserverConnected.combined$,
localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected),
),
).pipe(
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
),
);
/**
* Whether we should tell the user that we're reconnecting to the call.
*/
const reconnecting$ = scope.behavior(
matrixAndLivekitConnected$.pipe(
pairwise(),
map(([prev, current]) => prev === true && current === false),
),
false,
);
// inform the widget about the connect and disconnect intent from the user.
scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
undefined,
joinAndPublishRequested$.value,
])
.subscribe(([prev, current]) => {
if (!widget) return;
// JOIN prev=false (was left) => current-true (now joiend)
if (!prev && current) {
widget.api.transport
.send(ElementWidgetActions.JoinCall, {})
.catch((e) => {
logger.error("Failed to send join action", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
widget.api.transport
.send(ElementWidgetActions.HangupCall, {})
.catch((e) => {
logger.error("Failed to send hangup action", e);
});
}
});
combineLatest([muteStates.video.enabled$, homeserverConnected.combined$])
.pipe(scope.bind())
.subscribe(([videoEnabled, connected]) => {
if (!connected) return;
void matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio");
});
// Keep matrix rtc session in sync with localTransport$, connectRequested$
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([localTransport$, connectRequested$])), scope.behavior(combineLatest([localTransport$, joinAndPublishRequested$])),
async ([transport, shouldConnect]) => { async ([transport, shouldConnect]) => {
if (!transport) return;
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
if (!shouldConnect) return; if (!shouldConnect) return;
if (!transport) return;
try { try {
await joinMatrixRTC(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)
setMatrixError(new MembershipManagerError(error)); setMatrixError(new MembershipManagerError(error));
} }
// Update our member event when our mute state changes. return Promise.resolve(async (): Promise<void> => {
const callIntentScope = new ObservableScope();
// because this uses its own scope, we can start another reconciliation for the duration of one connection.
callIntentScope.reconcile(
muteStates.video.enabled$,
async (videoEnabled) =>
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
);
return async (): Promise<void> => {
callIntentScope.end();
try { try {
// Update matrixRTCSession to allow udpating the transport without leaving the session! // TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
await matrixRTCSession.leaveRoomSession(); await matrixRTCSession.leaveRoomSession(1000);
} catch (e) { } catch (e) {
logger.error("Error leaving RTC session", e); logger.error("Error leaving RTC session", e);
} }
try { });
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
};
}, },
); );
@@ -497,7 +561,7 @@ export const createLocalMembership$ = ({
// pause tracks during the initial joining sequence too until we're sure // pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen. // that our own media is displayed on screen.
// TODO refactor this based no livekitState$ // TODO refactor this based no livekitState$
combineLatest([participant$, homeserverConnected$]) combineLatest([participant$, homeserverConnected.combined$])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([participant, connected]) => { .subscribe(([participant, connected]) => {
if (!participant) return; if (!participant) return;
@@ -582,16 +646,17 @@ export const createLocalMembership$ = ({
return { return {
startTracks, startTracks,
requestConnect, requestJoinAndPublish,
requestDisconnect, requestDisconnect,
connectionState: { localMemberState$,
livekit$: livekitState$,
matrix$: matrixState$,
},
tracks$,
participant$, participant$,
homeserverConnected$,
reconnecting$, reconnecting$,
connected$: matrixAndLivekitConnected$,
disconnected$: scope.behavior(
homeserverConnected.rtsSession$.pipe(
map((state) => state === RTCSessionStatus.Disconnected),
),
),
sharingScreen$, sharingScreen$,
toggleScreenSharing, toggleScreenSharing,
connection$: localConnection$, connection$: localConnection$,
@@ -620,17 +685,19 @@ interface EnterRTCSessionOptions {
* - Delay events management * - Delay events management
* - Handles retries (fails only after several attempts) * - Handles retries (fails only after several attempts)
* *
* @param rtcSession * @param rtcSession - The MatrixRTCSession to join.
* @param transport * @param transport - The LivekitTransport to use for this session.
* @param options * @param options - Options for entering the RTC session.
* @param options.encryptMedia - Whether to encrypt media.
* @param options.matrixRTCMode - The Matrix RTC mode to use.
* @throws If the widget could not send ElementWidgetActions.JoinCall action. * @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/ */
// Exported for unit testing // Exported for unit testing
export async function enterRTCSession( export function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
transport: LivekitTransport, transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions, { encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): Promise<void> { ): void {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -669,7 +736,4 @@ export async function enterRTCSession(
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
}, },
); );
if (widget) {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
}
} }

View File

@@ -5,9 +5,18 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockedObject,
vi,
} from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import { mockConfig, flushPromises } from "../../../utils/test"; import { mockConfig, flushPromises } from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport"; import { createLocalTransport$ } from "./LocalTransport";
@@ -18,31 +27,22 @@ import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
} from "../../../utils/errors"; } from "../../../utils/errors";
import * as openIDSFU from "../../../livekit/openIDSFU"; import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures";
describe("LocalTransport", () => { describe("LocalTransport", () => {
const openIdResponse: openIDSFU.SFUConfig = {
url: "https://lk.example.org",
jwt: testJWTToken,
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
};
let scope: ObservableScope; let scope: ObservableScope;
beforeEach(() => (scope = new ObservableScope())); beforeEach(() => {
afterEach(() => scope.end()); scope = new ObservableScope();
it("throws if config is missing", async () => {
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => localTransport$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
}); });
afterEach(() => scope.end());
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport // Provide a valid config so makeTransportInternal resolves a transport
@@ -61,12 +61,14 @@ describe("LocalTransport", () => {
const errors: Error[] = []; const errors: Error[] = [];
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
// Use empty domain to skip .well-known and use config directly // Use empty domain to skip .well-known and use config directly
getDomain: () => "", getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
@@ -84,41 +86,6 @@ describe("LocalTransport", () => {
expect(() => localTransport$.value).toThrow(expectedError); expect(() => localTransport$.value).toThrow(expectedError);
}); });
it("emits preferred transport after OpenID resolves", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" });
expect(localTransport$.value).toBe(null);
await flushPromises();
// final
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!room:example.org",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("updates local transport when oldest member changes", async () => { it("updates local transport when oldest member changes", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({ mockConfig({
@@ -133,24 +100,171 @@ describe("LocalTransport", () => {
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!example_room_id",
useOldestMember$: constant(true), useOldestMember$: constant(true),
memberships$, memberships$,
client: { client: {
getDomain: () => "", getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
}); });
openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" }); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
// final // final
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!room:example.org", livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}); });
}); });
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>;
};
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
beforeEach(() => {
mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = {
scope,
roomId: "!example_room_id",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: vi.fn().mockReturnValue(""),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
};
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
});
afterEach(() => {
fetchMock.reset();
});
it("supports getting transport via application config", async () => {
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org");
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("supports getting transport via backend", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
try {
await lastValueFrom(createLocalTransport$(localTransportOpts));
throw Error("Expected test to throw");
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
});
it("supports getting transport via well-known", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
expect(fetchMock.done()).toEqual(true);
});
it("fails fast if the openId request fails for the well-known config", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
try {
await lastValueFrom(createLocalTransport$(localTransportOpts));
throw Error("Expected test to throw");
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
});
it("throws if no options are available", async () => {
const localTransport$ = createLocalTransport$({
scope,
roomId: "!example_room_id",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => localTransport$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
});
});
}); });

View File

@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
import { import {
type CallMembership, type CallMembership,
isLivekitTransport, isLivekitTransport,
type LivekitTransportConfig,
type LivekitTransport, type LivekitTransport,
isLivekitTransportConfig, isLivekitTransportConfig,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { MatrixError, type MatrixClient } from "matrix-js-sdk";
import { import {
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
@@ -27,7 +27,10 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts"; import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors.ts";
import { import {
getSFUConfigWithOpenID, getSFUConfigWithOpenID,
type OpenIDClientParts, type OpenIDClientParts,
@@ -45,7 +48,8 @@ const logger = rootLogger.getChild("[LocalTransport]");
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>; memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts; client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts;
roomId: string; roomId: string;
useOldestMember$: Behavior<boolean>; useOldestMember$: Behavior<boolean>;
} }
@@ -85,7 +89,7 @@ export const createLocalTransport$ = ({
* 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).
* *
* @throws * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior( const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior(
customLivekitUrl.value$.pipe( customLivekitUrl.value$.pipe(
@@ -116,73 +120,150 @@ export const createLocalTransport$ = ({
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
/** /**
* Determine the correct Transport for the current session, including
* validating auth against the service to ensure it's correct.
* Prefers in order:
* *
* @param client * 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw.
* @param roomId * 2. The transports returned via the homeserver.
* @returns * 3. The transports returned via .well-known.
* 4. The transport configured in Element Call's config.
*
* @param client The authenticated Matrix client for the current user
* @param roomId The ID of the room to be connected to.
* @param urlFromDevSettings Override URL provided by the user's local config.
* @returns A fully validated transport config.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
async function makeTransport( async function makeTransport(
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts, client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts,
roomId: string, roomId: string,
urlFromDevSettings: string | null, urlFromDevSettings: string | null,
): Promise<LivekitTransport> { ): Promise<LivekitTransport> {
let transport: LivekitTransport | undefined;
logger.trace("Searching for a preferred transport"); logger.trace("Searching for a preferred transport");
//TODO refactor this to use the jwt service returned alias.
const livekitAlias = roomId; // We will call `getSFUConfigWithOpenID` once per transport here as it's our
// only mechanism of valiation. This means we will also ask the
// homeserver for a OpenID token a few times. Since OpenID tokens are single
// use we don't want to risk any issues by re-using a token.
//
// If the OpenID request were to fail then it's acceptable for us to fail
// this function early, as we assume the homeserver has got some problems.
// DEVTOOL: Highest priority: Load from devtool setting // DEVTOOL: Highest priority: Load from devtool setting
if (urlFromDevSettings !== null) { if (urlFromDevSettings !== null) {
const transportFromStorage: LivekitTransport = { logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
// Validate that the SFU is up. Otherwise, we want to fail on this
// as we don't permit other SFUs.
const config = await getSFUConfigWithOpenID(
client,
urlFromDevSettings,
roomId,
);
return {
type: "livekit", type: "livekit",
livekit_service_url: urlFromDevSettings, livekit_service_url: urlFromDevSettings,
livekit_alias: livekitAlias, livekit_alias: config.livekitAlias,
}; };
logger.info(
"Using LiveKit transport from dev tools: ",
transportFromStorage,
);
transport = transportFromStorage;
} }
// WELL_KNOWN: Prioritize the .well-known/matrix/client, if available, over the configured SFU async function getFirstUsableTransport(
transports: Transport[],
): Promise<LivekitTransport | null> {
for (const potentialTransport of transports) {
if (isLivekitTransportConfig(potentialTransport)) {
try {
const { livekitAlias } = await getSFUConfigWithOpenID(
client,
potentialTransport.livekit_service_url,
roomId,
);
return {
...potentialTransport,
livekit_alias: livekitAlias,
};
} catch (ex) {
if (ex instanceof FailToGetOpenIdToken) {
// Explictly throw these
throw ex;
}
logger.debug(
`Could not use SFU service "${potentialTransport.livekit_service_url}" as SFU`,
ex,
);
}
}
}
return null;
}
// MSC4143: Attempt to fetch transports from backend.
if ("_unstable_getRTCTransports" in client) {
try {
const selectedTransport = await getFirstUsableTransport(
await client._unstable_getRTCTransports(),
);
if (selectedTransport) {
logger.info("Using backend-configured SFU", selectedTransport);
return selectedTransport;
}
} catch (ex) {
if (ex instanceof MatrixError && ex.httpStatus === 404) {
// Expected, this is an unstable endpoint and it's not required.
logger.debug("Backend does not provide any RTC transports", ex);
} else if (ex instanceof FailToGetOpenIdToken) {
throw ex;
} else {
// We got an error that wasn't just missing support for the feature, so log it loudly.
logger.error(
"Unexpected error fetching RTC transports from backend",
ex,
);
}
}
}
// Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available.
const domain = client.getDomain(); const domain = client.getDomain();
if (domain && transport === undefined) { if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already // we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started // been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY FOCI_WK_KEY
]; ];
if (Array.isArray(wellKnownFoci)) { const selectedTransport = Array.isArray(wellKnownFoci)
const wellKnownTransport: LivekitTransportConfig | undefined = ? await getFirstUsableTransport(wellKnownFoci)
wellKnownFoci.find((f) => f && isLivekitTransportConfig(f)); : null;
if (wellKnownTransport !== undefined) { if (selectedTransport) {
logger.info("Using LiveKit transport from .well-known: ", transport); logger.info("Using .well-known SFU", selectedTransport);
transport = { ...wellKnownTransport, livekit_alias: livekitAlias }; return selectedTransport;
}
} }
} }
// CONFIG: Least prioritized; Load from config file // CONFIG: Least prioritized; Load from config file
const urlFromConf = Config.get().livekit?.livekit_service_url; const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf && transport === undefined) { if (urlFromConf) {
const transportFromConf: LivekitTransport = { try {
type: "livekit", const { livekitAlias } = await getSFUConfigWithOpenID(
livekit_service_url: urlFromConf, client,
livekit_alias: livekitAlias, urlFromConf,
}; roomId,
logger.info("Using LiveKit transport from config: ", transportFromConf); );
transport = transportFromConf; const selectedTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
logger.info("Using config SFU", selectedTransport);
return selectedTransport;
} catch (ex) {
if (ex instanceof FailToGetOpenIdToken) {
throw ex;
}
logger.error("Failed to validate config SFU", ex);
}
} }
if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. throw new MatrixRTCTransportMissingError(domain ?? "");
await getSFUConfigWithOpenID(
client,
transport.livekit_service_url,
transport.livekit_alias,
);
return transport;
} }

View File

@@ -5,66 +5,320 @@ 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 { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import { import {
afterEach, ConnectionState as LivekitConnectionState,
beforeEach, LocalParticipant,
describe, type LocalTrack,
expect, type LocalTrackPublication,
it, ParticipantEvent,
type Mock, Track,
vi, } from "livekit-client";
} from "vitest"; import { BehaviorSubject } from "rxjs";
import { ConnectionState as LivekitConenctionState } from "livekit-client";
import { type BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../ObservableScope"; import { ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { import {
flushPromises,
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant,
mockMediaDevices, mockMediaDevices,
} from "../../../utils/test"; } from "../../../utils/test";
import { Publisher } from "./Publisher"; import { Publisher } from "./Publisher";
import { import { type Connection } from "../remoteMembers/Connection";
type Connection,
type ConnectionState,
} from "../remoteMembers/Connection";
import { type MuteStates } from "../../MuteStates"; import { type MuteStates } from "../../MuteStates";
import { FailToStartLivekitConnection } from "../../../utils/errors";
describe("Publisher", () => { let scope: ObservableScope;
let scope: ObservableScope;
let connection: Connection; beforeEach(() => {
let muteStates: MuteStates; scope = new ObservableScope();
beforeEach(() => { });
muteStates = {
audio: { afterEach(() => scope.end());
enabled$: constant(false),
unsetHandler: vi.fn(), function createMockLocalTrack(source: Track.Source): LocalTrack {
setHandler: vi.fn(), const track = {
}, source,
video: { isMuted: false,
enabled$: constant(false), isUpstreamPaused: false,
unsetHandler: vi.fn(), } as Partial<LocalTrack> as LocalTrack;
setHandler: vi.fn(),
}, vi.mocked(track).mute = vi.fn().mockImplementation(() => {
} as unknown as MuteStates; track.isMuted = true;
scope = new ObservableScope(); });
connection = { vi.mocked(track).unmute = vi.fn().mockImplementation(() => {
state$: constant({ track.isMuted = false;
state: "ConnectedToLkRoom", });
livekitConnectionState$: constant(LivekitConenctionState.Connected), vi.mocked(track).pauseUpstream = vi.fn().mockImplementation(() => {
}), // @ts-expect-error - for that test we want to set isUpstreamPaused directly
livekitRoom: mockLivekitRoom({ track.isUpstreamPaused = true;
localParticipant: mockLocalParticipant({}), });
}), vi.mocked(track).resumeUpstream = vi.fn().mockImplementation(() => {
} as unknown as Connection; // @ts-expect-error - for that test we want to set isUpstreamPaused directly
track.isUpstreamPaused = false;
}); });
afterEach(() => scope.end()); return track;
}
it("throws if livekit room could not publish", async () => { function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
enabled$: BehaviorSubject<boolean>;
setHandler: (h: (enabled: boolean) => void) => void;
unsetHandler: () => void;
} {
let currentHandler = (enabled: boolean): void => {};
const ms = {
enabled$,
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
currentHandler = h;
}),
unsetHandler: vi.fn().mockImplementation(() => {
currentHandler = (enabled: boolean): void => {};
}),
};
// forward enabled$ emissions to the current handler
enabled$.subscribe((enabled) => {
logger.info(`MockMuteState: enabled changed to ${enabled}`);
currentHandler(enabled);
});
return ms;
}
let connection: Connection;
let muteStates: MuteStates;
let localParticipant: LocalParticipant;
let audioEnabled$: BehaviorSubject<boolean>;
let videoEnabled$: BehaviorSubject<boolean>;
let trackPublications: LocalTrackPublication[];
// use it to control when track creation resolves, default to resolved
let createTrackLock: Promise<void>;
beforeEach(() => {
trackPublications = [];
audioEnabled$ = new BehaviorSubject(false);
videoEnabled$ = new BehaviorSubject(false);
createTrackLock = Promise.resolve();
muteStates = {
audio: createMockMuteState(audioEnabled$),
video: createMockMuteState(videoEnabled$),
} as unknown as MuteStates;
const mockSendDataPacket = vi.fn();
const mockEngine = {
client: {
sendUpdateLocalMetadata: vi.fn(),
},
on: vi.fn().mockReturnThis(),
sendDataPacket: mockSendDataPacket,
};
localParticipant = new LocalParticipant(
"local-sid",
"local-identity",
// @ts-expect-error - for that test we want a real LocalParticipant to have the pending publications logic
mockEngine,
{
adaptiveStream: true,
dynacase: false,
audioCaptureDefaults: {},
videoCaptureDefaults: {},
stopLocalTrackOnUnpublish: true,
reconnectPolicy: "always",
disconnectOnPageLeave: true,
},
new Map(),
{},
);
vi.mocked(localParticipant).createTracks = vi
.fn()
.mockImplementation(async (opts) => {
const tracks: LocalTrack[] = [];
if (opts.audio) {
tracks.push(createMockLocalTrack(Track.Source.Microphone));
}
if (opts.video) {
tracks.push(createMockLocalTrack(Track.Source.Camera));
}
await createTrackLock;
return tracks;
});
vi.mocked(localParticipant).publishTrack = vi
.fn()
.mockImplementation(async (track: LocalTrack) => {
const pub = {
track,
source: track.source,
mute: track.mute,
unmute: track.unmute,
} as Partial<LocalTrackPublication> as LocalTrackPublication;
trackPublications.push(pub);
localParticipant.emit(ParticipantEvent.LocalTrackPublished, pub);
return Promise.resolve(pub);
});
vi.mocked(localParticipant).getTrackPublication = vi
.fn()
.mockImplementation((source: Track.Source) => {
return trackPublications.find((pub) => pub.track?.source === source);
});
connection = {
state$: constant({
state: "ConnectedToLkRoom",
livekitConnectionState$: constant(LivekitConnectionState.Connected),
}),
livekitRoom: mockLivekitRoom({
localParticipant: localParticipant,
}),
} as unknown as Connection;
});
describe("Publisher", () => {
let publisher: Publisher;
beforeEach(() => {
publisher = new Publisher(
scope,
connection,
mockMediaDevices({}),
muteStates,
constant({ supported: false, processor: undefined }),
logger,
);
});
afterEach(() => {});
it("Should not create tracks if started muted to avoid unneeded permission requests", async () => {
const createTracksSpy = vi.spyOn(
connection.livekitRoom.localParticipant,
"createTracks",
);
audioEnabled$.next(false);
videoEnabled$.next(false);
await publisher.createAndSetupTracks();
expect(createTracksSpy).not.toHaveBeenCalled();
});
it("Should minimize permission request by querying create at once", async () => {
const enableCameraAndMicrophoneSpy = vi.spyOn(
localParticipant,
"enableCameraAndMicrophone",
);
const createTracksSpy = vi.spyOn(localParticipant, "createTracks");
audioEnabled$.next(true);
videoEnabled$.next(true);
await publisher.createAndSetupTracks();
await flushPromises();
expect(enableCameraAndMicrophoneSpy).toHaveBeenCalled();
// It should create both at once
expect(createTracksSpy).toHaveBeenCalledWith({
audio: true,
video: true,
});
});
it("Ensure no data is streamed until publish has been called", async () => {
audioEnabled$.next(true);
await publisher.createAndSetupTracks();
// The track should be created and paused
expect(localParticipant.createTracks).toHaveBeenCalledWith({
audio: true,
video: undefined,
});
await flushPromises();
expect(localParticipant.publishTrack).toHaveBeenCalled();
await flushPromises();
const track = localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track;
expect(track).toBeDefined();
expect(track!.pauseUpstream).toHaveBeenCalled();
expect(track!.isUpstreamPaused).toBe(true);
});
it("Ensure resume upstream when published is called", async () => {
videoEnabled$.next(true);
await publisher.createAndSetupTracks();
// await flushPromises();
await publisher.startPublishing();
const track = localParticipant.getTrackPublication(
Track.Source.Camera,
)?.track;
expect(track).toBeDefined();
// expect(track.pauseUpstream).toHaveBeenCalled();
expect(track!.isUpstreamPaused).toBe(false);
});
describe("Mute states", () => {
let publisher: Publisher;
beforeEach(() => {
publisher = new Publisher(
scope,
connection,
mockMediaDevices({}),
muteStates,
constant({ supported: false, processor: undefined }),
logger,
);
});
test.each([
{ mutes: { audioEnabled: true, videoEnabled: false } },
{ mutes: { audioEnabled: true, videoEnabled: false } },
])("only create the tracks that are unmuted $mutes", async ({ mutes }) => {
// Ensure all muted
audioEnabled$.next(mutes.audioEnabled);
videoEnabled$.next(mutes.videoEnabled);
vi.mocked(connection.livekitRoom.localParticipant).createTracks = vi
.fn()
.mockResolvedValue([]);
await publisher.createAndSetupTracks();
expect(
connection.livekitRoom.localParticipant.createTracks,
).toHaveBeenCalledOnce();
expect(
connection.livekitRoom.localParticipant.createTracks,
).toHaveBeenCalledWith({
audio: mutes.audioEnabled ? true : undefined,
video: mutes.videoEnabled ? true : undefined,
});
});
});
it("does mute unmute audio", async () => {});
});
describe("Bug fix", () => {
// There is a race condition when creating and publishing tracks while the mute state changes.
// This race condition could cause tracks to be published even though they are muted at the
// beginning of a call coming from lobby.
// This is caused by our stack using manually the low level API to create and publish tracks,
// but also using the higher level setMicrophoneEnabled and setCameraEnabled functions that also create
// and publish tracks, and managing pending publications.
// Race is as follow, on creation of the Publisher we create the tracks then publish them.
// If in the middle of that process the mute state changes:
// - the `setMicrophoneEnabled` will be no-op because it is not aware of our created track and can't see any pending publication
// - If start publication is requested it will publish the track even though there was a mute request.
it("wrongly publish tracks while muted", async () => {
// setLogLevel(`debug`);
const publisher = new Publisher( const publisher = new Publisher(
scope, scope,
connection, connection,
@@ -73,68 +327,34 @@ describe("Publisher", () => {
constant({ supported: false, processor: undefined }), constant({ supported: false, processor: undefined }),
logger, logger,
); );
audioEnabled$.next(true);
// should do nothing if no tracks have been created yet. const resolvers = Promise.withResolvers<void>();
await publisher.startPublishing(); createTrackLock = resolvers.promise;
expect(
connection.livekitRoom.localParticipant.publishTrack,
).not.toHaveBeenCalled();
await expect(publisher.createAndSetupTracks()).rejects.toThrow( // Initially the audio is unmuted, so creating tracks should publish the audio track
Error("audio and video is false"), const createTracks = publisher.createAndSetupTracks();
); void publisher.startPublishing();
void createTracks.then(() => {
(muteStates.audio.enabled$ as BehaviorSubject<boolean>).next(true); void publisher.startPublishing();
(
connection.livekitRoom.localParticipant.createTracks as Mock
).mockResolvedValue([{}, {}]);
await expect(publisher.createAndSetupTracks()).resolves.not.toThrow();
expect(
connection.livekitRoom.localParticipant.createTracks,
).toHaveBeenCalledOnce();
// failiour due to localParticipant.publishTrack
(
connection.livekitRoom.localParticipant.publishTrack as Mock
).mockRejectedValue(Error("testError"));
await expect(publisher.startPublishing()).rejects.toThrow(
new FailToStartLivekitConnection("testError"),
);
// does not try other conenction after the first one failed
expect(
connection.livekitRoom.localParticipant.publishTrack,
).toHaveBeenCalledTimes(1);
// failiour due to connection.state$
const beforeState = connection.state$.value;
(connection.state$ as BehaviorSubject<ConnectionState>).next({
state: "FailedToStart",
error: Error("testStartError"),
}); });
// now mute the audio before allowing track creation to complete
audioEnabled$.next(false);
resolvers.resolve(undefined);
await createTracks;
await expect(publisher.startPublishing()).rejects.toThrow( await flushPromises();
new FailToStartLivekitConnection("testStartError"),
);
(connection.state$ as BehaviorSubject<ConnectionState>).next(beforeState);
// does not try other conenction after the first one failed const track = localParticipant.getTrackPublication(
expect( Track.Source.Microphone,
connection.livekitRoom.localParticipant.publishTrack, )?.track;
).toHaveBeenCalledTimes(1); expect(track).toBeDefined();
// success case try {
( expect(localParticipant.publishTrack).not.toHaveBeenCalled();
connection.livekitRoom.localParticipant.publishTrack as Mock } catch {
).mockResolvedValue({}); expect(track!.mute).toHaveBeenCalled();
expect(track!.isMuted).toBe(true);
await expect(publisher.startPublishing()).resolves.not.toThrow(); }
expect(
connection.livekitRoom.localParticipant.publishTrack,
).toHaveBeenCalledTimes(3);
}); });
}); });

View File

@@ -6,15 +6,14 @@ 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 { import {
ConnectionState as LivekitConnectionState,
type LocalTrackPublication,
LocalVideoTrack, LocalVideoTrack,
ParticipantEvent,
type Room as LivekitRoom, type Room as LivekitRoom,
Track, Track,
type LocalTrack,
type LocalTrackPublication,
ConnectionState as LivekitConnectionState,
} from "livekit-client"; } from "livekit-client";
import { import {
BehaviorSubject,
map, map,
NEVER, NEVER,
type Observable, type Observable,
@@ -34,10 +33,6 @@ import { getUrlParams } from "../../../UrlParams.ts";
import { observeTrackReference$ } from "../../MediaViewModel.ts"; import { observeTrackReference$ } from "../../MediaViewModel.ts";
import { type Connection } from "../remoteMembers/Connection.ts"; import { type Connection } from "../remoteMembers/Connection.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import {
ElementCallError,
FailToStartLivekitConnection,
} from "../../../utils/errors.ts";
/** /**
* A wrapper for a Connection object. * A wrapper for a Connection object.
@@ -45,14 +40,21 @@ import {
* The Publisher is also responsible for creating the media tracks. * The Publisher is also responsible for creating the media tracks.
*/ */
export class Publisher { export class Publisher {
/**
* By default, livekit will start publishing tracks as soon as they are created.
* In the matrix RTC world, we want to control when tracks are published based
* on whether the user is part of the RTC session or not.
*/
public shouldPublish = false;
/** /**
* Creates a new Publisher. * Creates a new Publisher.
* @param scope - The observable scope to use for managing the publisher. * @param scope - The observable scope to use for managing the publisher.
* @param connection - The connection to use for publishing. * @param connection - The connection to use for publishing.
* @param devices - The media devices to use for audio and video input. * @param devices - The media devices to use for audio and video input.
* @param muteStates - The mute states for audio and video. * @param muteStates - The mute states for audio and video.
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit room. Use to share the same key provider across connections!.
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
* @param logger - The logger to use for logging :D.
*/ */
public constructor( public constructor(
private scope: ObservableScope, private scope: ObservableScope,
@@ -62,7 +64,6 @@ export class Publisher {
trackerProcessorState$: Behavior<ProcessorState>, trackerProcessorState$: Behavior<ProcessorState>,
private logger: Logger, private logger: Logger,
) { ) {
this.logger.info("Create LiveKit room");
const { controlledAudioDevices } = getUrlParams(); const { controlledAudioDevices } = getUrlParams();
const room = connection.livekitRoom; const room = connection.livekitRoom;
@@ -80,161 +81,185 @@ export class Publisher {
this.scope.onEnd(() => { this.scope.onEnd(() => {
this.logger.info("Scope ended -> stop publishing all tracks"); this.logger.info("Scope ended -> stop publishing all tracks");
void this.stopPublishing(); void this.stopPublishing();
muteStates.audio.unsetHandler();
muteStates.video.unsetHandler();
}); });
// TODO move mute state handling here using reconcile (instead of inside the mute state class) this.connection.livekitRoom.localParticipant.on(
// this.scope.reconcile( ParticipantEvent.LocalTrackPublished,
// this.scope.behavior( this.onLocalTrackPublished.bind(this),
// combineLatest([this.muteStates.video.enabled$, this.tracks$]), );
// ),
// async ([videoEnabled, tracks]) => {
// const track = tracks.find((t) => t.kind == Track.Kind.Video);
// if (!track) return;
// if (videoEnabled) {
// await track.unmute();
// } else {
// await track.mute();
// }
// },
// );
} }
private _tracks$ = new BehaviorSubject<LocalTrack<Track.Kind>[]>([]); // LiveKit will publish the tracks as soon as they are created
public tracks$ = this._tracks$ as Behavior<LocalTrack<Track.Kind>[]>; // but we want to control when tracks are published.
// We cannot just mute the tracks, even if this will effectively stop the publishing,
// it would also prevent the user from seeing their own video/audio preview.
// So for that we use pauseUpStream(): Stops sending media to the server by replacing
// the sender track with null, but keeps the local MediaStreamTrack active.
// The user can still see/hear themselves locally, but remote participants see nothing.
private onLocalTrackPublished(
localTrackPublication: LocalTrackPublication,
): void {
this.logger.info("Local track published", localTrackPublication);
const lkRoom = this.connection.livekitRoom;
if (!this.shouldPublish) {
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
this.logger.error(`Failed to pause upstreams`, e);
});
}
// also check the mute state and apply it
if (localTrackPublication.source === Track.Source.Microphone) {
const enabled = this.muteStates.audio.enabled$.value;
lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => {
this.logger.error(
`Failed to enable microphone track, enabled:${enabled}`,
e,
);
});
} else if (localTrackPublication.source === Track.Source.Camera) {
const enabled = this.muteStates.video.enabled$.value;
lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => {
this.logger.error(
`Failed to enable camera track, enabled:${enabled}`,
e,
);
});
}
}
/** /**
* Start the connection to LiveKit and publish local tracks. * Create and setup local audio and video tracks based on the current mute states.
* It creates the tracks only if audio and/or video is enabled, to avoid unnecessary
* permission prompts.
* *
* This will: * It also observes mute state changes to update LiveKit microphone/camera states accordingly.
* wait for the connection to be ready. * If a track is not created initially because disabled, it will be created when unmuting.
// * 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.) *
// * 2. Use this token to request the SFU config to the MatrixRtc authentication service. * This call is not blocking anymore, instead callers can listen to the
// * 3. Connect to the configured LiveKit room. * `RoomEvent.MediaDevicesError` event in the LiveKit room to be notified of any errors.
// * 4. Create local audio and video tracks based on the current mute states and publish them to the room.
* *
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
public async createAndSetupTracks(): Promise<void> { public async createAndSetupTracks(): Promise<void> {
this.logger.debug("createAndSetupTracks called"); this.logger.debug("createAndSetupTracks called");
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
// Observe mute state changes and update LiveKit microphone/camera states accordingly // Observe mute state changes and update LiveKit microphone/camera states accordingly
this.observeMuteStates(this.scope); this.observeMuteStates();
// TODO-MULTI-SFU: Prepublish a microphone track // Check if audio and/or video is enabled. We only create tracks if enabled,
// because it could prompt for permission, and we don't want to do that unnecessarily.
const audio = this.muteStates.audio.enabled$.value; const audio = this.muteStates.audio.enabled$.value;
const video = this.muteStates.video.enabled$.value; const video = this.muteStates.video.enabled$.value;
// createTracks throws if called with audio=false and video=false
if (audio || video) { // We don't await the creation, because livekit could block until the tracks
// TODO this can still throw errors? It will also prompt for permissions if not already granted // are fully published, and not only that they are created.
return lkRoom.localParticipant // We don't have control on that, localParticipant creates and publishes the tracks
.createTracks({ // asap.
audio, // We are using the `ParticipantEvent.LocalTrackPublished` to be notified
video, // when tracks are actually published, and at that point
}) // we can pause upstream if needed (depending on if startPublishing has been called).
.then((tracks) => { if (audio && video) {
this.logger.info( // Enable both at once in order to have a single permission prompt!
"created track", void lkRoom.localParticipant.enableCameraAndMicrophone();
tracks.map((t) => t.kind + ", " + t.id), } else if (audio) {
); void lkRoom.localParticipant.setMicrophoneEnabled(true);
this._tracks$.next(tracks); } else if (video) {
}) void lkRoom.localParticipant.setCameraEnabled(true);
.catch((error) => { }
this.logger.error("Failed to create tracks", error);
}); return Promise.resolve();
}
private async pauseUpstreams(
lkRoom: LivekitRoom,
sources: Track.Source[],
): Promise<void> {
for (const source of sources) {
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
if (track) {
await track.pauseUpstream();
} else {
this.logger.warn(
`No track found for source ${source} to pause upstream`,
);
}
}
}
private async resumeUpstreams(
lkRoom: LivekitRoom,
sources: Track.Source[],
): Promise<void> {
for (const source of sources) {
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
if (track) {
await track.resumeUpstream();
} else {
this.logger.warn(
`No track found for source ${source} to resume upstream`,
);
}
} }
throw Error("audio and video is false");
} }
private _publishing$ = new BehaviorSubject<boolean>(false);
public publishing$ = this.scope.behavior(this._publishing$);
/** /**
*
* Request to publish local tracks to the LiveKit room.
* This will wait for the connection to be ready before publishing.
* Livekit also have some local retry logic for publishing tracks.
* Can be called multiple times, localparticipant manages the state of published tracks (or pending publications).
* *
* @returns * @returns
* @throws ElementCallError
*/ */
public async startPublishing(): Promise<LocalTrack[]> { public async startPublishing(): Promise<void> {
if (this.shouldPublish) {
this.logger.debug(`Already publishing, ignoring startPublishing call`);
return;
}
this.shouldPublish = true;
this.logger.debug("startPublishing called"); this.logger.debug("startPublishing called");
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((s) => { // Resume upstream for both audio and video tracks
switch (s.state) { // We need to call it explicitly because call setTrackEnabled does not always
case "ConnectedToLkRoom": // resume upstream. It will only if you switch the track from disabled to enabled,
resolve(); // but if the track is already enabled but upstream is paused, it won't resume it.
break; // TODO what about screen share?
case "FailedToStart":
reject(
s.error instanceof ElementCallError
? s.error
: new FailToStartLivekitConnection(s.error.message),
);
break;
default:
this.logger.info("waiting for connection: ", s.state);
}
});
try { try {
await promise; await this.resumeUpstreams(lkRoom, [
Track.Source.Microphone,
Track.Source.Camera,
]);
} catch (e) { } catch (e) {
throw e; this.logger.error(`Failed to resume upstreams`, e);
} finally {
sub.unsubscribe();
} }
for (const track of this.tracks$.value) {
this.logger.info("publish ", this.tracks$.value.length, "tracks");
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally
// with a timeout.
await lkRoom.localParticipant.publishTrack(track).catch((error) => {
this.logger.error("Failed to publish track", error);
throw new FailToStartLivekitConnection(
error instanceof Error ? error.message : error,
);
});
this.logger.info("published track ", track.kind, track.id);
// TODO: check if the connection is still active? and break the loop if not?
}
this._publishing$.next(true);
return this.tracks$.value;
} }
public async stopPublishing(): Promise<void> { public async stopPublishing(): Promise<void> {
this.logger.debug("stopPublishing called"); this.logger.debug("stopPublishing called");
// TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope this.shouldPublish = false;
// actually has the right lifetime // Pause upstream will stop sending media to the server, while keeping
this.muteStates.audio.unsetHandler(); // the local MediaStreamTrack active, so the user can still see themselves.
this.muteStates.video.unsetHandler(); await this.pauseUpstreams(this.connection.livekitRoom, [
Track.Source.Microphone,
const localParticipant = this.connection.livekitRoom.localParticipant; Track.Source.Camera,
const tracks: LocalTrack[] = []; Track.Source.ScreenShare,
const addToTracksIfDefined = (p: LocalTrackPublication): void => { ]);
if (p.track !== undefined) tracks.push(p.track);
};
localParticipant.trackPublications.forEach(addToTracksIfDefined);
this.logger.debug(
"list of tracks to unpublish:",
tracks.map((t) => t.kind + ", " + t.id),
"start unpublishing now",
);
await localParticipant.unpublishTracks(tracks).catch((error) => {
this.logger.error("Failed to unpublish tracks", error);
throw error;
});
this.logger.debug(
"unpublished tracks",
tracks.map((t) => t.kind + ", " + t.id),
);
this._publishing$.next(false);
} }
/** public async stopTracks(): Promise<void> {
* Stops all tracks that are currently running const lkRoom = this.connection.livekitRoom;
*/ for (const source of [
public stopTracks(): void { Track.Source.Microphone,
this.tracks$.value.forEach((t) => t.stop()); Track.Source.Camera,
this._tracks$.next([]); Track.Source.ScreenShare,
]) {
const localPub = lkRoom.localParticipant.getTrackPublication(source);
if (localPub?.track) {
// stops and unpublishes the track
await lkRoom.localParticipant.unpublishTrack(localPub!.track, true);
}
}
} }
/// Private methods /// Private methods
@@ -331,22 +356,35 @@ export class Publisher {
/** /**
* Observe changes in the mute states and update the LiveKit room accordingly. * Observe changes in the mute states and update the LiveKit room accordingly.
* @param scope
* @private * @private
*/ */
private observeMuteStates(scope: ObservableScope): void { private observeMuteStates(): void {
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
this.muteStates.audio.setHandler(async (desired) => { this.muteStates.audio.setHandler(async (enable) => {
try { try {
await lkRoom.localParticipant.setMicrophoneEnabled(desired); this.logger.debug(
`handler: Setting LiveKit microphone enabled: ${enable}`,
);
await lkRoom.localParticipant.setMicrophoneEnabled(enable);
// Unmute will restart the track if it was paused upstream,
// but until explicitly requested, we want to keep it paused.
if (!this.shouldPublish && enable) {
await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]);
}
} catch (e) { } catch (e) {
this.logger.error("Failed to update LiveKit audio input mute state", e); this.logger.error("Failed to update LiveKit audio input mute state", e);
} }
return lkRoom.localParticipant.isMicrophoneEnabled; return lkRoom.localParticipant.isMicrophoneEnabled;
}); });
this.muteStates.video.setHandler(async (desired) => { this.muteStates.video.setHandler(async (enable) => {
try { try {
await lkRoom.localParticipant.setCameraEnabled(desired); this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`);
await lkRoom.localParticipant.setCameraEnabled(enable);
// Unmute will restart the track if it was paused upstream,
// but until explicitly requested, we want to keep it paused.
if (!this.shouldPublish && enable) {
await this.pauseUpstreams(lkRoom, [Track.Source.Camera]);
}
} catch (e) { } catch (e) {
this.logger.error("Failed to update LiveKit video input mute state", e); this.logger.error("Failed to update LiveKit video input mute state", e);
} }
@@ -362,7 +400,7 @@ export class Publisher {
const track$ = scope.behavior( const track$ = scope.behavior(
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe( observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
map((trackRef) => { map((trackRef) => {
const track = trackRef?.publication?.track; const track = trackRef?.publication.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
), ),

View File

@@ -30,13 +30,17 @@ import { logger } from "matrix-js-sdk/lib/logger";
import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { import {
Connection, Connection,
ConnectionState,
type ConnectionOpts, type ConnectionOpts,
type ConnectionState,
type PublishingParticipant,
} from "./Connection.ts"; } from "./Connection.ts";
import { ObservableScope } from "../../ObservableScope.ts"; import { ObservableScope } from "../../ObservableScope.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { FailToGetOpenIdToken } from "../../../utils/errors.ts"; import {
ElementCallError,
FailToGetOpenIdToken,
} from "../../../utils/errors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
import { mockRemoteParticipant } from "../../../utils/test.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -47,11 +51,6 @@ let fakeLivekitRoom: MockedObject<LivekitRoom>;
let localParticipantEventEmiter: EventEmitter; let localParticipantEventEmiter: EventEmitter;
let fakeLocalParticipant: MockedObject<LocalParticipant>; let fakeLocalParticipant: MockedObject<LocalParticipant>;
let fakeRoomEventEmiter: EventEmitter;
// let fakeMembershipsFocusMap$: BehaviorSubject<
// { membership: CallMembership; transport: LivekitTransport }[]
// >;
const livekitFocus: LivekitTransport = { const livekitFocus: LivekitTransport = {
livekit_alias: "!roomID:example.org", livekit_alias: "!roomID:example.org",
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt", livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
@@ -88,22 +87,25 @@ function setupTest(): void {
localParticipantEventEmiter, localParticipantEventEmiter,
), ),
} as unknown as LocalParticipant); } as unknown as LocalParticipant);
fakeRoomEventEmiter = new EventEmitter();
const fakeRoomEventEmitter = new EventEmitter();
fakeLivekitRoom = vi.mocked<LivekitRoom>({ fakeLivekitRoom = vi.mocked<LivekitRoom>({
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
remoteParticipants: new Map(), remoteParticipants: new Map(),
localParticipant: fakeLocalParticipant, localParticipant: fakeLocalParticipant,
state: LivekitConnectionState.Disconnected, state: LivekitConnectionState.Disconnected,
on: fakeRoomEventEmiter.on.bind(fakeRoomEventEmiter), on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
off: fakeRoomEventEmiter.off.bind(fakeRoomEventEmiter), off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
addListener: fakeRoomEventEmiter.addListener.bind(fakeRoomEventEmiter), addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
removeListener: removeListener:
fakeRoomEventEmiter.removeListener.bind(fakeRoomEventEmiter), fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
removeAllListeners: removeAllListeners:
fakeRoomEventEmiter.removeAllListeners.bind(fakeRoomEventEmiter), fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
setE2EEEnabled: vi.fn().mockResolvedValue(undefined), setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
emit: (eventName: string | symbol, ...args: unknown[]) => {
fakeRoomEventEmitter.emit(eventName, ...args);
},
} as unknown as LivekitRoom); } as unknown as LivekitRoom);
} }
@@ -120,12 +122,21 @@ function setupRemoteConnection(): Connection {
status: 200, status: 200,
body: { body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu", url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });
fakeLivekitRoom.connect.mockResolvedValue(undefined); fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
const changeEv = RoomEvent.ConnectionStateChanged;
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
fakeLivekitRoom.state = LivekitConnectionState.Connected;
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
return Promise.resolve();
});
return new Connection(opts, logger); return new Connection(opts, logger);
} }
@@ -148,7 +159,7 @@ describe("Start connection states", () => {
}; };
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
expect(connection.state$.getValue().state).toEqual("Initialized"); expect(connection.state$.getValue()).toEqual("Initialized");
}); });
it("fail to getOpenId token then error state", async () => { it("fail to getOpenId token then error state", async () => {
@@ -164,7 +175,7 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = []; const capturedStates: (ConnectionState | Error)[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -184,22 +195,20 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState!.state).toEqual("FetchingConfig"); expect(capturedState!).toEqual("FetchingConfig");
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token"))); deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState!.state === "FailedToStart") { if (capturedState instanceof Error) {
expect(capturedState!.error.message).toEqual("Something went wrong"); expect(capturedState.message).toEqual("Something went wrong");
expect(connection.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias, livekitFocus.livekit_alias,
); );
} else { } else {
expect.fail( expect.fail("Expected FailedToStart state but got " + capturedState);
"Expected FailedToStart state but got " + capturedState?.state,
);
} }
}); });
@@ -216,7 +225,7 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = []; const capturedStates: (ConnectionState | Error)[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -238,24 +247,25 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState?.state).toEqual("FetchingConfig"); expect(capturedState).toEqual(ConnectionState.FetchingConfig);
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState?.state === "FailedToStart") { if (
expect(capturedState?.error.message).toContain( capturedState instanceof ElementCallError &&
"SFU Config fetch failed with exception Error", capturedState.cause instanceof Error
) {
expect(capturedState.cause.message).toContain(
"SFU Config fetch failed with exception",
); );
expect(connection.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias, livekitFocus.livekit_alias,
); );
} else { } else {
expect.fail( expect.fail("Expected FailedToStart state but got " + capturedState);
"Expected FailedToStart state but got " + capturedState?.state,
);
} }
}); });
@@ -272,7 +282,7 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = []; const capturedStates: (ConnectionState | Error)[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -285,7 +295,7 @@ describe("Start connection states", () => {
status: 200, status: 200,
body: { body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu", url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });
@@ -302,15 +312,18 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState?.state).toEqual("FetchingConfig"); expect(capturedState).toEqual(ConnectionState.FetchingConfig);
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState && capturedState?.state === "FailedToStart") { if (
expect(capturedState.error.message).toContain( capturedState instanceof ElementCallError &&
capturedState.cause instanceof Error
) {
expect(capturedState.cause.message).toContain(
"Failed to connect to livekit", "Failed to connect to livekit",
); );
expect(connection.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
@@ -329,7 +342,7 @@ describe("Start connection states", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: ConnectionState[] = []; const capturedStates: (ConnectionState | Error)[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -339,13 +352,15 @@ describe("Start connection states", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const initialState = capturedStates.shift(); const initialState = capturedStates.shift();
expect(initialState?.state).toEqual("Initialized"); expect(initialState).toEqual(ConnectionState.Initialized);
const fetchingState = capturedStates.shift(); const fetchingState = capturedStates.shift();
expect(fetchingState?.state).toEqual("FetchingConfig"); expect(fetchingState).toEqual(ConnectionState.FetchingConfig);
const disconnectedState = capturedStates.shift();
expect(disconnectedState).toEqual(ConnectionState.LivekitDisconnected);
const connectingState = capturedStates.shift(); const connectingState = capturedStates.shift();
expect(connectingState?.state).toEqual("ConnectingToLkRoom"); expect(connectingState).toEqual(ConnectionState.LivekitConnecting);
const connectedState = capturedStates.shift(); const connectedState = capturedStates.shift();
expect(connectedState?.state).toEqual("ConnectedToLkRoom"); expect(connectedState).toEqual(ConnectionState.LivekitConnected);
}); });
it("shutting down the scope should stop the connection", async () => { it("shutting down the scope should stop the connection", async () => {
@@ -363,46 +378,32 @@ describe("Start connection states", () => {
}); });
}); });
function fakeRemoteLivekitParticipant( describe("remote participants", () => {
id: string, it("emits the list of remote participants", () => {
publications: number = 1,
): RemoteParticipant {
return {
identity: id,
getTrackPublications: () => Array(publications),
} as unknown as RemoteParticipant;
}
describe("Publishing participants observations", () => {
it("should emit the list of publishing participants", () => {
setupTest(); setupTest();
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const bobIsAPublisher = Promise.withResolvers<void>(); const observedParticipants: RemoteParticipant[][] = [];
const danIsAPublisher = Promise.withResolvers<void>(); const s = connection.remoteParticipants$.subscribe((participants) => {
const observedPublishers: PublishingParticipant[][] = []; observedParticipants.push(participants);
const s = connection.remoteParticipantsWithTracks$.subscribe( });
(publishers) => {
observedPublishers.push(publishers);
if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
bobIsAPublisher.resolve();
}
if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
danIsAPublisher.resolve();
}
},
);
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
// The publishingParticipants$ observable is derived from the current members of the // The remoteParticipants$ observable is derived from the current members of the
// livekitRoom and the rtc membership in order to publish the members that are publishing // livekitRoom and the rtc membership in order to publish the members that are publishing
// on this connection. // on this connection.
let participants: RemoteParticipant[] = [ let participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0), mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0), mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0), // Mock Dan to have no published tracks. We want him to still show show up
// in the participants list.
mockRemoteParticipant({
identity: "@dan:example.org:DEV333",
getTrackPublication: () => undefined,
getTrackPublications: () => [],
}),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
@@ -411,24 +412,26 @@ describe("Publishing participants observations", () => {
); );
participants.forEach((p) => participants.forEach((p) =>
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p), fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
); );
// At this point there should be no publishers // At this point there should be ~~no~~ publishers
expect(observedPublishers.pop()!.length).toEqual(0); // We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
// The filtering we do is just based on the matrixRTC member events.
expect(observedParticipants.pop()!.length).toEqual(4);
participants = [ participants = [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1), mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1), mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2), mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
]; ];
participants.forEach((p) => participants.forEach((p) =>
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p), fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
); );
// At this point there should be no publishers // At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(4); expect(observedParticipants.pop()!.length).toEqual(4);
}); });
it("should be scoped to parent scope", (): void => { it("should be scoped to parent scope", (): void => {
@@ -436,16 +439,14 @@ describe("Publishing participants observations", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
let observedPublishers: PublishingParticipant[][] = []; let observedParticipants: RemoteParticipant[][] = [];
const s = connection.remoteParticipantsWithTracks$.subscribe( const s = connection.remoteParticipants$.subscribe((participants) => {
(publishers) => { observedParticipants.push(participants);
observedPublishers.push(publishers); });
},
);
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
let participants: RemoteParticipant[] = [ let participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
@@ -454,38 +455,29 @@ describe("Publishing participants observations", () => {
); );
for (const participant of participants) { for (const participant of participants) {
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant); fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
} }
// At this point there should be no publishers // We should have bob as a participant now
expect(observedPublishers.pop()!.length).toEqual(0); const ps = observedParticipants.pop();
expect(ps?.length).toEqual(1);
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)]; expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
for (const participant of participants) {
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
}
// We should have bob has a publisher now
const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1);
expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
// end the parent scope // end the parent scope
testScope.end(); testScope.end();
observedPublishers = []; observedParticipants = [];
// SHOULD NOT emit any more publishers as the scope is ended // SHOULD NOT emit any more participants as the scope is ended
participants = participants.filter( participants = participants.filter(
(p) => p.identity !== "@bob:example.org:DEV111", (p) => p.identity !== "@bob:example.org:DEV111",
); );
fakeRoomEventEmiter.emit( fakeLivekitRoom.emit(
RoomEvent.ParticipantDisconnected, RoomEvent.ParticipantDisconnected,
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
); );
expect(observedPublishers.length).toEqual(0); expect(observedParticipants.length).toEqual(0);
}); });
}); });

View File

@@ -12,11 +12,8 @@ import {
} from "@livekit/components-core"; } from "@livekit/components-core";
import { import {
ConnectionError, ConnectionError,
type ConnectionState as LivekitConenctionState,
type Room as LivekitRoom, type Room as LivekitRoom,
type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
RoomEvent,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
@@ -30,12 +27,12 @@ import {
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import { import {
ElementCallError,
InsufficientCapacityError, InsufficientCapacityError,
SFURoomCreationRestrictedError, SFURoomCreationRestrictedError,
UnknownCallError,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
export type PublishingParticipant = LocalParticipant | RemoteParticipant;
export interface ConnectionOpts { export interface ConnectionOpts {
/** The media transport to connect to. */ /** The media transport to connect to. */
transport: LivekitTransport; transport: LivekitTransport;
@@ -47,17 +44,30 @@ export interface ConnectionOpts {
/** Optional factory to create the LiveKit room, mainly for testing purposes. */ /** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom; livekitRoomFactory: () => LivekitRoom;
} }
export class FailedToStartError extends Error {
public constructor(message: string) {
super(message);
this.name = "FailedToStartError";
}
}
export type ConnectionState = export enum ConnectionState {
| { state: "Initialized" } /** The start state of a connection. It has been created but nothing has loaded yet. */
| { state: "FetchingConfig" } Initialized = "Initialized",
| { state: "ConnectingToLkRoom" } /** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */
| { FetchingConfig = "FetchingConfig",
state: "ConnectedToLkRoom"; Stopped = "Stopped",
livekitConnectionState$: Behavior<LivekitConenctionState>; /** The same as ConnectionState.Disconnected from `livekit-client` */
} LivekitDisconnected = "disconnected",
| { state: "FailedToStart"; error: Error } /** The same as ConnectionState.Connecting from `livekit-client` */
| { state: "Stopped" }; LivekitConnecting = "connecting",
/** The same as ConnectionState.Connected from `livekit-client` */
LivekitConnected = "connected",
/** The same as ConnectionState.Reconnecting from `livekit-client` */
LivekitReconnecting = "reconnecting",
/** The same as ConnectionState.SignalReconnecting from `livekit-client` */
LivekitSignalReconnecting = "signalReconnecting",
}
/** /**
* A connection to a Matrix RTC LiveKit backend. * A connection to a Matrix RTC LiveKit backend.
@@ -66,14 +76,14 @@ export type ConnectionState =
*/ */
export class Connection { export class Connection {
// Private Behavior // Private Behavior
private readonly _state$ = new BehaviorSubject<ConnectionState>({ private readonly _state$ = new BehaviorSubject<
state: "Initialized", ConnectionState | ElementCallError
}); >(ConnectionState.Initialized);
/** /**
* The current state of the connection to the media transport. * The current state of the connection to the media transport.
*/ */
public readonly state$: Behavior<ConnectionState> = this._state$; public readonly state$: Behavior<ConnectionState | Error> = this._state$;
/** /**
* The media transport to connect to. * The media transport to connect to.
@@ -85,13 +95,13 @@ export class Connection {
private scope: ObservableScope; private scope: ObservableScope;
/** /**
* An observable of the participants that are publishing on this connection. (Excluding our local participant) * The remote LiveKit participants that are visible on this connection.
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`. *
* It filters the participants to only those that are associated with a membership that claims to publish on this connection. * Note that this may include participants that are connected only to
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
* It is therefore more low-level than what should be presented to the user.
*/ */
public readonly remoteParticipantsWithTracks$: Behavior< public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
PublishingParticipant[]
>;
/** /**
* Whether the connection has been stopped. * Whether the connection has been stopped.
@@ -117,16 +127,24 @@ export class Connection {
this.logger.debug("Starting Connection"); this.logger.debug("Starting Connection");
this.stopped = false; this.stopped = false;
try { try {
this._state$.next({ this._state$.next(ConnectionState.FetchingConfig);
state: "FetchingConfig", // We should already have this information after creating the localTransport.
}); // It would probably be better to forward this here.
const { url, jwt } = await this.getSFUConfigWithOpenID(); const { url, jwt } = await this.getSFUConfigWithOpenID();
// If we were stopped while fetching the config, don't proceed to connect // If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return; if (this.stopped) return;
this._state$.next({ // Setup observer once we are done with getSFUConfigWithOpenID
state: "ConnectingToLkRoom", connectionStateObserver(this.livekitRoom)
}); .pipe(
this.scope.bind(),
map((s) => s as unknown as ConnectionState),
)
.subscribe((lkState) => {
// It is save to cast lkState to ConnectionState as they are fully overlapping.
this._state$.next(lkState);
});
try { try {
await this.livekitRoom.connect(url, jwt); await this.livekitRoom.connect(url, jwt);
} catch (e) { } catch (e) {
@@ -141,7 +159,8 @@ export class Connection {
throw new InsufficientCapacityError(); throw new InsufficientCapacityError();
} }
if (e.status === 404) { if (e.status === 404) {
// error msg is "Could not establish signal connection: requested room does not exist" // error msg is "Failed to create call"
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
// The room does not exist. There are two different modes of operation for the SFU: // The room does not exist. There are two different modes of operation for the SFU:
// - the room is created on the fly when connecting (livekit `auto_create` option) // - the room is created on the fly when connecting (livekit `auto_create` option)
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service) // - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
@@ -153,19 +172,16 @@ export class Connection {
} }
// If we were stopped while connecting, don't proceed to update state. // If we were stopped while connecting, don't proceed to update state.
if (this.stopped) return; if (this.stopped) return;
this._state$.next({
state: "ConnectedToLkRoom",
livekitConnectionState$: this.scope.behavior(
connectionStateObserver(this.livekitRoom),
),
});
} catch (error) { } catch (error) {
this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next({ this._state$.next(
state: "FailedToStart", error instanceof ElementCallError
error: error instanceof Error ? error : new Error(`${error}`), ? error
}); : error instanceof Error
? new UnknownCallError(error)
: new UnknownCallError(new Error(`${error}`)),
);
// Its okay to ignore the throw. The error is part of the state.
throw error; throw error;
} }
} }
@@ -190,9 +206,7 @@ export class Connection {
); );
if (this.stopped) return; if (this.stopped) return;
await this.livekitRoom.disconnect(); await this.livekitRoom.disconnect();
this._state$.next({ this._state$.next(ConnectionState.Stopped);
state: "Stopped",
});
this.stopped = true; this.stopped = true;
} }
@@ -204,12 +218,12 @@ export class Connection {
* *
* @param opts - Connection options {@link ConnectionOpts}. * @param opts - Connection options {@link ConnectionOpts}.
* *
* @param logger * @param logger - The logger to use.
*/ */
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
this.logger = logger.getChild("[Connection]"); this.logger = logger.getChild("[Connection]");
this.logger.info( this.logger.info(
`[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, `Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
); );
const { transport, client, scope } = opts; const { transport, client, scope } = opts;
@@ -218,23 +232,9 @@ export class Connection {
this.transport = transport; this.transport = transport;
this.client = client; this.client = client;
// REMOTE participants with track!!! this.remoteParticipants$ = scope.behavior(
// this.remoteParticipantsWithTracks$ // Only tracks remote participants
this.remoteParticipantsWithTracks$ = scope.behavior( connectedParticipantsObserver(this.livekitRoom),
// only tracks remote participants
connectedParticipantsObserver(this.livekitRoom, {
additionalRoomEvents: [
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
],
}).pipe(
map((participants) => {
return participants.filter(
(participant) => participant.getTrackPublications().length > 0,
);
}),
),
[],
); );
scope.onEnd(() => { scope.onEnd(() => {

View File

@@ -7,13 +7,15 @@ Please see LICENSE in the repository root for full details.
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { import {
type E2EEOptions,
Room as LivekitRoom, Room as LivekitRoom,
type RoomOptions, type RoomOptions,
type BaseKeyProvider, type BaseKeyProvider,
type E2EEManagerOptions,
type BaseE2EEManager,
} from "livekit-client"; } from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import E2EEWorker from "livekit-client/e2ee-worker?worker"; // imported as inline to support worker when loaded from a cdn (cross domain)
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import { Connection } from "./Connection.ts"; import { Connection } from "./Connection.ts";
@@ -41,9 +43,11 @@ export class ECConnectionFactory implements ConnectionFactory {
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens. * @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
* @param devices - Used for video/audio out/in capture options. * @param devices - Used for video/audio out/in capture options.
* @param processorState$ - Effects like background blur (only for publishing connection?) * @param processorState$ - Effects like background blur (only for publishing connection?)
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit Room. * @param livekitKeyProvider - Optional key provider for end-to-end encryption.
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app). * @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used. * @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
* @param echoCancellation - Whether to enable echo cancellation for audio capture.
* @param noiseSuppression - Whether to enable noise suppression for audio capture.
*/ */
public constructor( public constructor(
private client: OpenIDClientParts, private client: OpenIDClientParts,
@@ -52,20 +56,24 @@ export class ECConnectionFactory implements ConnectionFactory {
livekitKeyProvider: BaseKeyProvider | undefined, livekitKeyProvider: BaseKeyProvider | undefined,
private controlledAudioDevices: boolean, private controlledAudioDevices: boolean,
livekitRoomFactory?: () => LivekitRoom, livekitRoomFactory?: () => LivekitRoom,
echoCancellation: boolean = true,
noiseSuppression: boolean = true,
) { ) {
const defaultFactory = (): LivekitRoom => const defaultFactory = (): LivekitRoom =>
new LivekitRoom( new LivekitRoom(
generateRoomOption( generateRoomOption({
this.devices, devices: this.devices,
this.processorState$.value, processorState: this.processorState$.value,
livekitKeyProvider && { e2eeLivekitOptions: livekitKeyProvider && {
keyProvider: livekitKeyProvider, keyProvider: livekitKeyProvider,
// It's important that every room use a separate E2EE worker. // It's important that every room use a separate E2EE worker.
// They get confused if given streams from multiple rooms. // They get confused if given streams from multiple rooms.
worker: new E2EEWorker(), worker: new E2EEWorker(),
}, },
this.controlledAudioDevices, controlledAudioDevices: this.controlledAudioDevices,
), echoCancellation,
noiseSuppression,
}),
); );
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
} }
@@ -90,12 +98,24 @@ export class ECConnectionFactory implements ConnectionFactory {
/** /**
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state. * Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
*/ */
function generateRoomOption( function generateRoomOption({
devices: MediaDevices, devices,
processorState: ProcessorState, processorState,
e2eeLivekitOptions: E2EEOptions | undefined, e2eeLivekitOptions,
controlledAudioDevices: boolean, controlledAudioDevices,
): RoomOptions { echoCancellation,
noiseSuppression,
}: {
devices: MediaDevices;
processorState: ProcessorState;
e2eeLivekitOptions:
| E2EEManagerOptions
| { e2eeManager: BaseE2EEManager }
| undefined;
controlledAudioDevices: boolean;
echoCancellation: boolean;
noiseSuppression: boolean;
}): RoomOptions {
return { return {
...defaultLiveKitOptions, ...defaultLiveKitOptions,
videoCaptureDefaults: { videoCaptureDefaults: {
@@ -106,6 +126,8 @@ function generateRoomOption(
audioCaptureDefaults: { audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults, ...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id, deviceId: devices.audioInput.selected$.value?.id,
echoCancellation,
noiseSuppression,
}, },
audioOutput: { audioOutput: {
// When using controlled audio devices, we don't want to set the // When using controlled audio devices, we don't want to set the

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type Participant as LivekitParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
@@ -52,7 +52,7 @@ beforeEach(() => {
(transport: LivekitTransport, scope: ObservableScope) => { (transport: LivekitTransport, scope: ObservableScope) => {
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipantsWithTracks$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();
@@ -200,24 +200,21 @@ describe("connections$ stream", () => {
}); });
describe("connectionManagerData$ stream", () => { describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakePublishingParticipantsStreams: Map< let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
string,
Behavior<LivekitParticipant[]>
>;
function keyForTransport(transport: LivekitTransport): string { function keyForTransport(transport: LivekitTransport): string {
return `${transport.livekit_service_url}|${transport.livekit_alias}`; return `${transport.livekit_service_url}|${transport.livekit_alias}`;
} }
beforeEach(() => { beforeEach(() => {
fakePublishingParticipantsStreams = new Map(); fakeRemoteParticipantsStreams = new Map();
function getPublishingParticipantsFor( function getRemoteParticipantsFor(
transport: LivekitTransport, transport: LivekitTransport,
): Behavior<LivekitParticipant[]> { ): Behavior<RemoteParticipant[]> {
return ( return (
fakePublishingParticipantsStreams.get(keyForTransport(transport)) ?? fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
new BehaviorSubject([]) new BehaviorSubject([])
); );
} }
@@ -227,13 +224,12 @@ describe("connectionManagerData$ stream", () => {
.fn() .fn()
.mockImplementation( .mockImplementation(
(transport: LivekitTransport, scope: ObservableScope) => { (transport: LivekitTransport, scope: ObservableScope) => {
const fakePublishingParticipants$ = new BehaviorSubject< const fakeRemoteParticipants$ = new BehaviorSubject<
LivekitParticipant[] RemoteParticipant[]
>([]); >([]);
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipantsWithTracks$: remoteParticipants$: getRemoteParticipantsFor(transport),
getPublishingParticipantsFor(transport),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();
@@ -242,36 +238,36 @@ describe("connectionManagerData$ stream", () => {
void mockConnection.stop(); void mockConnection.stop();
}); });
fakePublishingParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(transport), keyForTransport(transport),
fakePublishingParticipants$, fakeRemoteParticipants$,
); );
return mockConnection; return mockConnection;
}, },
); );
}); });
test("Should report connections with the publishing participants", () => { test("Should report connections with the remote participants", () => {
withTestScheduler(({ expectObservable, schedule, behavior }) => { withTestScheduler(({ expectObservable, schedule, behavior }) => {
// Setup the fake participants streams behavior // Setup the fake participants streams behavior
// ============================== // ==============================
fakePublishingParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_1), keyForTransport(TRANSPORT_1),
behavior("oa-b", { behavior("oa-b", {
o: [], o: [],
a: [{ identity: "user1A" } as LivekitParticipant], a: [{ identity: "user1A" } as RemoteParticipant],
b: [ b: [
{ identity: "user1A" } as LivekitParticipant, { identity: "user1A" } as RemoteParticipant,
{ identity: "user1B" } as LivekitParticipant, { identity: "user1B" } as RemoteParticipant,
], ],
}), }),
); );
fakePublishingParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_2), keyForTransport(TRANSPORT_2),
behavior("o-a", { behavior("o-a", {
o: [], o: [],
a: [{ identity: "user2A" } as LivekitParticipant], a: [{ identity: "user2A" } as RemoteParticipant],
}), }),
); );
// ============================== // ==============================
@@ -289,47 +285,47 @@ describe("connectionManagerData$ stream", () => {
a: expect.toSatisfy((e) => { a: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
return true; return true;
}), }),
b: expect.toSatisfy((e) => { b: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( expect(
"user1A", data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
); ).toBe("user1A");
return true; return true;
}), }),
c: expect.toSatisfy((e) => { c: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( expect(
"user1A", data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
); ).toBe("user1A");
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe( expect(
"user2A", data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
); ).toBe("user2A");
return true; return true;
}), }),
d: expect.toSatisfy((e) => { d: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(2); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( expect(
"user1A", data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
); ).toBe("user1A");
expect(data.getParticipantForTransport(TRANSPORT_1)[1].identity).toBe( expect(
"user1B", data.getParticipantsForTransport(TRANSPORT_1)[1].identity,
); ).toBe("user1B");
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe( expect(
"user2A", data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
); ).toBe("user2A");
return true; return true;
}), }),
}); });

View File

@@ -6,13 +6,10 @@ 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 { import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
type LivekitTransport, import { combineLatest, map, of, switchMap, tap } from "rxjs";
type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
@@ -24,21 +21,18 @@ import { type ConnectionFactory } from "./ConnectionFactory.ts";
export class ConnectionManagerData { export class ConnectionManagerData {
private readonly store: Map< private readonly store: Map<
string, string,
[Connection, (LocalParticipant | RemoteParticipant)[]] { connection: Connection; participants: RemoteParticipant[] }
> = new Map(); > = new Map();
public constructor() {} public constructor() {}
public add( public add(connection: Connection, participants: RemoteParticipant[]): void {
connection: Connection,
participants: (LocalParticipant | RemoteParticipant)[],
): void {
const key = this.getKey(connection.transport); const key = this.getKey(connection.transport);
const existing = this.store.get(key); const existing = this.store.get(key);
if (!existing) { if (!existing) {
this.store.set(key, [connection, participants]); this.store.set(key, { connection, participants });
} else { } else {
existing[1].push(...participants); existing.participants.push(...participants);
} }
} }
@@ -47,58 +41,46 @@ export class ConnectionManagerData {
} }
public getConnections(): Connection[] { public getConnections(): Connection[] {
return Array.from(this.store.values()).map(([connection]) => connection); return Array.from(this.store.values()).map(({ connection }) => connection);
} }
public getConnectionForTransport( public getConnectionForTransport(
transport: LivekitTransport, transport: LivekitTransport,
): Connection | null { ): Connection | null {
return this.store.get(this.getKey(transport))?.[0] ?? null; return this.store.get(this.getKey(transport))?.connection ?? null;
} }
public getParticipantForTransport( public getParticipantsForTransport(
transport: LivekitTransport, transport: LivekitTransport,
): (LocalParticipant | RemoteParticipant)[] { ): RemoteParticipant[] {
const key = transport.livekit_service_url + "|" + transport.livekit_alias; const key = transport.livekit_service_url + "|" + transport.livekit_alias;
const existing = this.store.get(key); const existing = this.store.get(key);
if (existing) { if (existing) {
return existing[1]; return existing.participants;
} }
return []; return [];
} }
/**
* Get all connections where the given participant is publishing.
* In theory, there could be several connections where the same participant is publishing but with
* only well behaving clients a participant should only be publishing on a single connection.
* @param participantId
*/
public getConnectionsForParticipant(
participantId: ParticipantId,
): Connection[] {
const connections: Connection[] = [];
for (const [connection, participants] of this.store.values()) {
if (participants.some((p) => p.identity === participantId)) {
connections.push(connection);
}
}
return connections;
}
} }
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
inputTransports$: Behavior<Epoch<LivekitTransport[]>>; inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger; logger: Logger;
} }
// TODO - write test for scopes (do we really need to bind scope) // TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager { export interface IConnectionManager {
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>; connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
} }
/** /**
* Crete a `ConnectionManager` * Crete a `ConnectionManager`
* @param scope the observable scope used by this object. * @param props - Configuration object
* @param connectionFactory used to create new connections. * @param props.scope - The observable scope used by this object
* @param _transportsSubscriptions$ A list of Behaviors each containing a LIST of LivekitTransport. * @param props.connectionFactory - Used to create new connections
* @param props.inputTransports$ - A list of Behaviors each containing a LIST of LivekitTransport.
* @param props.logger - The logger to use
* Each of these behaviors can be interpreted as subscribed list of transports. * Each of these behaviors can be interpreted as subscribed list of transports.
* *
* Using `registerTransports` independent external modules can control what connections * Using `registerTransports` independent external modules can control what connections
@@ -115,9 +97,6 @@ export function createConnectionManager$({
logger: parentLogger, logger: parentLogger,
}: Props): IConnectionManager { }: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]"); const logger = parentLogger.getChild("[ConnectionManager]");
const running$ = new BehaviorSubject(true);
scope.onEnd(() => running$.next(false));
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing // TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
/** /**
@@ -129,10 +108,7 @@ export function createConnectionManager$({
* externally this is modified via `registerTransports()`. * externally this is modified via `registerTransports()`.
*/ */
const transports$ = scope.behavior( const transports$ = scope.behavior(
combineLatest([running$, inputTransports$]).pipe( inputTransports$.pipe(
map(([running, transports]) =>
transports.mapInner((transport) => (running ? transport : [])),
),
map((transports) => transports.mapInner(removeDuplicateTransports)), map((transports) => transports.mapInner(removeDuplicateTransports)),
tap(({ value: transports }) => { tap(({ value: transports }) => {
logger.trace( logger.trace(
@@ -182,23 +158,25 @@ export function createConnectionManager$({
const epoch = connections.epoch; const epoch = connections.epoch;
// Map the connections to list of {connection, participants}[] // Map the connections to list of {connection, participants}[]
const listOfConnectionsWithPublishingParticipants = const listOfConnectionsWithRemoteParticipants = connections.value.map(
connections.value.map((connection) => { (connection) => {
return connection.remoteParticipantsWithTracks$.pipe( return connection.remoteParticipants$.pipe(
map((participants) => ({ map((participants) => ({
connection, connection,
participants, participants,
})), })),
); );
}); },
);
// probably not required // probably not required
if (listOfConnectionsWithPublishingParticipants.length === 0) {
if (listOfConnectionsWithRemoteParticipants.length === 0) {
return of(new Epoch(new ConnectionManagerData(), epoch)); return of(new Epoch(new ConnectionManagerData(), epoch));
} }
// combineLatest the several streams into a single stream with the ConnectionManagerData // combineLatest the several streams into a single stream with the ConnectionManagerData
return combineLatest(listOfConnectionsWithPublishingParticipants).pipe( return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
map( map(
(lists) => (lists) =>
new Epoch( new Epoch(

View File

@@ -0,0 +1,133 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Room as LivekitRoom } from "livekit-client";
import { BehaviorSubject } from "rxjs";
import fetchMock from "fetch-mock";
import { logger } from "matrix-js-sdk/lib/logger";
import EventEmitter from "events";
import { ObservableScope } from "../../ObservableScope.ts";
import { ECConnectionFactory } from "./ConnectionFactory.ts";
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { exampleTransport, mockMediaDevices } from "../../../utils/test.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { constant } from "../../Behavior";
// At the top of your test file, after imports
vi.mock("livekit-client", async (importOriginal) => {
return {
...(await importOriginal()),
Room: vi.fn().mockImplementation(function (this: LivekitRoom, options) {
const emitter = new EventEmitter();
return {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
disconnect: vi.fn(),
remoteParticipants: new Map(),
} as unknown as LivekitRoom;
}),
};
});
let testScope: ObservableScope;
let mockClient: OpenIDClientParts;
beforeEach(() => {
testScope = new ObservableScope();
mockClient = {
getOpenIdToken: vi.fn().mockReturnValue(""),
getDeviceId: vi.fn().mockReturnValue("DEV000"),
};
});
describe("ECConnectionFactory - Audio inputs options", () => {
test.each([
{ echo: true, noise: true },
{ echo: true, noise: false },
{ echo: false, noise: true },
{ echo: false, noise: false },
])(
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters",
({ echo, noise }) => {
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
undefined,
echo,
noise,
);
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
// Check if Room was constructed with expected options
expect(RoomConstructor).toHaveBeenCalledWith(
expect.objectContaining({
audioCaptureDefaults: expect.objectContaining({
echoCancellation: echo,
noiseSuppression: noise,
}),
}),
);
},
);
});
describe("ECConnectionFactory - ControlledAudioDevice", () => {
test.each([{ controlled: true }, { controlled: false }])(
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
({ controlled }) => {
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
mockMediaDevices({
audioOutput: {
available$: constant(new Map<never, never>()),
selected$: constant({ id: "DEV00", virtualEarpiece: false }),
select: () => {},
},
}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
controlled,
undefined,
false,
false,
);
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
// Check if Room was constructed with expected options
expect(RoomConstructor).toHaveBeenCalledWith(
expect.objectContaining({
audioOutput: expect.objectContaining({
deviceId: controlled ? undefined : "DEV00",
}),
}),
);
},
);
});
afterEach(() => {
testScope.end();
fetchMock.reset();
});

View File

@@ -15,7 +15,7 @@ import { combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts"; import { type IConnectionManager } from "./ConnectionManager.ts";
import { import {
type MatrixLivekitMember, type RemoteMatrixLivekitMember,
createMatrixLivekitMembers$, createMatrixLivekitMembers$,
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { import {
@@ -91,7 +91,7 @@ test("should signal participant not yet connected to livekit", () => {
}), }),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionManager: {
@@ -99,21 +99,24 @@ test("should signal participant not yet connected to livekit", () => {
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { "a",
expect(data.length).toEqual(1); {
expectObservable(data[0].membership$).toBe("a", { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
a: bobMembership, expect(data.length).toEqual(1);
}); expectObservable(data[0].membership$).toBe("a", {
expectObservable(data[0].participant$).toBe("a", { a: bobMembership,
a: null, });
}); expectObservable(data[0].participant.value$).toBe("a", {
expectObservable(data[0].connection$).toBe("a", { a: null,
a: null, });
}); expectObservable(data[0].connection$).toBe("a", {
return true; a: null,
}), });
}); return true;
}),
},
);
}); });
}); });
@@ -171,7 +174,7 @@ test("should signal participant on a connection that is publishing", () => {
}), }),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionManager: {
@@ -179,25 +182,28 @@ test("should signal participant on a connection that is publishing", () => {
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { "a",
expect(data.length).toEqual(1); {
expectObservable(data[0].membership$).toBe("a", { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
a: bobMembership, expect(data.length).toEqual(1);
}); expectObservable(data[0].membership$).toBe("a", {
expectObservable(data[0].participant$).toBe("a", { a: bobMembership,
a: expect.toSatisfy((participant) => { });
expect(participant).toBeDefined(); expectObservable(data[0].participant.value$).toBe("a", {
expect(participant!.identity).toEqual(bobParticipantId); a: expect.toSatisfy((participant) => {
return true; expect(participant).toBeDefined();
}), expect(participant!.identity).toEqual(bobParticipantId);
}); return true;
expectObservable(data[0].connection$).toBe("a", { }),
a: connection, });
}); expectObservable(data[0].connection$).toBe("a", {
return true; a: connection,
}), });
}); return true;
}),
},
);
}); });
}); });
@@ -222,7 +228,7 @@ test("should signal participant on a connection that is not publishing", () => {
}), }),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionManager: {
@@ -230,21 +236,24 @@ test("should signal participant on a connection that is not publishing", () => {
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { "a",
expect(data.length).toEqual(1); {
expectObservable(data[0].membership$).toBe("a", { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
a: bobMembership, expect(data.length).toEqual(1);
}); expectObservable(data[0].membership$).toBe("a", {
expectObservable(data[0].participant$).toBe("a", { a: bobMembership,
a: null, });
}); expectObservable(data[0].participant.value$).toBe("a", {
expectObservable(data[0].connection$).toBe("a", { a: null,
a: connection, });
}); expectObservable(data[0].connection$).toBe("a", {
return true; a: connection,
}), });
}); return true;
}),
},
);
}); });
}); });
@@ -283,7 +292,7 @@ describe("Publication edge case", () => {
}), }),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior( membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$, membershipsWithTransport$,
@@ -293,10 +302,10 @@ describe("Publication edge case", () => {
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe( expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
"a", "a",
{ {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2); expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", { expectObservable(data[0].membership$).toBe("a", {
a: bobMembership, a: bobMembership,
@@ -305,7 +314,7 @@ describe("Publication edge case", () => {
// The real connection should be from transportA as per the membership // The real connection should be from transportA as per the membership
a: connectionA, a: connectionA,
}); });
expectObservable(data[0].participant$).toBe("a", { expectObservable(data[0].participant.value$).toBe("a", {
a: expect.toSatisfy((participant) => { a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined(); expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId); expect(participant!.identity).toEqual(bobParticipantId);
@@ -349,7 +358,7 @@ describe("Publication edge case", () => {
}), }),
); );
const matrixLivekitMember$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior( membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$, membershipsWithTransport$,
@@ -359,10 +368,10 @@ describe("Publication edge case", () => {
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe( expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
"a", "a",
{ {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2); expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", { expectObservable(data[0].membership$).toBe("a", {
a: bobMembership, a: bobMembership,
@@ -371,7 +380,7 @@ describe("Publication edge case", () => {
// The real connection should be from transportA as per the membership // The real connection should be from transportA as per the membership
a: connectionA, a: connectionA,
}); });
expectObservable(data[0].participant$).toBe("a", { expectObservable(data[0].participant.value$).toBe("a", {
// No participant as Bob is not publishing on his membership transport // No participant as Bob is not publishing on his membership transport
a: null, a: null,
}); });

View File

@@ -5,10 +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 { import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
type LocalParticipant as LocalLivekitParticipant,
type RemoteParticipant as RemoteLivekitParticipant,
} from "livekit-client";
import { import {
type LivekitTransport, type LivekitTransport,
type CallMembership, type CallMembership,
@@ -24,22 +21,44 @@ import { generateItemsWithEpoch } from "../../../utils/observable";
const logger = rootLogger.getChild("[MatrixLivekitMembers]"); const logger = rootLogger.getChild("[MatrixLivekitMembers]");
/** interface LocalTaggedParticipant {
* Represents a Matrix call member and their associated LiveKit participation. type: "local";
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room value$: Behavior<LocalParticipant | null>;
* or if it has no livekit transport at all. }
*/ interface RemoteTaggedParticipant {
export interface MatrixLivekitMember { type: "remote";
value$: Behavior<RemoteParticipant | null>;
}
export type TaggedParticipant =
| LocalTaggedParticipant
| RemoteTaggedParticipant;
interface MatrixLivekitMember {
membership$: Behavior<CallMembership>; membership$: Behavior<CallMembership>;
participant$: Behavior<
LocalLivekitParticipant | RemoteLivekitParticipant | null
>;
connection$: Behavior<Connection | null>; connection$: Behavior<Connection | null>;
// participantId: string; We do not want a participantId here since it will be generated by the jwt // participantId: string; We do not want a participantId here since it will be generated by the jwt
// TODO decide if we can also drop the userId. Its in the matrix membership anyways. // TODO decide if we can also drop the userId. Its in the matrix membership anyways.
userId: string; userId: string;
} }
/**
* Represents the local Matrix call member and their associated LiveKit participation.
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
* or if it has no livekit transport at all.
*/
export interface LocalMatrixLivekitMember extends MatrixLivekitMember {
participant: LocalTaggedParticipant;
}
/**
* Represents a remote Matrix call member and their associated LiveKit participation.
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
* or if it has no livekit transport at all.
*/
export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
participant: RemoteTaggedParticipant;
}
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
membershipsWithTransport$: Behavior< membershipsWithTransport$: Behavior<
@@ -61,7 +80,7 @@ export function createMatrixLivekitMembers$({
scope, scope,
membershipsWithTransport$, membershipsWithTransport$,
connectionManager, connectionManager,
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> { }: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
/** /**
* Stream of all the call members and their associated livekit data (if available). * Stream of all the call members and their associated livekit data (if available).
*/ */
@@ -91,7 +110,7 @@ export function createMatrixLivekitMembers$({
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`; const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
const participants = transport const participants = transport
? managerData.getParticipantForTransport(transport) ? managerData.getParticipantsForTransport(transport)
: []; : [];
const participant = const participant =
participants.find((p) => p.identity == participantId) ?? null; participants.find((p) => p.identity == participantId) ?? null;
@@ -108,14 +127,16 @@ export function createMatrixLivekitMembers$({
// 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$` observable in the factory.
(scope, data$, participantId, userId) => { (scope, data$, participantId, userId) => {
logger.debug( logger.debug(
`Updating data$ for participantId: ${participantId}, userId: ${userId}`, `Generating member for participantId: ${participantId}, userId: ${userId}`,
); );
const { participant$, ...rest } = scope.splitBehavior(data$);
// will only get called once per `participantId, userId` pair. // will only get called once per `participantId, userId` pair.
// 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 {
participantId, participantId,
userId, userId,
...scope.splitBehavior(data$), participant: { type: "remote" as const, value$: participant$ },
...rest,
}; };
}, },
), ),

View File

@@ -29,10 +29,11 @@ import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"
import { import {
areLivekitTransportsEqual, areLivekitTransportsEqual,
createMatrixLivekitMembers$, createMatrixLivekitMembers$,
type MatrixLivekitMember, type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -85,7 +86,7 @@ beforeEach(() => {
status: 200, status: 200,
body: { body: {
url: `wss://${domain}/livekit/sfu`, url: `wss://${domain}/livekit/sfu`,
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });
@@ -124,15 +125,15 @@ test("bob, carl, then bob joining no tracks yet", () => {
logger: logger, logger: logger,
}); });
const matrixLivekitItems$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$, membershipsAndTransports.membershipsWithTransport$,
connectionManager, connectionManager,
}); });
expectObservable(matrixLivekitItems$).toBe(vMarble, { expectObservable(matrixLivekitMembers$).toBe(vMarble, {
a: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => { a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
const items = e.value; const items = e.value;
expect(items.length).toBe(1); expect(items.length).toBe(1);
const item = items[0]!; const item = items[0]!;
@@ -147,12 +148,12 @@ test("bob, carl, then bob joining no tracks yet", () => {
), ),
), ),
}); });
expectObservable(item.participant$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
a: null, a: null,
}); });
return true; return true;
}), }),
b: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => { b: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
const items = e.value; const items = e.value;
expect(items.length).toBe(2); expect(items.length).toBe(2);
@@ -161,7 +162,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
expectObservable(item.membership$).toBe("a", { expectObservable(item.membership$).toBe("a", {
a: bobMembership, a: bobMembership,
}); });
expectObservable(item.participant$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
a: null, a: null,
}); });
} }
@@ -172,7 +173,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
expectObservable(item.membership$).toBe("a", { expectObservable(item.membership$).toBe("a", {
a: carlMembership, a: carlMembership,
}); });
expectObservable(item.participant$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
a: null, a: null,
}); });
expectObservable(item.connection$).toBe("a", { expectObservable(item.connection$).toBe("a", {
@@ -189,7 +190,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
} }
return true; return true;
}), }),
c: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => { c: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
const items = e.value; const items = e.value;
expect(items.length).toBe(3); expect(items.length).toBe(3);
@@ -216,7 +217,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
return true; return true;
}), }),
}); });
expectObservable(item.participant$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
a: null, a: null,
}); });
} }

View File

@@ -15,6 +15,7 @@ import { constant } from "./Behavior.ts";
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
import { ElementWidgetActions, widget } from "../widget.ts"; import { ElementWidgetActions, widget } from "../widget.ts";
import { E2eeType } from "../e2ee/e2eeType.ts"; import { E2eeType } from "../e2ee/e2eeType.ts";
import { MatrixRTCMode } from "../settings/settings.ts";
vi.mock("@livekit/components-core", { spy: true }); vi.mock("@livekit/components-core", { spy: true });
@@ -34,36 +35,43 @@ vi.mock("../widget", () => ({
}, },
})); }));
it("expect leave when ElementWidgetActions.HangupCall is called", async () => { it.each([
const pr = Promise.withResolvers<string>(); [MatrixRTCMode.Legacy],
withCallViewModel( [MatrixRTCMode.Compatibil],
{ [MatrixRTCMode.Matrix_2_0],
remoteParticipants$: constant([aliceParticipant]), ])(
rtcMembers$: constant([localRtcMember]), "expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
}, async (mode) => {
(vm: CallViewModel) => { const pr = Promise.withResolvers<string>();
vm.leave$.subscribe((s: string) => { withCallViewModel(mode)(
pr.resolve(s); {
}); remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember]),
},
(vm: CallViewModel) => {
vm.leave$.subscribe((s: string) => {
pr.resolve(s);
});
widget!.lazyActions!.emit( widget!.lazyActions!.emit(
ElementWidgetActions.HangupCall, ElementWidgetActions.HangupCall,
new CustomEvent(ElementWidgetActions.HangupCall, { new CustomEvent(ElementWidgetActions.HangupCall, {
detail: { detail: {
action: "im.vector.hangup", action: "im.vector.hangup",
api: "toWidget", api: "toWidget",
data: {}, data: {},
requestId: "widgetapi-1761237395918", requestId: "widgetapi-1761237395918",
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F", widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
}, },
}), }),
); );
}, },
{ {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
}, },
); );
const source = await pr.promise; const source = await pr.promise;
expect(source).toBe("user"); expect(source).toBe("user");
}); },
);

View File

@@ -20,6 +20,7 @@ import {
createLocalMedia, createLocalMedia,
createRemoteMedia, createRemoteMedia,
withTestScheduler, withTestScheduler,
mockRemoteParticipant,
} from "../utils/test"; } from "../utils/test";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { constant } from "./Behavior"; import { constant } from "./Behavior";
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
test("control a participant's volume", () => { test("control a participant's volume", () => {
const setVolumeSpy = vi.fn(); const setVolumeSpy = vi.fn();
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy }); const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({ setVolume: setVolumeSpy }),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
}); });
test("toggle fit/contain for a participant's video", () => { test("toggle fit/contain for a participant's video", () => {
const vm = createRemoteMedia(rtcMembership, {}, {}); const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab|", { schedule("-ab|", {
a: () => vm.toggleFitContain(), a: () => vm.toggleFitContain(),
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
}); });
expect(deviceId).toBe("front camera"); expect(deviceId).toBe("front camera");
}); });
test("remote media is in waiting state when participant has not yet connected", () => {
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
expect(vm.waitingForMedia$.value).toBe(true);
});
test("remote media is not in waiting state when participant is connected", () => {
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
expect(vm.waitingForMedia$.value).toBe(false);
});
test("remote media is not in waiting state when participant is connected with no publications", () => {
const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({
getTrackPublication: () => undefined,
getTrackPublications: () => [],
}),
);
expect(vm.waitingForMedia$.value).toBe(false);
});
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({}),
undefined, // No room (no advertised transport)
);
expect(vm.waitingForMedia$.value).toBe(false);
});

View File

@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
import { import {
type AudioSource, type AudioSource,
type TrackReferenceOrPlaceholder,
type VideoSource, type VideoSource,
type TrackReference,
observeParticipantEvents, observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
roomEventSelector, roomEventSelector,
@@ -33,7 +33,6 @@ import {
type Observable, type Observable,
Subject, Subject,
combineLatest, combineLatest,
distinctUntilKeyChanged,
filter, filter,
fromEvent, fromEvent,
interval, interval,
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
export function observeTrackReference$( export function observeTrackReference$(
participant: Participant, participant: Participant,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> { ): Observable<TrackReference | undefined> {
return observeParticipantMedia(participant).pipe( return observeParticipantMedia(participant).pipe(
map(() => ({ map(() => participant.getTrackPublication(source)),
participant: participant, distinctUntilChanged(),
publication: participant.getTrackPublication(source), map((publication) => publication && { participant, publication, source }),
source,
})),
distinctUntilKeyChanged("publication"),
); );
} }
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>; public readonly video$: Behavior<TrackReference | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
private observeTrackReference$( private observeTrackReference$(
source: Track.Source, source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | null> { ): Behavior<TrackReference | undefined> {
return this.scope.behavior( return this.scope.behavior(
this.participant$.pipe( this.participant$.pipe(
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))), switchMap((p) =>
!p ? of(undefined) : observeTrackReference$(p, source),
),
), ),
); );
} }
@@ -268,7 +266,7 @@ abstract class BaseMediaViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom$: Behavior<LivekitRoom | undefined>, protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
public readonly focusUrl$: Behavior<string | undefined>, public readonly focusUrl$: Behavior<string | undefined>,
public readonly displayName$: Behavior<string>, public readonly displayName$: Behavior<string>,
public readonly mxcAvatarUrl$: Behavior<string | undefined>, public readonly mxcAvatarUrl$: Behavior<string | undefined>,
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
[audio$, this.video$], [audio$, this.video$],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false || (a?.publication.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication.isEncrypted === false),
), ),
); );
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
private readonly videoTrack$: Observable<LocalVideoTrack | null> = private readonly videoTrack$: Observable<LocalVideoTrack | null> =
this.video$.pipe( this.video$.pipe(
switchMap((v) => { switchMap((v) => {
const track = v?.publication?.track; const track = v?.publication.track;
if (!(track instanceof LocalVideoTrack)) return of(null); if (!(track instanceof LocalVideoTrack)) return of(null);
return merge( return merge(
// Watch for track restarts because they indicate a camera switch. // Watch for track restarts because they indicate a camera switch.
@@ -596,6 +594,21 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media. * A remote participant's user media.
*/ */
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether we are waiting for this user's LiveKit participant to exist. This
* could be because either we or the remote party are still connecting.
*/
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
combineLatest(
[this.livekitRoom$, this.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
);
// This private field is used to override the value from the superclass // This private field is used to override the value from the superclass
private __speaking$: Behavior<boolean>; private __speaking$: Behavior<boolean>;
public get speaking$(): Behavior<boolean> { public get speaking$(): Behavior<boolean> {

View File

@@ -0,0 +1,212 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { MuteStates, MuteState } from "./MuteStates";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedAudioOutputDevice,
type SelectedDevice,
} from "./MediaDevices";
import { constant } from "./Behavior";
import { ObservableScope } from "./ObservableScope";
import { flushPromises, mockMediaDevices } from "../utils/test";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams }));
let testScope: ObservableScope;
beforeEach(() => {
testScope = new ObservableScope();
});
afterEach(() => {
testScope.end();
});
describe("MuteState", () => {
test("should automatically mute if force mute is set", async () => {
const forceMute$ = new BehaviorSubject<boolean>(false);
const deviceStub = {
available$: constant(
new Map<string, DeviceLabel>([
["fbac11", { type: "name", name: "HD Camera" }],
]),
),
selected$: constant({ id: "fbac11" }),
select(): void {},
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
const muteState = new MuteState(
testScope,
deviceStub,
constant(true),
true,
forceMute$,
);
let lastEnabled: boolean = false;
muteState.enabled$.subscribe((enabled) => {
lastEnabled = enabled;
});
let setEnabled: ((enabled: boolean) => void) | null = null;
muteState.setEnabled$.subscribe((setter) => {
setEnabled = setter;
});
await flushPromises();
setEnabled!(true);
await flushPromises();
expect(lastEnabled).toBe(true);
// Now force mute
forceMute$.next(true);
await flushPromises();
// Should automatically mute
expect(lastEnabled).toBe(false);
// Try to unmute can not work
expect(setEnabled).toBeNull();
// Disable force mute
forceMute$.next(false);
await flushPromises();
// TODO I'd expect it to go back to previous state (enabled)
// but actually it goes back to the initial state from construction (disabled)
// Should go back to previous state (enabled)
// Skip for now
// expect(lastEnabled).toBe(true);
// But yet it can be unmuted now
expect(setEnabled).not.toBeNull();
setEnabled!(true);
await flushPromises();
expect(lastEnabled).toBe(true);
});
});
describe("MuteStates", () => {
function aAudioOutputDevices(): MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> {
const selected$ = new BehaviorSubject<
SelectedAudioOutputDevice | undefined
>({
id: "default",
virtualEarpiece: false,
});
return {
available$: constant(
new Map<string, AudioOutputDeviceLabel>([
["default", { type: "speaker" }],
["0000", { type: "speaker" }],
["1111", { type: "earpiece" }],
["222", { type: "name", name: "Bluetooth Speaker" }],
]),
),
selected$,
select(id: string): void {
if (!this.available$.getValue().has(id)) {
logger.warn(`Attempted to select unknown device id: ${id}`);
return;
}
selected$.next({
id,
/** For test purposes we ignore this */
virtualEarpiece: false,
});
},
};
}
function aVideoInput(): MediaDevice<DeviceLabel, SelectedDevice> {
const selected$ = new BehaviorSubject<SelectedDevice | undefined>(
undefined,
);
return {
available$: constant(
new Map<string, DeviceLabel>([
["0000", { type: "name", name: "HD Camera" }],
["1111", { type: "name", name: "WebCam Pro" }],
]),
),
selected$,
select(id: string): void {
if (!this.available$.getValue().has(id)) {
logger.warn(`Attempted to select unknown device id: ${id}`);
return;
}
selected$.next({ id });
},
};
}
test("should mute camera when in earpiece mode", async () => {
const audioOutputDevice = aAudioOutputDevices();
const mediaDevices = mockMediaDevices({
audioOutput: audioOutputDevice,
videoInput: aVideoInput(),
// other devices are not relevant for this test
});
const muteStates = new MuteStates(
testScope,
mediaDevices,
// consider joined
constant(true),
);
let latestSyncedState: boolean | null = null;
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
logger.info(`Video mute state set to: ${enabled}`);
latestSyncedState = enabled;
return Promise.resolve(enabled);
});
let lastVideoEnabled: boolean = false;
muteStates.video.enabled$.subscribe((enabled) => {
lastVideoEnabled = enabled;
});
expect(muteStates.video.setEnabled$.value).toBeDefined();
muteStates.video.setEnabled$.value?.(true);
await flushPromises();
expect(lastVideoEnabled).toBe(true);
// Select earpiece audio output
audioOutputDevice.select("1111");
await flushPromises();
// Video should be automatically muted
expect(lastVideoEnabled).toBe(false);
expect(latestSyncedState).toBe(false);
// Try to switch to speaker
audioOutputDevice.select("0000");
await flushPromises();
// TODO I'd expect it to go back to previous state (enabled)??
// But maybe not? If you move the phone away from your ear you may not want it
// to automatically enable video?
expect(lastVideoEnabled).toBe(false);
// But yet it can be unmuted now
expect(muteStates.video.setEnabled$.value).toBeDefined();
muteStates.video.setEnabled$.value?.(true);
await flushPromises();
expect(lastVideoEnabled).toBe(true);
});
});

View File

@@ -27,7 +27,7 @@ import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "./ObservableScope";
import { type Behavior } from "./Behavior"; import { type Behavior, constant } from "./Behavior";
interface MuteStateData { interface MuteStateData {
enabled$: Observable<boolean>; enabled$: Observable<boolean>;
@@ -38,31 +38,58 @@ interface MuteStateData {
export type Handler = (desired: boolean) => Promise<boolean>; export type Handler = (desired: boolean) => Promise<boolean>;
const defaultHandler: Handler = async (desired) => Promise.resolve(desired); const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
class MuteState<Label, Selected> { /**
* Internal class - exported only for testing purposes.
* Do not use directly outside of tests.
*/
export class MuteState<Label, Selected> {
// TODO: rewrite this to explain behavior, it is not understandable, and cannot add logging
private readonly enabledByDefault$ = private readonly enabledByDefault$ =
this.enabledByConfig && !getUrlParams().skipLobby this.enabledByConfig && !getUrlParams().skipLobby
? this.joined$.pipe(map((isJoined) => !isJoined)) ? this.joined$.pipe(map((isJoined) => !isJoined))
: of(false); : of(false);
private readonly handler$ = new BehaviorSubject(defaultHandler); private readonly handler$ = new BehaviorSubject(defaultHandler);
public setHandler(handler: Handler): void { public setHandler(handler: Handler): void {
if (this.handler$.value !== defaultHandler) if (this.handler$.value !== defaultHandler)
throw new Error("Multiple mute state handlers are not supported"); throw new Error("Multiple mute state handlers are not supported");
this.handler$.next(handler); this.handler$.next(handler);
} }
public unsetHandler(): void { public unsetHandler(): void {
this.handler$.next(defaultHandler); this.handler$.next(defaultHandler);
} }
private readonly canControlDevices$ = combineLatest([
this.device.available$,
this.forceMute$,
]).pipe(
map(([available, forceMute]) => {
return !forceMute && available.size > 0;
}),
);
private readonly data$ = this.scope.behavior<MuteStateData>( private readonly data$ = this.scope.behavior<MuteStateData>(
this.device.available$.pipe( this.canControlDevices$.pipe(
map((available) => available.size > 0),
distinctUntilChanged(), distinctUntilChanged(),
withLatestFrom( withLatestFrom(
this.enabledByDefault$, this.enabledByDefault$,
(devicesConnected, enabledByDefault) => { (canControlDevices, enabledByDefault) => {
if (!devicesConnected) logger.info(
`MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${enabledByDefault}`,
);
if (!canControlDevices) {
logger.info(
`MuteState: devices connected: ${canControlDevices}, disabling`,
);
// We need to sync the mute state with the handler
// to ensure nothing is beeing published.
this.handler$.value(false).catch((err) => {
logger.error("MuteState-disable: handler error", err);
});
return { enabled$: of(false), set: null, toggle: null }; return { enabled$: of(false), set: null, toggle: null };
}
// Assume the default value only once devices are actually connected // Assume the default value only once devices are actually connected
let enabled = enabledByDefault; let enabled = enabledByDefault;
@@ -135,21 +162,45 @@ class MuteState<Label, Selected> {
private readonly device: MediaDevice<Label, Selected>, private readonly device: MediaDevice<Label, Selected>,
private readonly joined$: Observable<boolean>, private readonly joined$: Observable<boolean>,
private readonly enabledByConfig: boolean, private readonly enabledByConfig: boolean,
/**
* An optional observable which, when it emits `true`, will force the mute.
* Used for video to stop camera when earpiece mode is on.
* @private
*/
private readonly forceMute$: Observable<boolean>,
) {} ) {}
} }
export class MuteStates { export class MuteStates {
/**
* True if the selected audio output device is an earpiece.
* Used to force-disable video when on earpiece.
*/
private readonly isEarpiece$ = combineLatest(
this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$,
).pipe(
map(([available, selected]) => {
if (!selected?.id) return false;
const device = available.get(selected.id);
logger.info(`MuteStates: selected audio output device:`, device);
return device?.type === "earpiece";
}),
);
public readonly audio = new MuteState( public readonly audio = new MuteState(
this.scope, this.scope,
this.mediaDevices.audioInput, this.mediaDevices.audioInput,
this.joined$, this.joined$,
Config.get().media_devices.enable_audio, Config.get().media_devices.enable_audio,
constant(false),
); );
public readonly video = new MuteState( public readonly video = new MuteState(
this.scope, this.scope,
this.mediaDevices.videoInput, this.mediaDevices.videoInput,
this.joined$, this.joined$,
Config.get().media_devices.enable_video, Config.get().media_devices.enable_video,
this.isEarpiece$,
); );
public constructor( public constructor(

View File

@@ -27,6 +27,7 @@ import type { ReactionOption } from "../reactions";
import { observeSpeaker$ } from "./observeSpeaker.ts"; import { observeSpeaker$ } from "./observeSpeaker.ts";
import { generateItems } from "../utils/observable.ts"; import { generateItems } from "../utils/observable.ts";
import { ScreenShare } from "./ScreenShare.ts"; import { ScreenShare } from "./ScreenShare.ts";
import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
/** /**
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
@@ -68,40 +69,46 @@ enum SortingBin {
* for inclusion in the call layout and tracks associated screen shares. * for inclusion in the call layout and tracks associated screen shares.
*/ */
export class UserMedia { export class UserMedia {
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal public readonly vm: UserMediaViewModel =
? new LocalUserMediaViewModel( this.participant.type === "local"
this.scope, ? new LocalUserMediaViewModel(
this.id, this.scope,
this.userId, this.id,
this.participant$ as Behavior<LocalParticipant | null>, this.userId,
this.encryptionSystem, this.participant.value$,
this.livekitRoom$, this.encryptionSystem,
this.focusUrl$, this.livekitRoom$,
this.mediaDevices, this.focusUrl$,
this.displayName$, this.mediaDevices,
this.mxcAvatarUrl$, this.displayName$,
this.scope.behavior(this.handRaised$), this.mxcAvatarUrl$,
this.scope.behavior(this.reaction$), this.scope.behavior(this.handRaised$),
) this.scope.behavior(this.reaction$),
: new RemoteUserMediaViewModel( )
this.scope, : new RemoteUserMediaViewModel(
this.id, this.scope,
this.userId, this.id,
this.participant$ as Behavior<RemoteParticipant | null>, this.userId,
this.encryptionSystem, this.participant.value$,
this.livekitRoom$, this.encryptionSystem,
this.focusUrl$, this.livekitRoom$,
this.pretendToBeDisconnected$, this.focusUrl$,
this.displayName$, this.pretendToBeDisconnected$,
this.mxcAvatarUrl$, this.displayName$,
this.scope.behavior(this.handRaised$), this.mxcAvatarUrl$,
this.scope.behavior(this.reaction$), this.scope.behavior(this.handRaised$),
); this.scope.behavior(this.reaction$),
);
private readonly speaker$ = this.scope.behavior( private readonly speaker$ = this.scope.behavior(
observeSpeaker$(this.vm.speaking$), observeSpeaker$(this.vm.speaking$),
); );
// TypeScript needs this widening of the type to happen in a separate statement
private readonly participant$: Behavior<
LocalParticipant | RemoteParticipant | null
> = this.participant.value$;
/** /**
* All screen share media associated with this user media. * All screen share media associated with this user media.
*/ */
@@ -184,9 +191,7 @@ export class UserMedia {
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
public readonly id: string, public readonly id: string,
private readonly userId: string, private readonly userId: string,
private readonly participant$: Behavior< private readonly participant: TaggedParticipant,
LocalParticipant | RemoteParticipant | null
>,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>, private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
private readonly focusUrl$: Behavior<string | undefined>, private readonly focusUrl$: Behavior<string | undefined>,

View File

@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
import { mockRtcMembership, createRemoteMedia } from "../utils/test"; import {
mockRtcMembership,
createRemoteMedia,
mockRemoteParticipant,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
{ mockRemoteParticipant({
setVolume() {}, setVolume() {},
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, ({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
}, }),
); );
const fakeRtcSession = { const fakeRtcSession = {

View File

@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
mirror: boolean; mirror: boolean;
locallyMuted: boolean; locallyMuted: boolean;
waitingForMedia?: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
menuStart?: ReactNode; menuStart?: ReactNode;
menuEnd?: ReactNode; menuEnd?: ReactNode;
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
locallyMuted, locallyMuted,
waitingForMedia,
primaryButton, primaryButton,
menuStart, menuStart,
menuEnd, menuEnd,
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const tile = ( const tile = (
<MediaView <MediaView
ref={ref} ref={ref}
video={video ?? undefined} video={video}
userId={vm.userId} userId={vm.userId}
unencryptedWarning={unencryptedWarning} unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus} encryptionStatus={encryptionStatus}
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
raisedHandTime={handRaised ?? undefined} raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined} currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local} waitingForMedia={waitingForMedia}
focusUrl={focusUrl} focusUrl={focusUrl}
audioStreamStats={audioStreamStats} audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats} videoStreamStats={videoStreamStats}
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const waitingForMedia = useBehavior(vm.waitingForMedia$);
const locallyMuted = useBehavior(vm.locallyMuted$); const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useBehavior(vm.localVolume$); const localVolume = useBehavior(vm.localVolume$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
waitingForMedia={waitingForMedia}
locallyMuted={locallyMuted} locallyMuted={locallyMuted}
mirror={false} mirror={false}
menuStart={ menuStart={

View File

@@ -47,7 +47,6 @@ describe("MediaView", () => {
video: trackReference, video: trackReference,
userId: "@alice:example.com", userId: "@alice:example.com",
mxcAvatarUrl: undefined, mxcAvatarUrl: undefined,
localParticipant: false,
focusable: true, focusable: true,
}; };
@@ -66,24 +65,13 @@ describe("MediaView", () => {
}); });
}); });
describe("with no participant", () => { describe("with no video", () => {
it("shows avatar for local user", () => { it("shows avatar", () => {
render( render(<MediaView {...baseProps} video={undefined} />);
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
);
expect( expect(
screen.getByRole("img", { name: "@alice:example.com" }), screen.getByRole("img", { name: "@alice:example.com" }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryAllByText("Waiting for media...").length).toBe(0); expect(screen.queryByTestId("video")).toBe(null);
});
it("shows avatar and label for remote user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
);
expect(
screen.getByRole("img", { name: "@alice:example.com" }),
).toBeVisible();
expect(screen.getByText("Waiting for media...")).toBeVisible();
}); });
}); });
@@ -94,6 +82,22 @@ describe("MediaView", () => {
}); });
}); });
describe("waitingForMedia", () => {
test("defaults to false", () => {
render(<MediaView {...baseProps} />);
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
});
test("shows and is accessible", async () => {
const { container } = render(
<TooltipProvider>
<MediaView {...baseProps} waitingForMedia={true} />
</TooltipProvider>,
);
expect(await axe(container)).toHaveNoViolations();
expect(screen.getByText("Waiting for media...")).toBeVisible();
});
});
describe("unencryptedWarning", () => { describe("unencryptedWarning", () => {
test("is shown and accessible", async () => { test("is shown and accessible", async () => {
const { container } = render( const { container } = render(

View File

@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
raisedHandTime?: Date; raisedHandTime?: Date;
currentReaction?: ReactionOption; currentReaction?: ReactionOption;
raisedHandOnClick?: () => void; raisedHandOnClick?: () => void;
localParticipant: boolean; waitingForMedia?: boolean;
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
// The focus url, mainly for debugging purposes // The focus url, mainly for debugging purposes
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
raisedHandTime, raisedHandTime,
currentReaction, currentReaction,
raisedHandOnClick, raisedHandOnClick,
localParticipant, waitingForMedia,
audioStreamStats, audioStreamStats,
videoStreamStats, videoStreamStats,
focusUrl, focusUrl,
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
/> />
)} )}
</div> </div>
{!video && !localParticipant && ( {waitingForMedia && (
<div className={styles.status}> <div className={styles.status}>
{t("video_tile.waiting_for_media")} {t("video_tile.waiting_for_media")}
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
mockRtcMembership, mockRtcMembership,
createLocalMedia, createLocalMedia,
createRemoteMedia, createRemoteMedia,
mockRemoteParticipant,
} from "../utils/test"; } from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
{}, mockRemoteParticipant({}),
); );
const vm2 = createLocalMedia( const vm2 = createLocalMedia(

View File

@@ -38,6 +38,7 @@ import {
type MediaViewModel, type MediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
type RemoteUserMediaViewModel,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
interface SpotlightRemoteUserMediaItemProps
extends SpotlightUserMediaItemBaseProps {
vm: RemoteUserMediaViewModel;
}
const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
vm,
...props
}) => {
const waitingForMedia = useBehavior(vm.waitingForMedia$);
return (
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} />
);
};
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
} }
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
return vm instanceof LocalUserMediaViewModel ? ( return vm instanceof LocalUserMediaViewModel ? (
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} /> <SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
) : ( ) : (
<MediaView mirror={false} {...baseProps} /> <SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
); );
}; };

View File

@@ -22,9 +22,12 @@ import * as controls from "./controls";
* Play a sound though a given AudioContext. Will take * Play a sound though a given AudioContext. Will take
* care of connecting the correct buffer and gating * care of connecting the correct buffer and gating
* through gain. * through gain.
* @param volume The volume to play at.
* @param ctx The context to play through. * @param ctx The context to play through.
* @param buffer The buffer to play. * @param buffer The buffer to play.
* @param volume The volume to play at.
* @param stereoPan The stereo pan to apply.
* @param delayS Delay in seconds before starting playing.
* @param abort Optional AbortController that can be used to stop playback.
* @returns A promise that resolves when the sound has finished playing. * @returns A promise that resolves when the sound has finished playing.
*/ */
async function playSound( async function playSound(
@@ -55,9 +58,11 @@ async function playSound(
* Play a sound though a given AudioContext, looping until stopped. Will take * Play a sound though a given AudioContext, looping until stopped. Will take
* care of connecting the correct buffer and gating * care of connecting the correct buffer and gating
* through gain. * through gain.
* @param volume The volume to play at.
* @param ctx The context to play through. * @param ctx The context to play through.
* @param buffer The buffer to play. * @param buffer The buffer to play.
* @param volume The volume to play at.
* @param stereoPan The stereo pan to apply.
* @param delayS Delay in seconds between each loop.
* @returns A function used to end the sound. This function will return a promise when the sound has stopped. * @returns A function used to end the sound. This function will return a promise when the sound has stopped.
*/ */
function playSoundLooping( function playSoundLooping(
@@ -120,7 +125,7 @@ interface UseAudioContext<S extends string> {
/** /**
* Add an audio context which can be used to play * Add an audio context which can be used to play
* a set of preloaded sounds. * a set of preloaded sounds.
* @param props * @param props The properties for the audio context.
* @returns Either an instance that can be used to play sounds, or null if not ready. * @returns Either an instance that can be used to play sounds, or null if not ready.
*/ */
export function useAudioContext<S extends string>( export function useAudioContext<S extends string>(

View File

@@ -77,6 +77,13 @@ export function shouldDisambiguate(
); );
} }
/**
* Calculates a display name for a member, optionally disambiguating it.
* @param member - The member to calculate the display name for.
* @param member.rawDisplayName - The raw display name of the member
* @param member.userId - The user ID of the member
* @param disambiguate - Whether to disambiguate the display name.
*/
export function calculateDisplayName( export function calculateDisplayName(
member: { rawDisplayName?: string; userId: string }, member: { rawDisplayName?: string; userId: string },
disambiguate: boolean, disambiguate: boolean,

View File

@@ -57,9 +57,16 @@ export class ElementCallError extends Error {
} }
} }
/**
* Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured.
*/
export class MatrixRTCTransportMissingError extends ElementCallError { export class MatrixRTCTransportMissingError extends ElementCallError {
public domain: string; public domain: string;
/**
* Creates an instance of MatrixRTCTransportMissingError.
* @param domain - The domain where the MatrixRTC transport is missing.
*/
public constructor(domain: string) { public constructor(domain: string) {
super( super(
t("error.call_is_not_supported"), t("error.call_is_not_supported"),
@@ -75,6 +82,9 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
} }
} }
/**
* Error indicating that the connection to the call was lost and could not be re-established.
*/
export class ConnectionLostError extends ElementCallError { export class ConnectionLostError extends ElementCallError {
public constructor() { public constructor() {
super( super(
@@ -86,7 +96,16 @@ export class ConnectionLostError extends ElementCallError {
} }
} }
/**
* Error indicating a failure in the membership manager causing the join call
* operation to fail.
*/
export class MembershipManagerError extends ElementCallError { export class MembershipManagerError extends ElementCallError {
/**
* Creates an instance of MembershipManagerError.
*
* @param error - The underlying error that caused the membership manager failure.
*/
public constructor(error: Error) { public constructor(error: Error) {
super( super(
t("error.membership_manager"), t("error.membership_manager"),
@@ -98,6 +117,9 @@ export class MembershipManagerError extends ElementCallError {
} }
} }
/**
* Error indicating that end-to-end encryption is not supported in the current environment.
*/
export class E2EENotSupportedError extends ElementCallError { export class E2EENotSupportedError extends ElementCallError {
public constructor() { public constructor() {
super( super(
@@ -109,7 +131,14 @@ export class E2EENotSupportedError extends ElementCallError {
} }
} }
/**
* Error indicating an unknown issue occurred during a call operation.
*/
export class UnknownCallError extends ElementCallError { export class UnknownCallError extends ElementCallError {
/**
* Creates an instance of UnknownCallError.
* @param error - The underlying error that caused the unknown issue.
*/
public constructor(error: Error) { public constructor(error: Error) {
super( super(
t("error.generic"), t("error.generic"),
@@ -122,7 +151,14 @@ export class UnknownCallError extends ElementCallError {
} }
} }
/**
* Error indicating a failure to obtain an OpenID token.
*/
export class FailToGetOpenIdToken extends ElementCallError { export class FailToGetOpenIdToken extends ElementCallError {
/**
* Creates an instance of FailToGetOpenIdToken.
* @param error - The underlying error that caused the failure.
*/
public constructor(error: Error) { public constructor(error: Error) {
super( super(
t("error.generic"), t("error.generic"),
@@ -135,7 +171,14 @@ export class FailToGetOpenIdToken extends ElementCallError {
} }
} }
/**
* Error indicating a failure to start publishing on a LiveKit connection.
*/
export class FailToStartLivekitConnection extends ElementCallError { export class FailToStartLivekitConnection extends ElementCallError {
/**
* Creates an instance of FailToStartLivekitConnection.
* @param e - An optional error message providing additional context.
*/
public constructor(e?: string) { public constructor(e?: string) {
super( super(
t("error.failed_to_start_livekit"), t("error.failed_to_start_livekit"),
@@ -146,6 +189,9 @@ export class FailToStartLivekitConnection extends ElementCallError {
} }
} }
/**
* Error indicating that a LiveKit's server has hit its track limits.
*/
export class InsufficientCapacityError extends ElementCallError { export class InsufficientCapacityError extends ElementCallError {
public constructor() { public constructor() {
super( super(
@@ -157,6 +203,10 @@ export class InsufficientCapacityError extends ElementCallError {
} }
} }
/**
* Error indicating that room creation is restricted by the SFU.
* Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
*/
export class SFURoomCreationRestrictedError extends ElementCallError { export class SFURoomCreationRestrictedError extends ElementCallError {
public constructor() { public constructor() {
super( super(

View File

@@ -188,7 +188,6 @@ function fullAliasFromRoomName(roomName: string, client: MatrixClient): string {
* Applies some basic sanitisation to a room name that the user * Applies some basic sanitisation to a room name that the user
* has given us * has given us
* @param input The room name from the user * @param input The room name from the user
* @param client A matrix client object
*/ */
export function sanitiseRoomNameInput(input: string): string { export function sanitiseRoomNameInput(input: string): string {
// check to see if the user has entered a fully qualified room // check to see if the user has entered a fully qualified room
@@ -304,8 +303,9 @@ export async function createRoom(
/** /**
* Returns an absolute URL to that will load Element Call with the given room * Returns an absolute URL to that will load Element Call with the given room
* @param roomId ID of the room * @param roomId ID of the room
* @param roomName Name of the room
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses * @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
* @param roomName Name of the room
* @param viaServers Optional list of servers to include as 'via' parameters in the URL
*/ */
export function getAbsoluteRoomUrl( export function getAbsoluteRoomUrl(
roomId: string, roomId: string,
@@ -321,8 +321,9 @@ export function getAbsoluteRoomUrl(
/** /**
* Returns a relative URL to that will load Element Call with the given room * Returns a relative URL to that will load Element Call with the given room
* @param roomId ID of the room * @param roomId ID of the room
* @param roomName Name of the room
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses * @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
* @param roomName Name of the room
* @param viaServers Optional list of servers to include as 'via' parameters in the URL
*/ */
export function getRelativeRoomUrl( export function getRelativeRoomUrl(
roomId: string, roomId: string,

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
/** /**
* Finds a media device with label matching 'deviceName' * Finds a media device with label matching 'deviceName'
* @param deviceName The label of the device to look for * @param deviceName The label of the device to look for
* @param kind The kind of media device to look for
* @param devices The list of devices to search * @param devices The list of devices to search
* @returns A matching media device or undefined if no matching device was found * @returns A matching media device or undefined if no matching device was found
*/ */

View File

@@ -5,11 +5,12 @@ 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 { test } from "vitest"; import { expect, test } from "vitest";
import { Subject } from "rxjs"; import { type Observable, of, Subject, switchMap } from "rxjs";
import { withTestScheduler } from "./test"; import { withTestScheduler } from "./test";
import { generateItems, pauseWhen } from "./observable"; import { filterBehavior, generateItems, pauseWhen } from "./observable";
import { type Behavior } from "../state/Behavior";
test("pauseWhen", () => { test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
@@ -72,3 +73,31 @@ test("generateItems", () => {
expectObservable(scope4$).toBe(scope4Marbles); expectObservable(scope4$).toBe(scope4Marbles);
}); });
}); });
test("filterBehavior", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// Filtering the input should segment it into 2 modes of non-null behavior.
const inputMarbles = " abcxabx";
const filteredMarbles = "a--xa-x";
const input$ = behavior(inputMarbles, {
a: "a",
b: "b",
c: "c",
x: null,
});
const filtered$: Observable<Behavior<string> | null> = input$.pipe(
filterBehavior((value) => typeof value === "string"),
);
expectObservable(filtered$).toBe(filteredMarbles, {
a: expect.any(Object),
x: null,
});
expectObservable(
filtered$.pipe(
switchMap((value$) => (value$ === null ? of(null) : value$)),
),
).toBe(inputMarbles, { a: "a", b: "b", c: "c", x: null });
});
});

View File

@@ -22,6 +22,7 @@ import {
withLatestFrom, withLatestFrom,
BehaviorSubject, BehaviorSubject,
type OperatorFunction, type OperatorFunction,
distinctUntilChanged,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
@@ -134,7 +135,6 @@ 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 input$ The input value to be mapped.
* @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,
@@ -185,6 +185,28 @@ export function generateItemsWithEpoch<
); );
} }
/**
* Segments a behavior into periods during which its value matches the filter
* (outputting a behavior with a narrowed type) and periods during which it does
* not match (outputting null).
*/
export function filterBehavior<T, S extends T>(
predicate: (value: T) => value is S,
): OperatorFunction<T, Behavior<S> | null> {
return (input$) =>
input$.pipe(
scan<T, BehaviorSubject<S> | null>((acc$, input) => {
if (predicate(input)) {
const output$ = acc$ ?? new BehaviorSubject(input);
output$.next(input);
return output$;
}
return null;
}, null),
distinctUntilChanged(),
);
}
function generateItemsInternal< function generateItemsInternal<
Input, Input,
Keys extends [unknown, ...unknown[]], Keys extends [unknown, ...unknown[]],

View File

@@ -59,3 +59,17 @@ export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD");
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, { export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD", rawDisplayName: "\u202eevaD",
}); });
export const testJWTToken = [
{}, // header
{
// payload
sub: "@me:example.org:ABCDEF",
video: {
room: "!example_room_id",
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");

View File

@@ -37,6 +37,7 @@ import {
import { aliceRtcMember, localRtcMember } from "./test-fixtures"; import { aliceRtcMember, localRtcMember } from "./test-fixtures";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
import { MatrixRTCMode } from "../settings/settings";
mockConfig({ livekit: { livekit_service_url: "https://example.com" } }); mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
@@ -162,6 +163,7 @@ export function getBasicCallViewModelEnvironment(
setE2EEEnabled: async () => Promise.resolve(), setE2EEEnabled: async () => Promise.resolve(),
}), }),
connectionState$: constant(ConnectionState.Connected), connectionState$: constant(ConnectionState.Connected),
matrixRTCMode$: constant(MatrixRTCMode.Legacy),
...callViewModelOptions, ...callViewModelOptions,
}, },
handRaisedSubject$, handRaisedSubject$,

View File

@@ -44,12 +44,12 @@ import {
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import {
type RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type TrackReference } from "@livekit/components-core"; import { type TrackReference } from "@livekit/components-core";
import EventEmitter from "events"; import EventEmitter from "events";
import {
type KeyTransportEvents,
type KeyTransportEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
@@ -311,6 +311,8 @@ export function mockLocalParticipant(
publishTrack: vi.fn(), publishTrack: vi.fn(),
unpublishTracks: vi.fn().mockResolvedValue([]), unpublishTracks: vi.fn().mockResolvedValue([]),
createTracks: vi.fn(), createTracks: vi.fn(),
setMicrophoneEnabled: vi.fn(),
setCameraEnabled: vi.fn(),
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication, ({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
...mockEmitter(), ...mockEmitter(),
@@ -319,12 +321,12 @@ export function mockLocalParticipant(
} }
export function createLocalMedia( export function createLocalMedia(
localRtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
localParticipant: LocalParticipant, localParticipant: LocalParticipant,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
return new LocalUserMediaViewModel( return new LocalUserMediaViewModel(
testScope(), testScope(),
"local", "local",
@@ -359,23 +361,26 @@ export function mockRemoteParticipant(
} }
export function createRemoteMedia( export function createRemoteMedia(
localRtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: RemoteParticipant | null,
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
{},
{
remoteParticipants$: of(participant ? [participant] : []),
},
),
): RemoteUserMediaViewModel { ): RemoteUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
const remoteParticipant = mockRemoteParticipant(participant);
return new RemoteUserMediaViewModel( return new RemoteUserMediaViewModel(
testScope(), testScope(),
"remote", "remote",
member.userId, member.userId,
of(remoteParticipant), constant(participant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
constant( constant(livekitRoom),
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
),
constant("https://rtc-example.org"), constant("https://rtc-example.org"),
constant(false), constant(false),
constant(member.rawDisplayName ?? "nodisplayname"), constant(member.rawDisplayName ?? "nodisplayname"),
@@ -398,9 +403,9 @@ export function mockConfig(
} }
export class MockRTCSession extends TypedEventEmitter< export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, MatrixRTCSessionEvent | MembershipManagerEvent | KeyTransportEvents,
MatrixRTCSessionEventHandlerMap & KeyTransportEventsHandlerMap &
RoomAndToDeviceEventsHandlerMap & MatrixRTCSessionEventHandlerMap &
MembershipManagerEventHandlerMap MembershipManagerEventHandlerMap
> { > {
public asMockedSession(): MockedObject<MatrixRTCSession> { public asMockedSession(): MockedObject<MatrixRTCSession> {

View File

@@ -64,6 +64,12 @@ export const widget = ((): WidgetHelpers | null => {
try { try {
const { widgetId, parentUrl } = getUrlParams(); const { widgetId, parentUrl } = getUrlParams();
const { roomId, userId, deviceId, baseUrl, e2eEnabled, allowIceFallback } =
getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
if (!baseUrl) throw new Error("Base URL must be supplied");
if (widgetId && parentUrl) { if (widgetId && parentUrl) {
const parentOrigin = new URL(parentUrl).origin; const parentOrigin = new URL(parentUrl).origin;
logger.info("Widget API is available"); logger.info("Widget API is available");
@@ -92,19 +98,6 @@ export const widget = ((): WidgetHelpers | null => {
// We need to do this now rather than later because it has capabilities to // We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?) // request, and is responsible for starting the transport (should it be?)
const {
roomId,
userId,
deviceId,
baseUrl,
e2eEnabled,
allowIceFallback,
} = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
if (!baseUrl) throw new Error("Base URL must be supplied");
// These are all the event types the app uses // These are all the event types the app uses
const sendEvent = [ const sendEvent = [
EventType.CallNotify, // Sent as a deprecated fallback EventType.CallNotify, // Sent as a deprecated fallback

View File

@@ -50,6 +50,11 @@
"plugins": [{ "name": "typescript-eslint-language-service" }] "plugins": [{ "name": "typescript-eslint-language-service" }]
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"], "include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./playwright/**/*.ts",
"./sdk/**/*.ts"
],
"exclude": ["**.test.ts"] "exclude": ["**.test.ts"]
} }

28
vite-sdk.config.ts Normal file
View File

@@ -0,0 +1,28 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { defineConfig, mergeConfig } from "vite";
import nodePolyfills from "vite-plugin-node-stdlib-browser";
const base = "./";
// Config for embedded deployments (possibly hosted under a non-root path)
export default defineConfig(() => ({
worker: { format: "es" as const },
base, // Use relative URLs to allow the app to be hosted under any path
build: {
sourcemap: true,
manifest: true,
lib: {
formats: ["es" as const],
entry: "./sdk/main.ts",
name: "MatrixrtcSdk",
fileName: "matrixrtc-sdk",
},
},
plugins: [nodePolyfills()],
}));

View File

@@ -7,14 +7,17 @@ Please see LICENSE in the repository root for full details.
import { import {
loadEnv, loadEnv,
PluginOption,
searchForWorkspaceRoot, searchForWorkspaceRoot,
type ConfigEnv, type ConfigEnv,
type UserConfig, type UserConfig,
} from "vite"; } from "vite";
import svgrPlugin from "vite-plugin-svgr"; import svgrPlugin from "vite-plugin-svgr";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import { codecovVitePlugin } from "@codecov/vite-plugin"; import { codecovVitePlugin } from "@codecov/vite-plugin";
import { sentryVitePlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin } from "@sentry/vite-plugin";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { realpathSync } from "fs"; import { realpathSync } from "fs";
import * as fs from "node:fs"; import * as fs from "node:fs";
@@ -31,7 +34,7 @@ export default ({
// In future we might be able to do what is needed via code splitting at // In future we might be able to do what is needed via code splitting at
// build time. // build time.
process.env.VITE_PACKAGE = packageType ?? "full"; process.env.VITE_PACKAGE = packageType ?? "full";
const plugins = [ const plugins: PluginOption[] = [
react(), react(),
svgrPlugin({ svgrPlugin({
svgrOptions: { svgrOptions: {
@@ -41,16 +44,6 @@ export default ({
}, },
}), }),
createHtmlPlugin({
entry: "src/main.tsx",
inject: {
data: {
brand: env.VITE_PRODUCT_NAME || "Element Call",
packageType: process.env.VITE_PACKAGE,
},
},
}),
codecovVitePlugin({ codecovVitePlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: "element-call", bundleName: "element-call",
@@ -73,6 +66,18 @@ export default ({
); );
} }
plugins.push(
createHtmlPlugin({
entry: "src/main.tsx",
inject: {
data: {
brand: env.VITE_PRODUCT_NAME || "Element Call",
packageType: process.env.VITE_PACKAGE,
},
},
}),
);
// The crypto WASM module is imported dynamically. Since it's common // The crypto WASM module is imported dynamically. Since it's common
// for developers to use a linked copy of matrix-js-sdk or Rust // for developers to use a linked copy of matrix-js-sdk or Rust
// crypto (which could reside anywhere on their file system), Vite // crypto (which could reside anywhere on their file system), Vite

1178
yarn.lock

File diff suppressed because it is too large Load Diff