Merge branch 'livekit' into toger5/otel-remove-experiment

This commit is contained in:
Timo
2026-01-05 14:22:11 +01:00
committed by GitHub
96 changed files with 6364 additions and 2158 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

@@ -23,7 +23,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Check it out - name: Check it out
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 📥 Download artifact - name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -42,7 +42,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}} tags: ${{ inputs.docker_tags}}

View File

@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- name: Yarn cache - name: Yarn cache

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- name: Yarn cache - name: Yarn cache

View File

@@ -85,7 +85,7 @@ jobs:
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload - name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with: with:
files: | files: |
${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}.tar.gz
@@ -103,7 +103,7 @@ jobs:
id-token: write # Allow npm to authenticate as a trusted publisher id-token: write # Allow npm to authenticate as a trusted publisher
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 📥 Download built element-call artifact - name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -142,7 +142,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 📥 Download built element-call artifact - name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -197,7 +197,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
path: element-call path: element-call
@@ -210,7 +210,7 @@ jobs:
path: element-call/embedded/ios/Sources/dist path: element-call/embedded/ios/Sources/dist
- name: Checkout element-call-swift - name: Checkout element-call-swift
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
repository: element-hq/element-call-swift repository: element-hq/element-call-swift
path: element-call-swift path: element-call-swift
@@ -262,7 +262,7 @@ jobs:
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
- name: Add release notes - name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with: with:
append_body: true append_body: true
body: | body: |

View File

@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum - name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload - name: Upload
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with: with:
files: | files: |
${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}.tar.gz
@@ -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
@@ -68,7 +69,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Add release note - name: Add release note
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with: with:
append_body: true append_body: true
body: | body: |

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- name: Yarn cache - name: Yarn cache
@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
@@ -42,7 +42,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: cpr id: cpr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/localazy-download branch: actions/localazy-download

View File

@@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Upload - name: Upload
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1 uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1

View File

@@ -13,7 +13,6 @@ coverage:
informational: true informational: true
patch: patch:
default: default:
# Encourage (but don't enforce) 80% coverage on all lines that a PR # Enforce 80% coverage on all lines that a PR
# touches # touches
target: 80% target: 80%
informational: true

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

@@ -2,11 +2,11 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions] [versions]
android_gradle_plugin = "8.13.0" android_gradle_plugin = "8.13.1"
[libraries] [libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
[plugins] [plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" }

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

@@ -108,11 +108,14 @@
"connection_lost_description": "You were disconnected from the call.", "connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser", "e2ee_unsupported": "Incompatible browser",
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.", "e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
"failed_to_start_livekit": "Failed to start Livekit connection",
"generic": "Something went wrong", "generic": "Something went wrong",
"generic_description": "Submitting debug logs will help us track down the problem.", "generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity", "insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.", "insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
"open_elsewhere": "Opened in another tab", "open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",

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 .",
@@ -47,7 +49,14 @@
"@livekit/protocol": "^1.42.2", "@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5", "@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18", "@mediapipe/tasks-vision": "^0.10.18",
"@playwright/test": "^1.56.1", "@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.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",
@@ -86,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",
@@ -102,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",
@@ -126,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("/");
@@ -75,7 +77,12 @@ test("Should automatically retry non fatal JWT errors", async ({
test("Should show error screen if call creation is restricted", async ({ test("Should show error screen if call creation is restricted", async ({
page, page,
browserName,
}) => { }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment.",
);
await page.goto("/"); await page.goto("/");
// We need the socket connection to fail, but this cannot be done by using the websocket route. // We need the socket connection to fail, but this cannot be done by using the websocket route.
@@ -88,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

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { render, configure } from "@testing-library/react"; import { render, configure } from "@testing-library/react";
import { RaisedHandIndicator } from "./RaisedHandIndicator"; import { RaisedHandIndicator } from "./RaisedHandIndicator";
@@ -15,6 +15,13 @@ configure({
}); });
describe("RaisedHandIndicator", () => { describe("RaisedHandIndicator", () => {
const fixedTime = new Date("2025-01-01T12:00:00.000Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(fixedTime);
});
test("renders nothing when no hand has been raised", () => { test("renders nothing when no hand has been raised", () => {
const { container } = render(<RaisedHandIndicator />); const { container } = render(<RaisedHandIndicator />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();

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

@@ -15,7 +15,7 @@ exports[`RaisedHandIndicator > renders a smaller indicator when miniature is spe
</span> </span>
</div> </div>
<p> <p>
00:01 00:00
</p> </p>
</div> </div>
`; `;
@@ -35,7 +35,7 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
</span> </span>
</div> </div>
<p> <p>
00:01 00:00
</p> </p>
</div> </div>
`; `;
@@ -55,7 +55,7 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
</span> </span>
</div> </div>
<p> <p>
01:01 01:00
</p> </p>
</div> </div>
`; `;

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,
@@ -87,6 +87,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";
@@ -108,6 +109,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
@@ -126,6 +129,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 } =
@@ -140,15 +144,20 @@ 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$,
scope.behavior(trackProcessorState$), scope.behavior(trackProcessorState$),
); );
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
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();
}; };
}, [ }, [
@@ -249,7 +258,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$);
@@ -266,7 +275,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
@@ -267,7 +274,7 @@ describe("CallViewModel", () => {
}); });
}); });
it.skip("screen sharing activates spotlight layout", () => { test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens, // Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit // then return to no screen shares, then have just Alice share for a bit
@@ -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,9 +15,9 @@ 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 {
catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
EMPTY,
filter, filter,
fromEvent, fromEvent,
map, map,
@@ -28,7 +28,6 @@ import {
pairwise, pairwise,
race, race,
scan, scan,
skip,
skipWhile, skipWhile,
startWith, startWith,
Subject, Subject,
@@ -54,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";
@@ -77,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";
@@ -95,15 +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,
LivekitState, TransportState,
type LocalMemberConnectionState, } from "./localMember/LocalMember.ts";
} from "./localMember/LocalMembership.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import { import {
createMemberships$, createMemberships$,
@@ -113,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,
@@ -128,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
@@ -149,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
@@ -174,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;
@@ -197,12 +206,15 @@ 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
join: () => LocalMemberConnectionState; join: () => void;
// screen sharing // screen sharing
/** /**
@@ -250,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}`)*/
@@ -333,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
@@ -372,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.
@@ -404,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)),
), ),
}); });
@@ -415,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$({
@@ -422,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) => [
@@ -432,7 +462,7 @@ export function createCallViewModel$(
}, },
), ),
), ),
logger: logger, logger,
}); });
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
@@ -443,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...
@@ -454,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,
@@ -474,6 +504,9 @@ export function createCallViewModel$(
mediaDevices, mediaDevices,
muteStates, muteStates,
trackProcessorState$, trackProcessorState$,
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
); );
}, },
connectionManager: connectionManager, connectionManager: connectionManager,
@@ -494,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,
};
}), }),
), ),
); );
@@ -572,34 +604,16 @@ export function createCallViewModel$(
), ),
); );
// CODESMELL? const livekitRoomItems$ = scope.behavior(
// This is functionally the same Observable as leave$, except here it's
// hoisted to the top of the class. This enables the cyclic dependency between
// leave$ -> autoLeave$ -> callPickupState$ -> livekitConnectionState$ ->
// localConnection$ -> transports$ -> joined$ -> leave$.
const leaveHoisted$ = new Subject<
"user" | "timeout" | "decline" | "allOthersLeft"
>();
/**
* 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 pretendToBeDisconnected$ = 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)
@@ -619,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);
@@ -640,7 +654,7 @@ export function createCallViewModel$(
); );
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(pretendToBeDisconnected$)), handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
); );
const reactions$ = scope.behavior( const reactions$ = scope.behavior(
@@ -653,7 +667,7 @@ export function createCallViewModel$(
]), ]),
), ),
), ),
pauseWhen(pretendToBeDisconnected$), pauseWhen(localMembership.reconnecting$),
), ),
); );
@@ -674,10 +688,10 @@ export function createCallViewModel$(
{ value: matrixLivekitMembers }, { value: matrixLivekitMembers },
duplicateTiles, duplicateTiles,
]) { ]) {
let localParticipantId = 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;
@@ -688,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,
@@ -699,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) {
@@ -708,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,
}; };
} }
@@ -720,7 +734,7 @@ export function createCallViewModel$(
dup, dup,
participantId, participantId,
userId, userId,
participant$, participant,
connection$, connection$,
) => { ) => {
const livekitRoom$ = scope.behavior( const livekitRoom$ = scope.behavior(
@@ -739,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,
pretendToBeDisconnected$, 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)),
@@ -840,10 +854,7 @@ export function createCallViewModel$(
merge( merge(
autoLeave$, autoLeave$,
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe( ).pipe(scope.share);
scope.share,
tap((reason) => leaveHoisted$.next(reason)),
);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>( const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
userMedia$.pipe( userMedia$.pipe(
@@ -952,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,
@@ -975,7 +995,6 @@ export function createCallViewModel$(
return "normal"; return "normal";
}), }),
), ),
"normal",
); );
/** /**
@@ -992,36 +1011,11 @@ export function createCallViewModel$(
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)), spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
); );
const gridModeUserSelection$ = new Subject<GridMode>(); const { setGridMode, gridMode$ } = createLayoutModeSwitch(
/** scope,
* The layout mode of the media tile grid. windowMode$,
*/ hasRemoteScreenShares$,
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) =>
(userSelection === "spotlight"
? EMPTY
: combineLatest([hasRemoteScreenShares$, windowMode$]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid",
),
)
).pipe(startWith(userSelection ?? "grid")),
),
),
"grid",
);
const setGridMode = (value: GridMode): void => {
gridModeUserSelection$.next(value);
};
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest( const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
[grid$, spotlight$], [grid$, spotlight$],
@@ -1448,16 +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 join = localMembership.requestConnect; const errors$ = scope.behavior<{
// TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked? transportError?: ElementCallError;
join(); 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: join, join: localMembership.requestJoinAndPublish,
toggleScreenSharing: toggleScreenSharing, toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$, sharingScreen$: sharingScreen$,
@@ -1467,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 === LivekitState.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$,
@@ -1495,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$,
@@ -1502,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

@@ -0,0 +1,527 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
Status as RTCMemberStatus,
type LivekitTransport,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
import { MatrixRTCMode } from "../../../settings/settings";
import {
flushPromises,
mockConfig,
mockLivekitRoom,
mockMuteStates,
withTestScheduler,
} from "../../../utils/test";
import {
TransportState,
createLocalMembership$,
enterRTCSession,
PublishState,
TrackState,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
vi.mock("@livekit/components-core", () => ({
observeParticipantEvents: vi
.fn()
.mockReturnValue(of({ isScreenShareEnabled: false })),
}));
describe("LocalMembership", () => {
describe("enterRTCSession", () => {
it("It joins the correct Session", () => {
const focusFromOlderMembership = {
type: "livekit",
livekit_service_url: "http://my-oldest-member-service-url.com",
livekit_alias: "my-oldest-member-service-alias",
};
const focusConfigFromWellKnown = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
};
const focusConfigFromWellKnown2 = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url2.com",
};
const clientWellKnown = {
"org.matrix.msc4143.rtc_foci": [
focusConfigFromWellKnown,
focusConfigFromWellKnown2,
],
};
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
async (domain) => {
if (domain === "example.org") {
return Promise.resolve(clientWellKnown);
}
return Promise.resolve({});
},
);
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
getOldestMembership: vi.fn().mockReturnValue({
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
}),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
enterRTCSession(
mockedSession,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
[
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
],
undefined,
expect.objectContaining({
manageMediaKeys: true,
useLegacyMemberEvents: false,
}),
);
});
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [
{
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
},
],
});
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn(),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
enterRTCSession(
mockedSession,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
});
});
const defaultCreateLocalMemberValues = {
options: constant({
encryptMedia: false,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
}),
matrixRTCSession: {
updateCallIntent: () => {},
leaveRoomSession: () => {},
} as unknown as MatrixRTCSession,
muteStates: mockMuteStates(),
trackProcessorState$: constant({
supported: false,
processor: undefined,
}),
logger: logger,
createPublisherFactory: vi.fn(),
joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected: {
combined$: constant(true),
rtsSession$: constant(RTCMemberStatus.Connected),
},
};
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransport>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
);
// we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = {
transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))),
),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$,
});
localMembership.requestJoinAndPublish();
expectObservable(localMembership.localMemberState$).toBe("ne", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof MatrixRTCTransportMissingError),
});
});
});
const aTransport = {
livekit_service_url: "a",
} as LivekitTransport;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransport;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
localParticipant: {
isScreenShareEnabled: false,
trackPublications: [],
} as unknown as LocalParticipant,
}),
state$: constant(ConnectionState.LivekitConnected),
transport: aTransport,
} as unknown as Connection;
const connectionTransportAConnecting = {
...connectionTransportAConnected,
state$: constant(ConnectionState.LivekitConnecting),
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected),
transport: bTransport,
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const publishers: Publisher[] = [];
let seed = 0;
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const a = seed;
seed += 1;
logger.info(`creating [${a}]`);
const p = {
stopPublishing: vi.fn().mockImplementation(() => {
logger.info(`stopPublishing [${a}]`);
}),
stopTracks: vi.fn(),
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
connectionManagerData.add(connectionTransportBConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
});
await flushPromises();
localTransport$.next(bTransport);
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
expect(publishers.length).toBe(2);
// stop the first Publisher and let the second one life.
expect(publishers[0].stopTracks).toHaveBeenCalled();
expect(publishers[1].stopTracks).not.toHaveBeenCalled();
expect(publishers[0].stopPublishing).toHaveBeenCalled();
expect(publishers[1].stopPublishing).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
scope.end();
await flushPromises();
// stop all tracks after ending scopes
expect(publishers[1].stopPublishing).toHaveBeenCalled();
// expect(publishers[1].stopTracks).toHaveBeenCalled();
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
});
it("only start tracks if requested", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
const publishing$ = new BehaviorSubject<boolean>(false);
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const p = {
stopPublishing: vi.fn(),
stopTracks: vi.fn(),
createAndSetupTracks: vi.fn().mockImplementation(async () => {
tracks$.next([{}, {}] as LocalTrack[]);
return Promise.resolve();
}),
tracks$,
publishing$,
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
// expect(localMembership.tracks$.value.length).toBe(0);
expect(publishers[0].createAndSetupTracks).not.toHaveBeenCalled();
localMembership.startTracks();
await flushPromises();
expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
// expect(localMembership.tracks$.value.length).toBe(2);
scope.end();
await flushPromises();
// stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled();
// expect(publishers[0].stopTracks).toHaveBeenCalled();
publisherFactory.mockClear();
});
// TODO add an integration test combining publisher and localMembership
//
it("tracks livekit state correctly", async () => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new BehaviorSubject<null | LivekitTransport>(null);
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
);
const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
const publishing$ = new BehaviorSubject<boolean>(false);
const createTrackResolver = Promise.withResolvers<void>();
const publishResolver = Promise.withResolvers<void>();
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const p = {
stopPublishing: vi.fn(),
stopTracks: vi.fn().mockImplementation(() => {
logger.info("stopTracks");
tracks$.next([]);
}),
createAndSetupTracks: vi.fn().mockImplementation(async () => {
await createTrackResolver.promise;
tracks$.next([{}, {}] as LocalTrack[]);
}),
startPublishing: vi.fn().mockImplementation(async () => {
await publishResolver.promise;
publishing$.next(true);
}),
tracks$,
publishing$,
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$,
},
localTransport$,
});
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(aTransport);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
media: { connection: null, tracks: TrackState.WaitingForUser },
});
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();
expect(localMembership.localMemberState$.value).toStrictEqual({
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(localMembership.tracks$.value.length).toBe(0);
// -------
localMembership.startTracks();
// -------
await flushPromises();
// expect(localMembership.localMemberState$.value).toStrictEqual({
// matrix: RTCMemberStatus.Connected,
// media: {
// tracks: TrackState.Creating,
// connection: ConnectionState.LivekitConnected,
// },
// });
createTrackResolver.resolve();
await flushPromises();
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.WaitingForUser);
// -------
localMembership.requestJoinAndPublish();
// -------
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.Publishing);
publishResolver.resolve();
await flushPromises();
expect(
// 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(localMembership.localMemberState$.isStopped).toBe(false);
scope.end();
await flushPromises();
// stays in connected state because it is stopped before the update to tracks update the state.
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localMembership.localMemberState$.value as any).media,
).toStrictEqual(PublishState.Publishing);
// stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled();
// expect(publishers[0].stopTracks).toHaveBeenCalled();
});
// TODO add tests for matrix local matrix participation.
});

View File

@@ -0,0 +1,739 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type Participant,
ParticipantEvent,
type LocalParticipant,
type ScreenShareCaptureOptions,
RoomEvent,
MediaDeviceFailure,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
Status as RTCSessionStatus,
type LivekitTransport,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
from,
fromEvent,
map,
type Observable,
of,
pairwise,
startWith,
switchMap,
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type Behavior } from "../../Behavior.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
import { type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates.ts";
import {
ElementCallError,
FailToStartLivekitConnection,
MembershipManagerError,
UnknownCallError,
} from "../../../utils/errors.ts";
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.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 TransportState {
/** Not even a transport is available to the LocalMembership */
Waiting = "transport_waiting",
}
export enum PublishState {
WaitingForUser = "publish_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track publication anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Starting = "publish_start_publishing",
/** Implies lk connection is connected */
Publishing = "publish_publishing",
}
// TODO not sure how to map that correctly with the
// new publisher that does not manage tracks itself anymore
export enum TrackState {
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
WaitingForUser = "tracks_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track creation anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Creating = "tracks_creating",
/** Implies lk connection is connected */
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 oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
// TODO add a comment into some code style readme or file header callviewmodel
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
scope: ObservableScope;
muteStates: MuteStates;
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransport) => void;
homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LivekitTransport | null>;
matrixRTCSession: Pick<
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
>;
logger: Logger;
}
/**
* This class is responsible for managing the own membership in a room.
* We want
* - a publisher
* -
* @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
* - 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)
* - transport$: the transport object the ownMembership$ ended up using.
* - connectionState: the current connection state. Including matrix server and livekit server connection.
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
*/
export const createLocalMembership$ = ({
scope,
connectionManager,
localTransport$: localTransportCanThrow$,
homeserverConnected,
createPublisherFactory,
joinMatrixRTC,
logger: parentLogger,
muteStates,
matrixRTCSession,
}: Props): {
/**
* This request to start audio and video tracks.
* Can be called early to pre-emptively get media permissions and start devices.
*/
startTracks: () => void;
/**
* This sets a inner state (shouldPublish) to true and instructs the js-sdk and livekit to keep the user
* connected to matrix and livekit.
*/
requestJoinAndPublish: () => void;
requestDisconnect: () => void;
localMemberState$: Behavior<LocalMemberState>;
sharingScreen$: Behavior<boolean>;
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
// tracks$: Behavior<LocalTrack[]>;
participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>;
/**
* Tracks the homserver and livekit connected state and based on that computes reconnecting.
*/
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]");
logger.debug(`Creating local membership..`);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const localTransport$ = scope.behavior(
localTransportCanThrow$.pipe(
catchError((e: unknown) => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error
? e
: new Error("Unknown error from localTransport"),
);
}
setTransportError(error);
return of(null);
}),
),
);
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
]).pipe(
map(([{ value: connectionData }, localTransport]) => {
if (localTransport === null) {
return null;
}
return connectionData.getConnectionForTransport(localTransport);
}),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
);
// Tracks error that happen when creating the local tracks.
const mediaErrors$ = localConnection$.pipe(
switchMap((connection) => {
if (!connection) {
return of(null);
} else {
return fromEvent(
connection.livekitRoom,
RoomEvent.MediaDevicesError,
(error: Error) => {
return MediaDeviceFailure.getFailure(error) ?? null;
},
);
}
}),
);
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
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested = Promise.withResolvers<void>();
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const joinAndPublishRequested$ = new BehaviorSubject(false);
/**
* The publisher is stored in here an abstracts creating and publishing tracks.
*/
const publisher$ = new BehaviorSubject<Publisher | null>(null);
const startTracks = (): void => {
trackStartRequested.resolve();
// This used to return the tracks, but now they are only accessible via the publisher.
};
const requestJoinAndPublish = (): void => {
trackStartRequested.resolve();
joinAndPublishRequested$.next(true);
};
const requestDisconnect = (): void => {
joinAndPublishRequested$.next(false);
};
// Take care of the publisher$
// create a new one as soon as a local Connection is available
//
// Recreate a new one once the local connection changes
// - stop publishing
// - destruct all current streams
// - overwrite current publisher
scope.reconcile(localConnection$, async (connection) => {
if (connection !== null) {
const publisher = createPublisherFactory(connection);
publisher$.next(publisher);
// Clean-up callback
return Promise.resolve(async (): Promise<void> => {
await publisher.stopPublishing();
await publisher.stopTracks();
});
}
});
// Use reconcile here to not run concurrent createAndSetupTracks calls
// `tracks$` will update once they are ready.
scope.reconcile(
scope.behavior(
combineLatest([
publisher$ /*, tracks$*/,
from(trackStartRequested.promise),
]),
null,
),
async (valueIfReady) => {
if (!valueIfReady) return;
const [publisher] = valueIfReady;
if (publisher) {
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
}
},
);
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
scope.reconcile(
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
async ([publisher, shouldJoinAndPublish]) => {
// Get the current publishing state to avoid redundant calls.
const isPublishing = publisher?.shouldPublish === true;
if (shouldJoinAndPublish && !isPublishing) {
try {
await publisher?.startPublishing();
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setPublishError(new FailToStartLivekitConnection(message));
}
} else if (isPublishing) {
try {
await publisher?.stopPublishing();
} catch (error) {
setPublishError(new UnknownCallError(error as Error));
}
}
},
);
// STATE COMPUTATION
// These are non fatal since we can join a room and concume media even though publishing failed.
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
const setPublishError = (e: ElementCallError): void => {
if (publishError$.value !== null) {
logger.error("Multiple Media Errors:", e);
} else {
publishError$.next(e);
}
};
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([
localConnectionState$,
localTransport$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
startWith(false),
),
]).pipe(
map(
([
localConnectionState,
localTransport,
shouldPublish,
shouldStartTracks,
]) => {
if (!localTransport) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
},
),
distinctUntilChanged(deepCompare),
),
);
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
const setMatrixError = (e: ElementCallError): void => {
if (fatalMatrixError$.value !== null) {
logger.error("Multiple Matrix Errors:", e);
} else {
fatalMatrixError$.next(e);
}
};
const localMemberState$ = scope.behavior<LocalMemberState>(
combineLatest([
mediaState$,
homeserverConnected.rtsSession$,
fatalMatrixError$,
fatalTransportError$,
publishError$,
]).pipe(
map(
([
mediaState,
rtcSessionStatus,
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;
},
),
),
);
/**
* 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.behavior(combineLatest([localTransport$, joinAndPublishRequested$])),
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;
try {
joinMatrixRTC(transport);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
}
return Promise.resolve(async (): Promise<void> => {
try {
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
await matrixRTCSession.leaveRoomSession(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
});
},
);
const participant$ = scope.behavior(
localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)),
);
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
// TODO refactor this based no livekitState$
combineLatest([participant$, homeserverConnected.combined$])
.pipe(scope.bind())
.subscribe(([participant, connected]) => {
if (!participant) return;
const publications = participant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
/**
* Whether the user is currently sharing their screen.
*/
const sharingScreen$ = scope.behavior(
participant$.pipe(
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
),
);
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
};
const targetScreenshareState = !sharingScreen$.value;
logger.info(
`toggleScreenSharing called. Switching ${
targetScreenshareState ? "On" : "Off"
}`,
);
// If a connection is ready, toggle screen sharing.
// We deliberately do nothing in the case of a null connection because
// it looks nice for the call control buttons to all become available
// at once upon joining the call, rather than introducing a disabled
// state. The user can just click again.
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
participant$.value
?.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
.catch(logger.error);
};
}
return {
startTracks,
requestJoinAndPublish,
requestDisconnect,
localMemberState$,
participant$,
reconnecting$,
connected$: matrixAndLivekitConnected$,
disconnected$: scope.behavior(
homeserverConnected.rtsSession$.pipe(
map((state) => state === RTCSessionStatus.Disconnected),
),
),
sharingScreen$,
toggleScreenSharing,
connection$: localConnection$,
};
};
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
}
/**
* Does the necessary steps to enter the RTC session on the matrix side:
* - Preparing the membership info (FOCUS to use, options)
* - Sends the matrix event to join the call, and starts the membership manager:
* - Delay events management
* - Handles retries (fails only after several attempts)
*
* @param rtcSession - The MatrixRTCSession to join.
* @param transport - The LivekitTransport to use for this session.
* @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.
*/
// Exported for unit testing
export function enterRTCSession(
rtcSession: MatrixRTCSession,
transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): void {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall();
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
// TODO where/how do we track errors originating from the ongoing rtcSession?
rtcSession.joinRoomSession(
multiSFU ? [] : [transport],
multiSFU ? transport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
},
);
}

View File

@@ -1,229 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LivekitTransport,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixRTCMode } from "../../../settings/settings";
import {
mockConfig,
mockMuteStates,
withTestScheduler,
} from "../../../utils/test";
import {
createLocalMembership$,
enterRTCSession,
LivekitState,
} from "./LocalMembership";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { type Publisher } from "./Publisher";
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
describe("LocalMembership", () => {
describe("enterRTCSession", () => {
it("It joins the correct Session", async () => {
const focusFromOlderMembership = {
type: "livekit",
livekit_service_url: "http://my-oldest-member-service-url.com",
livekit_alias: "my-oldest-member-service-alias",
};
const focusConfigFromWellKnown = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
};
const focusConfigFromWellKnown2 = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url2.com",
};
const clientWellKnown = {
"org.matrix.msc4143.rtc_foci": [
focusConfigFromWellKnown,
focusConfigFromWellKnown2,
],
};
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
async (domain) => {
if (domain === "example.org") {
return Promise.resolve(clientWellKnown);
}
return Promise.resolve({});
},
);
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
getOldestMembership: vi.fn().mockReturnValue({
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
}),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
await enterRTCSession(
mockedSession,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
[
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
],
undefined,
expect.objectContaining({
manageMediaKeys: true,
useLegacyMemberEvents: false,
}),
);
});
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => {
mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [
{
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
},
],
});
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn(),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
await enterRTCSession(
mockedSession,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
});
});
const defaultCreateLocalMemberValues = {
options: constant({
encryptMedia: false,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
}),
matrixRTCSession: {
updateCallIntent: () => {},
leaveRoomSession: () => {},
} as unknown as MatrixRTCSession,
muteStates: mockMuteStates(),
isHomeserverConnected: constant(true),
trackProcessorState$: constant({
supported: false,
processor: undefined,
}),
logger: logger,
createPublisherFactory: (): Publisher => ({}) as unknown as Publisher,
joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected$: constant(true),
};
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
const goodTransport = {
livekit_service_url: "other",
} as LivekitTransport;
const localTransport$ = scope.behavior<LivekitTransport>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
goodTransport,
);
const mockConnectionManager = {
transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))),
),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$,
});
expectObservable(localMembership.connectionState.livekit$).toBe("ne", {
n: { state: LivekitState.Uninitialized },
e: {
state: LivekitState.Error,
error: expect.toSatisfy(
(e) => e instanceof MatrixRTCTransportMissingError,
),
},
});
});
});
});

View File

@@ -1,629 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrack,
type Participant,
ParticipantEvent,
type LocalParticipant,
type ScreenShareCaptureOptions,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
type LivekitTransport,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
map,
type Observable,
of,
scan,
switchMap,
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
import { ObservableScope } from "../../ObservableScope";
import { type Publisher } from "./Publisher";
import { type MuteStates } from "../../MuteStates";
import { and$ } from "../../../utils/observable";
import { ElementCallError, UnknownCallError } from "../../../utils/errors";
import { ElementWidgetActions, widget } from "../../../widget";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts";
import {
type Connection,
type ConnectionState,
} from "../remoteMembers/Connection.ts";
export enum LivekitState {
Uninitialized = "uninitialized",
Connecting = "connecting",
Connected = "connected",
Error = "error",
Disconnected = "disconnected",
Disconnecting = "disconnecting",
}
type LocalMemberLivekitState =
| { state: LivekitState.Error; error: ElementCallError }
| { state: LivekitState.Connected }
| { state: LivekitState.Connecting }
| { state: LivekitState.Uninitialized }
| { state: LivekitState.Disconnected }
| { state: LivekitState.Disconnecting };
export enum MatrixState {
Connected = "connected",
Disconnected = "disconnected",
Connecting = "connecting",
Error = "Error",
}
type LocalMemberMatrixState =
| { state: MatrixState.Connected }
| { state: MatrixState.Connecting }
| { state: MatrixState.Disconnected }
| { state: MatrixState.Error; error: Error };
export interface LocalMemberConnectionState {
livekit$: Behavior<LocalMemberLivekitState>;
matrix$: Behavior<LocalMemberMatrixState>;
}
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
// TODO add a comment into some code style readme or file header callviewmodel
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
scope: ObservableScope;
muteStates: MuteStates;
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (trasnport: LivekitTransport) => Promise<void>;
homeserverConnected$: Behavior<boolean>;
localTransport$: Behavior<LivekitTransport | null>;
matrixRTCSession: Pick<
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
>;
logger: Logger;
}
/**
* This class is responsible for managing the own membership in a room.
* We want
* - a publisher
* -
* @param param0
* @returns
* - 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)
* - transport$: the transport object the ownMembership$ ended up using.
* - connectionState: the current connection state. Including matrix server and livekit server connection.
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
*/
export const createLocalMembership$ = ({
scope,
connectionManager,
localTransport$: localTransportCanThrow$,
homeserverConnected$,
createPublisherFactory,
joinMatrixRTC,
logger: parentLogger,
muteStates,
matrixRTCSession,
}: Props): {
requestConnect: () => LocalMemberConnectionState;
startTracks: () => Behavior<LocalTrack[]>;
requestDisconnect: () => Observable<LocalMemberLivekitState> | null;
connectionState: LocalMemberConnectionState;
sharingScreen$: Behavior<boolean>;
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>;
homeserverConnected$: Behavior<boolean>;
// deprecated fields
/** @deprecated use state instead*/
connected$: Behavior<boolean>;
// this needs to be discussed
/** @deprecated use state instead*/
reconnecting$: Behavior<boolean>;
} => {
const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`);
const state = {
livekit$: new BehaviorSubject<LocalMemberLivekitState>({
state: LivekitState.Uninitialized,
}),
matrix$: new BehaviorSubject<LocalMemberMatrixState>({
state: MatrixState.Disconnected,
}),
};
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const connectRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
// unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const localTransport$ = scope.behavior(
localTransportCanThrow$.pipe(
catchError((e: unknown) => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error
? e
: new Error("Unknown error from localTransport"),
);
}
state.livekit$.next({ state: LivekitState.Error, error });
return of(null);
}),
),
);
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
]).pipe(
map(([connectionData, localTransport]) => {
if (localTransport === null) {
return null;
}
return connectionData.value.getConnectionForTransport(localTransport);
}),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
);
// /**
// * Whether we are "fully" connected to the call. Accounts for both the
// * connection to the MatrixRTC session and the LiveKit publish connection.
// */
// // TODO use this in combination with the MemberState.
const connected$ = scope.behavior(
and$(
homeserverConnected$,
localConnection$.pipe(
switchMap((c) =>
c
? c.state$.pipe(map((state) => state.state === "ConnectedToLkRoom"))
: of(false),
),
),
),
);
const publisher$ = new BehaviorSubject<Publisher | null>(null);
localConnection$.pipe(scope.bind()).subscribe((connection) => {
if (connection !== null && publisher$.value === null) {
// TODO looks strange to not change publisher if connection changes.
// @toger5 will take care of this!
publisher$.next(createPublisherFactory(connection));
}
});
// const mutestate= publisher$.pipe(switchMap((publisher) => {
// return publisher.muteState$
// });
combineLatest([publisher$, trackStartRequested$]).subscribe(
([publisher, shouldStartTracks]) => {
if (publisher && shouldStartTracks) {
publisher
.createAndSetupTracks()
.then((tracks) => {
tracks$.next(tracks);
})
.catch((error) => {
logger.error("Error creating tracks:", error);
});
}
},
);
// 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),
),
);
const startTracks = (): Behavior<LocalTrack[]> => {
trackStartRequested$.next(true);
return tracks$;
};
combineLatest([publisher$, tracks$]).subscribe(([publisher, tracks]) => {
if (
tracks.length === 0 ||
// change this to !== Publishing
state.livekit$.value.state !== LivekitState.Uninitialized
) {
return;
}
state.livekit$.next({ state: LivekitState.Connecting });
publisher
?.startPublishing()
.then(() => {
state.livekit$.next({ state: LivekitState.Connected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
});
combineLatest([localTransport$, connectRequested$]).subscribe(
// TODO reconnect when transport changes => create test.
([transport, connectRequested]) => {
if (
transport === null ||
!connectRequested ||
state.matrix$.value.state !== MatrixState.Disconnected
) {
logger.info(
"Not yet connecting because: ",
"transport === null:",
transport === null,
"!connectRequested:",
!connectRequested,
"state.matrix$.value.state !== MatrixState.Disconnected:",
state.matrix$.value.state !== MatrixState.Disconnected,
);
return;
}
state.matrix$.next({ state: MatrixState.Connecting });
logger.info("Matrix State connecting");
joinMatrixRTC(transport).catch((error) => {
logger.error(error);
state.matrix$.next({ state: MatrixState.Error, error });
});
},
);
// TODO add this and update `state.matrix$` based on it.
// useTypedEventEmitter(
// rtcSession,
// MatrixRTCSessionEvent.MembershipManagerError,
// (error) => setExternalError(new ConnectionLostError()),
// );
const requestConnect = (): LocalMemberConnectionState => {
trackStartRequested$.next(true);
connectRequested$.next(true);
return state;
};
const requestDisconnect = (): Behavior<LocalMemberLivekitState> | null => {
if (state.livekit$.value.state !== LivekitState.Connected) return null;
state.livekit$.next({ state: LivekitState.Disconnecting });
combineLatest([publisher$, tracks$], (publisher, tracks) => {
publisher
?.stopPublishing()
.then(() => {
tracks.forEach((track) => track.stop());
state.livekit$.next({ state: LivekitState.Disconnected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
});
return state.livekit$;
};
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
combineLatest([localConnection$, homeserverConnected$])
.pipe(scope.bind())
.subscribe(([connection, connected]) => {
if (connection?.state$.value.state !== "ConnectedToLkRoom") return;
const publications =
connection.livekitRoom.localParticipant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
// TODO: Refactor updateCallIntent to sth like this:
// combineLatest([muteStates.video.enabled$,localTransport$, state.matrix$]).pipe(map(()=>{
// matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
// }))
//
// TODO I do not fully understand what this does.
// Is it needed?
// Is this at the right place?
// Can this be simplified?
// Start and stop session membership as needed
// Discussed in statndup -> It seems we can remove this (there is another call to enterRTCSession in this file)
// MAKE SURE TO UNDERSTAND why reconcile is needed and what is potentially missing from the alternative enterRTCSession block.
// @toger5 will try to take care of this.
scope.reconcile(localTransport$, async (transport) => {
if (transport !== null && transport !== undefined) {
try {
state.matrix$.next({ state: MatrixState.Connecting });
await joinMatrixRTC(transport);
} catch (e) {
logger.error("Error entering RTC session", e);
}
// Update our member event when our mute state changes.
const intentScope = new ObservableScope();
intentScope.reconcile(muteStates.video.enabled$, async (videoEnabled) =>
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
);
return async (): Promise<void> => {
intentScope.end();
// Only sends Matrix leave event. The LiveKit session will disconnect
// as soon as either the stopConnection$ handler above gets to it or
// the view model is destroyed.
try {
await matrixRTCSession.leaveRoomSession();
} catch (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);
}
};
}
});
localConnection$
.pipe(
distinctUntilChanged(),
switchMap((c) =>
c === null ? of({ state: "Initialized" } as ConnectionState) : c.state$,
),
map((s) => {
logger.trace(`Local connection state update: ${s.state}`);
if (s.state == "FailedToStart") {
return s.error instanceof ElementCallError
? s.error
: new UnknownCallError(s.error);
}
}),
scope.bind(),
)
.subscribe((error) => {
if (error !== undefined)
state.livekit$.next({ state: LivekitState.Error, error });
});
/**
* Whether the user is currently sharing their screen.
*/
const sharingScreen$ = scope.behavior(
localConnection$.pipe(
switchMap((c) =>
c !== null
? observeSharingScreen$(c.livekitRoom.localParticipant)
: of(false),
),
),
);
let toggleScreenSharing = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
};
const targetScreenshareState = !sharingScreen$.value;
logger.info(
`toggleScreenSharing called. Switching ${
targetScreenshareState ? "On" : "Off"
}`,
);
// If a connection is ready, toggle screen sharing.
// We deliberately do nothing in the case of a null connection because
// it looks nice for the call control buttons to all become available
// at once upon joining the call, rather than introducing a disabled
// state. The user can just click again.
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
localConnection$.value?.livekitRoom.localParticipant
.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
.catch(logger.error);
};
}
const participant$ = scope.behavior(
localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)),
);
return {
startTracks,
requestConnect,
requestDisconnect,
connectionState: state,
homeserverConnected$,
connected$,
reconnecting$,
sharingScreen$,
toggleScreenSharing,
participant$,
connection$: localConnection$,
};
};
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
}
/**
* Does the necessary steps to enter the RTC session on the matrix side:
* - Preparing the membership info (FOCUS to use, options)
* - Sends the matrix event to join the call, and starts the membership manager:
* - Delay events management
* - Handles retries (fails only after several attempts)
*
* @param rtcSession
* @param transport
* @param options
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/
// Exported for unit testing
export async function enterRTCSession(
rtcSession: MatrixRTCSession,
transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): Promise<void> {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall();
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
// TODO where/how do we track errors originating from the ongoing rtcSession?
rtcSession.joinRoomSession(
multiSFU ? [] : [transport],
multiSFU ? transport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
},
);
if (widget) {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
}
}

View File

@@ -5,8 +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, 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";
@@ -17,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
@@ -60,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(),
}, },
@@ -83,12 +86,12 @@ describe("LocalTransport", () => {
expect(() => localTransport$.value).toThrow(expectedError); expect(() => localTransport$.value).toThrow(expectedError);
}); });
it("emits preferred transport after OpenID resolves", 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({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
const memberships$ = new BehaviorSubject(new Epoch([]));
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>(); const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue( vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
@@ -97,24 +100,171 @@ describe("LocalTransport", () => {
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember$: constant(true),
memberships$: constant(new Epoch<CallMembership[]>([])), 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

@@ -0,0 +1,360 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import {
ConnectionState as LivekitConnectionState,
LocalParticipant,
type LocalTrack,
type LocalTrackPublication,
ParticipantEvent,
Track,
} from "livekit-client";
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import {
flushPromises,
mockLivekitRoom,
mockMediaDevices,
} from "../../../utils/test";
import { Publisher } from "./Publisher";
import { type Connection } from "../remoteMembers/Connection";
import { type MuteStates } from "../../MuteStates";
let scope: ObservableScope;
beforeEach(() => {
scope = new ObservableScope();
});
afterEach(() => scope.end());
function createMockLocalTrack(source: Track.Source): LocalTrack {
const track = {
source,
isMuted: false,
isUpstreamPaused: false,
} as Partial<LocalTrack> as LocalTrack;
vi.mocked(track).mute = vi.fn().mockImplementation(() => {
track.isMuted = true;
});
vi.mocked(track).unmute = vi.fn().mockImplementation(() => {
track.isMuted = false;
});
vi.mocked(track).pauseUpstream = vi.fn().mockImplementation(() => {
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
track.isUpstreamPaused = true;
});
vi.mocked(track).resumeUpstream = vi.fn().mockImplementation(() => {
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
track.isUpstreamPaused = false;
});
return track;
}
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(
scope,
connection,
mockMediaDevices({}),
muteStates,
constant({ supported: false, processor: undefined }),
logger,
);
audioEnabled$.next(true);
const resolvers = Promise.withResolvers<void>();
createTrackLock = resolvers.promise;
// Initially the audio is unmuted, so creating tracks should publish the audio track
const createTracks = publisher.createAndSetupTracks();
void publisher.startPublishing();
void createTracks.then(() => {
void publisher.startPublishing();
});
// now mute the audio before allowing track creation to complete
audioEnabled$.next(false);
resolvers.resolve(undefined);
await createTracks;
await flushPromises();
const track = localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track;
expect(track).toBeDefined();
try {
expect(localParticipant.publishTrack).not.toHaveBeenCalled();
} catch {
expect(track!.mute).toHaveBeenCalled();
expect(track!.isMuted).toBe(true);
}
});
});

View File

@@ -6,12 +6,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 { 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 {
map, map,
@@ -40,31 +40,36 @@ import { type ObservableScope } from "../../ObservableScope.ts";
* 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 {
public tracks: LocalTrack<Track.Kind>[] = []; /**
* 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,
private connection: Connection, private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
devices: MediaDevices, devices: MediaDevices,
private readonly muteStates: MuteStates, private readonly muteStates: MuteStates,
trackerProcessorState$: Behavior<ProcessorState>, trackerProcessorState$: Behavior<ProcessorState>,
private logger?: Logger, private logger: Logger,
) { ) {
this.logger?.info("[PublishConnection] Create LiveKit room");
const { controlledAudioDevices } = getUrlParams(); const { controlledAudioDevices } = getUrlParams();
const room = connection.livekitRoom; const room = connection.livekitRoom;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
this.logger?.error("Failed to set E2EE enabled on room", e); this.logger.error("Failed to set E2EE enabled on room", e);
}); });
// Setup track processor syncing (blur) // Setup track processor syncing (blur)
@@ -74,117 +79,187 @@ export class Publisher {
this.workaroundRestartAudioInputTrackChrome(devices, scope); this.workaroundRestartAudioInputTrackChrome(devices, scope);
this.scope.onEnd(() => { this.scope.onEnd(() => {
this.logger?.info( this.logger.info("Scope ended -> stop publishing all tracks");
"[PublishConnection] Scope ended -> stop publishing all tracks",
);
void this.stopPublishing(); void this.stopPublishing();
muteStates.audio.unsetHandler();
muteStates.video.unsetHandler();
}); });
this.connection.livekitRoom.localParticipant.on(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished.bind(this),
);
}
// LiveKit will publish the tracks as soon as they are created
// 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,
);
});
}
}
/**
* 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.
*
* It also observes mute state changes to update LiveKit microphone/camera states accordingly.
* If a track is not created initially because disabled, it will be created when unmuting.
*
* This call is not blocking anymore, instead callers can listen to the
* `RoomEvent.MediaDevicesError` event in the LiveKit room to be notified of any errors.
*
*/
public async createAndSetupTracks(): Promise<void> {
this.logger.debug("createAndSetupTracks called");
const lkRoom = this.connection.livekitRoom;
// Observe mute state changes and update LiveKit microphone/camera states accordingly
this.observeMuteStates();
// 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 video = this.muteStates.video.enabled$.value;
// We don't await the creation, because livekit could block until the tracks
// are fully published, and not only that they are created.
// We don't have control on that, localParticipant creates and publishes the tracks
// asap.
// We are using the `ParticipantEvent.LocalTrackPublished` to be notified
// when tracks are actually published, and at that point
// we can pause upstream if needed (depending on if startPublishing has been called).
if (audio && video) {
// Enable both at once in order to have a single permission prompt!
void lkRoom.localParticipant.enableCameraAndMicrophone();
} else if (audio) {
void lkRoom.localParticipant.setMicrophoneEnabled(true);
} else if (video) {
void lkRoom.localParticipant.setCameraEnabled(true);
}
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`,
);
}
}
} }
/** /**
* Start the connection to LiveKit and publish local tracks.
* *
* This will: * Request to publish local tracks to the LiveKit room.
* wait for the connection to be ready. * This will wait for the connection to be ready before publishing.
// * 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.) * Livekit also have some local retry logic for publishing tracks.
// * 2. Use this token to request the SFU config to the MatrixRtc authentication service. * Can be called multiple times, localparticipant manages the state of published tracks (or pending publications).
// * 3. Connect to the configured LiveKit room.
// * 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. * @returns
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
public async createAndSetupTracks(): Promise<LocalTrack[]> { public async startPublishing(): Promise<void> {
const lkRoom = this.connection.livekitRoom; if (this.shouldPublish) {
// Observe mute state changes and update LiveKit microphone/camera states accordingly this.logger.debug(`Already publishing, ignoring startPublishing call`);
this.observeMuteStates(this.scope); return;
// TODO: This should be an autostarted connection no need to start here. just check the connection state.
// TODO: This will fetch the JWT token. Perhaps we could keep it preloaded
// instead? This optimization would only be safe for a publish connection,
// because we don't want to leak the user's intent to perhaps join a call to
// remote servers before they actually commit to it.
// const { promise, resolve, reject } = Promise.withResolvers<void>();
// const sub = this.connection.state$.subscribe((s) => {
// if (s.state === "FailedToStart") {
// reject(new Error("Disconnected from LiveKit server"));
// } else if (s.state === "ConnectedToLkRoom") {
// resolve();
// }
// });
// try {
// await promise;
// } catch (e) {
// throw e;
// } finally {
// sub.unsubscribe();
// }
// TODO-MULTI-SFU: Prepublish a microphone track
const audio = this.muteStates.audio.enabled$.value;
const video = this.muteStates.video.enabled$.value;
// createTracks throws if called with audio=false and video=false
if (audio || video) {
// TODO this can still throw errors? It will also prompt for permissions if not already granted
this.tracks =
(await lkRoom.localParticipant
.createTracks({
audio,
video,
})
.catch((error) => {
this.logger?.error("Failed to create tracks", error);
})) ?? [];
} }
return this.tracks; this.shouldPublish = true;
} this.logger.debug("startPublishing called");
public async startPublishing(): Promise<LocalTrack[]> {
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(new Error("Failed to connect to LiveKit server"));
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) {
// 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);
});
// TODO: check if the connection is still active? and break the loop if not?
}
return this.tracks;
} }
public async stopPublishing(): Promise<void> { public async stopPublishing(): Promise<void> {
// TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope this.logger.debug("stopPublishing called");
// actually has the right lifetime this.shouldPublish = false;
this.muteStates.audio.unsetHandler(); // Pause upstream will stop sending media to the server, while keeping
this.muteStates.video.unsetHandler(); // the local MediaStreamTrack active, so the user can still see themselves.
await this.pauseUpstreams(this.connection.livekitRoom, [
Track.Source.Microphone,
Track.Source.Camera,
Track.Source.ScreenShare,
]);
}
const localParticipant = this.connection.livekitRoom.localParticipant; public async stopTracks(): Promise<void> {
const tracks: LocalTrack[] = []; const lkRoom = this.connection.livekitRoom;
const addToTracksIfDefined = (p: LocalTrackPublication): void => { for (const source of [
if (p.track !== undefined) tracks.push(p.track); Track.Source.Microphone,
}; Track.Source.Camera,
localParticipant.trackPublications.forEach(addToTracksIfDefined); Track.Source.ScreenShare,
await localParticipant.unpublishTracks(tracks); ]) {
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
@@ -221,6 +296,9 @@ export class Publisher {
// the process of being restarted. // the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" activeMicTrack.mediaStreamTrack.readyState !== "ended"
) { ) {
this.logger?.info(
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
);
// Restart the track, which will cause Livekit to do another // Restart the track, which will cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device. // getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because // Note that room.switchActiveDevice() won't work: Livekit will ignore it because
@@ -229,7 +307,7 @@ export class Publisher {
.getTrackPublication(Track.Source.Microphone) .getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack() ?.audioTrack?.restartTrack()
.catch((e) => { .catch((e) => {
this.logger?.error(`Failed to restart audio device track`, e); this.logger.error(`Failed to restart audio device track`, e);
}); });
} }
}); });
@@ -249,7 +327,7 @@ export class Publisher {
selected$.pipe(scope.bind()).subscribe((device) => { selected$.pipe(scope.bind()).subscribe((device) => {
if (lkRoom.state != LivekitConnectionState.Connected) return; if (lkRoom.state != LivekitConnectionState.Connected) return;
// if (this.connectionState$.value !== ConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return;
this.logger?.info( this.logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :", "[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
lkRoom.getActiveDevice(kind), lkRoom.getActiveDevice(kind),
" !== ", " !== ",
@@ -262,7 +340,7 @@ export class Publisher {
lkRoom lkRoom
.switchActiveDevice(kind, device.id) .switchActiveDevice(kind, device.id)
.catch((e: Error) => .catch((e: Error) =>
this.logger?.error( this.logger.error(
`Failed to sync ${kind} device with LiveKit`, `Failed to sync ${kind} device with LiveKit`,
e, e,
), ),
@@ -278,30 +356,37 @@ 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(
} catch (e) { `handler: Setting LiveKit microphone enabled: ${enable}`,
this.logger?.error(
"Failed to update LiveKit audio input mute state",
e,
); );
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) {
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( this.logger.error("Failed to update LiveKit video input mute state", e);
"Failed to update LiveKit video input mute state",
e,
);
} }
return lkRoom.localParticipant.isCameraEnabled; return lkRoom.localParticipant.isCameraEnabled;
}); });
@@ -315,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(capturedState!.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(capturedState?.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,18 +312,21 @@ 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(capturedState.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias, livekitFocus.livekit_alias,
); );
} else { } else {
@@ -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,14 +12,11 @@ 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, type Observable } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -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,19 +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"; transport: LivekitTransport } Initialized = "Initialized",
| { state: "ConnectingToLkRoom"; transport: LivekitTransport } /** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */
| { state: "PublishingTracks"; transport: LivekitTransport } FetchingConfig = "FetchingConfig",
| { state: "FailedToStart"; error: Error; transport: LivekitTransport } Stopped = "Stopped",
| { /** The same as ConnectionState.Disconnected from `livekit-client` */
state: "ConnectedToLkRoom"; LivekitDisconnected = "disconnected",
livekitConnectionState$: Observable<LivekitConenctionState>; /** The same as ConnectionState.Connecting from `livekit-client` */
transport: LivekitTransport; LivekitConnecting = "connecting",
} /** The same as ConnectionState.Connected from `livekit-client` */
| { state: "Stopped"; transport: LivekitTransport }; 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.
@@ -68,14 +76,32 @@ 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.
*/
public readonly transport: LivekitTransport;
public readonly livekitRoom: LivekitRoom;
private scope: ObservableScope;
/**
* The remote LiveKit participants that are visible 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 remoteParticipants$: Behavior<RemoteParticipant[]>;
/** /**
* Whether the connection has been stopped. * Whether the connection has been stopped.
@@ -96,24 +122,29 @@ export class Connection {
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection. * @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. * @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
// TODO dont make this throw and instead store a connection error state in this class?
// TODO consider an autostart pattern... // TODO consider an autostart pattern...
public async start(): Promise<void> { public async start(): Promise<void> {
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.
transport: this.transport, // 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)
transport: this.transport, .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) {
@@ -128,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)
@@ -140,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",
transport: this.transport,
livekitConnectionState$: 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
transport: this.transport, : 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;
} }
} }
@@ -177,30 +206,11 @@ 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",
transport: this.transport,
});
this.stopped = true; this.stopped = true;
} }
/**
* An observable of the participants that are publishing on this connection. (Excluding our local participant)
* 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.
*/
public readonly remoteParticipantsWithTracks$: Behavior<
PublishingParticipant[]
>;
/**
* The media transport to connect to.
*/
public readonly transport: LivekitTransport;
private readonly client: OpenIDClientParts; private readonly client: OpenIDClientParts;
public readonly livekitRoom: LivekitRoom;
private readonly logger: Logger; private readonly logger: Logger;
/** /**
@@ -208,36 +218,23 @@ 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;
this.scope = scope;
this.livekitRoom = opts.livekitRoomFactory(); this.livekitRoom = opts.livekitRoomFactory();
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,59 +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 {
transports$: Behavior<Epoch<LivekitTransport[]>>;
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
@@ -116,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
/** /**
@@ -130,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(
@@ -183,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(
@@ -216,7 +193,7 @@ export function createConnectionManager$({
new Epoch(new ConnectionManagerData()), new Epoch(new ConnectionManagerData()),
); );
return { transports$, connectionManagerData$ }; return { connectionManagerData$ };
} }
function removeDuplicateTransports( function removeDuplicateTransports(

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

@@ -5,9 +5,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 { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, combineLatest, Subject } from "rxjs"; import { BehaviorSubject, combineLatest, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { sleep } from "matrix-js-sdk/lib/utils";
import { import {
Epoch, Epoch,
@@ -102,3 +103,137 @@ describe("Epoch", () => {
s$.complete(); s$.complete();
}); });
}); });
describe("Reconcile", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should wait clean up before processing next", async () => {
vi.useFakeTimers();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi.fn().mockImplementation(async () => await sleep(100));
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(100));
scope.reconcile(behavior$, async (value) => {
await setup();
return async (): Promise<void> => {
await cleanup(value);
};
});
// Let the initial setup process
await vi.advanceTimersByTimeAsync(120);
expect(setup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(0);
// Send next value
behavior$.next(1);
await vi.advanceTimersByTimeAsync(50);
// Should not have started setup for 1 yet
expect(setup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
// Let cleanup finish
await vi.advanceTimersByTimeAsync(50);
// Now setup for 1 should have started
expect(setup).toHaveBeenCalledTimes(2);
});
it("should skip intermediates values that are not setup", async () => {
vi.useFakeTimers();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(100));
const cleanupLock = Promise.withResolvers();
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await cleanupLock.promise);
scope.reconcile(behavior$, async (value) => {
await setup(value);
return async (): Promise<void> => {
await cleanup(value);
};
});
// Let the initial setup process (0)
await vi.advanceTimersByTimeAsync(120);
// Send 4 next values quickly
behavior$.next(1);
behavior$.next(2);
behavior$.next(3);
behavior$.next(4);
await vi.advanceTimersByTimeAsync(3000);
// should have only called cleanup for 0
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
// Let cleanup finish
cleanupLock.resolve(undefined);
await vi.advanceTimersByTimeAsync(120);
// Now setup for 4 should have started, skipping 1,2,3
expect(setup).toHaveBeenCalledTimes(2);
expect(setup).toHaveBeenCalledWith(4);
expect(setup).not.toHaveBeenCalledWith(1);
expect(setup).not.toHaveBeenCalledWith(2);
expect(setup).not.toHaveBeenCalledWith(3);
});
it("should wait for setup to complete before starting cleanup", async () => {
vi.useFakeTimers();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(3000));
const cleanupLock = Promise.withResolvers();
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await cleanupLock.promise);
scope.reconcile(behavior$, async (value) => {
await setup(value);
return async (): Promise<void> => {
await cleanup(value);
};
});
await vi.advanceTimersByTimeAsync(500);
// Setup for 0 should be in progress
expect(setup).toHaveBeenCalledTimes(1);
behavior$.next(1);
await vi.advanceTimersByTimeAsync(500);
// Should not have started setup for 1 yet
expect(setup).not.toHaveBeenCalledWith(1);
// Should not have called cleanup yet, because the setup for 0 is not done
expect(cleanup).toHaveBeenCalledTimes(0);
// Let setup for 0 finish
await vi.advanceTimersByTimeAsync(2500 + 100);
// Now cleanup for 0 should have started
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
cleanupLock.resolve(undefined);
await vi.advanceTimersByTimeAsync(100);
// Now setup for 1 should have started
expect(setup).toHaveBeenCalledWith(1);
});
});

View File

@@ -123,8 +123,22 @@ export class ObservableScope {
callback: (value: T) => Promise<(() => Promise<void>) | void>, callback: (value: T) => Promise<(() => Promise<void>) | void>,
): void { ): void {
let latestValue: T | typeof nothing = nothing; let latestValue: T | typeof nothing = nothing;
let reconciledValue: T | typeof nothing = nothing; let reconcilePromise: Promise<void> | undefined = undefined;
let cleanUp: (() => Promise<void>) | void = undefined; let cleanUp: (() => Promise<void>) | void = undefined;
let prevVal: T | typeof nothing = nothing;
// While this loop runs it will process the latest from `value$` until it caught up with the updates.
// It might skip updates from `value$` and only process the newest value after callback has resolved.
const reconcileLoop = async (): Promise<void> => {
while (latestValue !== prevVal) {
await cleanUp?.(); // Call the previous value's clean-up handler
prevVal = latestValue;
if (latestValue !== nothing) cleanUp = await callback(latestValue); // Sync current value...
// `latestValue` might have gotten updated during the `await callback`. That is why we loop here
}
};
value$ value$
.pipe( .pipe(
catchError(() => EMPTY), // Ignore errors catchError(() => EMPTY), // Ignore errors
@@ -132,23 +146,15 @@ export class ObservableScope {
endWith(nothing), // Clean up when the scope ends endWith(nothing), // Clean up when the scope ends
) )
.subscribe((value) => { .subscribe((value) => {
void (async (): Promise<void> => { // Always track the latest value! The `reconcileLoop` will run until it "processed" the "last" `latestValue`.
if (latestValue === nothing) { latestValue = value;
latestValue = value; // There's already an instance of the below 'reconcileLoop' loop running
while (latestValue !== reconciledValue) { // concurrently. So lets let the loop handle it. NEVER instanciate two `reconcileLoop`s.
await cleanUp?.(); // Call the previous value's clean-up handler if (reconcilePromise) return;
reconciledValue = latestValue;
if (latestValue !== nothing) reconcilePromise = reconcileLoop().finally(() => {
cleanUp = await callback(latestValue); // Sync current value reconcilePromise = undefined;
} });
// Reset to signal that reconciliation is done for now
latestValue = nothing;
} else {
// There's already an instance of the above 'while' loop running
// concurrently. Just update the latest value and let it be handled.
latestValue = value;
}
})();
}); });
} }

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

@@ -13,6 +13,8 @@ export enum ErrorCode {
*/ */
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT", MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
/** LiveKit indicates that the server has hit its track limits */ /** LiveKit indicates that the server has hit its track limits */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
@@ -27,6 +29,7 @@ export enum ErrorCategory {
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY", NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION", CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
UNKNOWN = "UNKNOWN", UNKNOWN = "UNKNOWN",
SYSTEM_FAILURE = "SYSTEM_FAILURE",
// SYSTEM_FAILURE / FEDERATION_FAILURE .. // SYSTEM_FAILURE / FEDERATION_FAILURE ..
} }
@@ -54,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"),
@@ -72,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(
@@ -83,6 +96,30 @@ 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 {
/**
* Creates an instance of MembershipManagerError.
*
* @param error - The underlying error that caused the membership manager failure.
*/
public constructor(error: Error) {
super(
t("error.membership_manager"),
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
ErrorCategory.SYSTEM_FAILURE,
t("error.membership_manager_description"),
error,
);
}
}
/**
* 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(
@@ -94,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"),
@@ -107,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"),
@@ -120,6 +171,27 @@ export class FailToGetOpenIdToken extends ElementCallError {
} }
} }
/**
* Error indicating a failure to start publishing on a LiveKit connection.
*/
export class FailToStartLivekitConnection extends ElementCallError {
/**
* Creates an instance of FailToStartLivekitConnection.
* @param e - An optional error message providing additional context.
*/
public constructor(e?: string) {
super(
t("error.failed_to_start_livekit"),
ErrorCode.FAILED_TO_START_LIVEKIT,
ErrorCategory.NETWORK_CONNECTIVITY,
e,
);
}
}
/**
* 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(
@@ -131,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,
@@ -284,6 +284,8 @@ export function mockLivekitRoom(
): LivekitRoom { ): LivekitRoom {
const livekitRoom = { const livekitRoom = {
options: {}, options: {},
setE2EEEnabled: vi.fn(),
...mockEmitter(), ...mockEmitter(),
...room, ...room,
} as Partial<LivekitRoom> as LivekitRoom; } as Partial<LivekitRoom> as LivekitRoom;
@@ -306,7 +308,11 @@ export function mockLocalParticipant(
return { return {
isLocal: true, isLocal: true,
trackPublications: new Map(), trackPublications: new Map(),
unpublishTracks: async () => Promise.resolve(), publishTrack: vi.fn(),
unpublishTracks: vi.fn().mockResolvedValue([]),
createTracks: vi.fn(),
setMicrophoneEnabled: vi.fn(),
setCameraEnabled: vi.fn(),
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication, ({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
...mockEmitter(), ...mockEmitter(),
@@ -315,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",
@@ -355,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"),
@@ -394,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

1185
yarn.lock

File diff suppressed because it is too large Load Diff