mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-09 18:29:21 +00:00
Merge branch 'livekit' into fkwp/elementregistry
This commit is contained in:
10
.github/workflows/build-and-publish-docker.yaml
vendored
10
.github/workflows/build-and-publish-docker.yaml
vendored
@@ -33,8 +33,8 @@ jobs:
|
|||||||
name: build-output-full
|
name: build-output-full
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Login to GitHub container registry
|
- name: Log in to container registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -88,10 +88,10 @@ jobs:
|
|||||||
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
2
.github/workflows/build-element-call.yaml
vendored
2
.github/workflows/build-element-call.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
package:
|
package:
|
||||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk'
|
||||||
required: true
|
required: true
|
||||||
build_mode:
|
build_mode:
|
||||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||||
|
|||||||
14
.github/workflows/build.yaml
vendored
14
.github/workflows/build.yaml
vendored
@@ -69,3 +69,17 @@ jobs:
|
|||||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
build_sdk_element_call:
|
||||||
|
# Use the embedded package vite build
|
||||||
|
uses: ./.github/workflows/build-element-call.yaml
|
||||||
|
with:
|
||||||
|
package: sdk
|
||||||
|
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||||
|
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||||
|
secrets:
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
16
.github/workflows/deploy-to-netlify.yaml
vendored
16
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -14,6 +14,10 @@ on:
|
|||||||
deployment_ref:
|
deployment_ref:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
package:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
||||||
artifact_run_id:
|
artifact_run_id:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
@@ -50,7 +54,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
run-id: ${{ inputs.artifact_run_id }}
|
run-id: ${{ inputs.artifact_run_id }}
|
||||||
name: build-output-full
|
name: build-output-${{ inputs.package }}
|
||||||
path: webapp
|
path: webapp
|
||||||
|
|
||||||
- name: Add redirects file
|
- name: Add redirects file
|
||||||
@@ -58,15 +62,19 @@ jobs:
|
|||||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||||
|
|
||||||
- name: Add config file
|
- name: Add config file
|
||||||
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
run: |
|
||||||
|
if [ "${{ inputs.package }}" = "full" ]; then
|
||||||
|
curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||||
|
else
|
||||||
|
curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json
|
||||||
|
fi
|
||||||
- name: ☁️ Deploy to Netlify
|
- name: ☁️ Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||||
with:
|
with:
|
||||||
publish-dir: webapp
|
publish-dir: webapp
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
alias: pr${{ inputs.pr_number }}
|
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||||
env:
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|||||||
20
.github/workflows/pr-deploy.yaml
vendored
20
.github/workflows/pr-deploy.yaml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||||
branch: ${{ github.event.workflow_run.head_branch }}
|
branch: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
|
||||||
netlify:
|
netlify-full:
|
||||||
needs: prdetails
|
needs: prdetails
|
||||||
permissions:
|
permissions:
|
||||||
deployments: write
|
deployments: write
|
||||||
@@ -31,6 +31,24 @@ jobs:
|
|||||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||||
|
package: full
|
||||||
|
secrets:
|
||||||
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
netlify-sdk:
|
||||||
|
needs: prdetails
|
||||||
|
permissions:
|
||||||
|
deployments: write
|
||||||
|
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||||
|
with:
|
||||||
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
|
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||||
|
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||||
|
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||||
|
package: sdk
|
||||||
secrets:
|
secrets:
|
||||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ jobs:
|
|||||||
path: embedded/android/lib/src/main/assets/element-call
|
path: embedded/android/lib/src/main/assets/element-call
|
||||||
|
|
||||||
- name: ☕️ Setup Java
|
- name: ☕️ Setup Java
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
|
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|||||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Vitest
|
- name: Vitest
|
||||||
run: "yarn run test:coverage"
|
run: "yarn run test:coverage"
|
||||||
- name: Upload to codecov
|
- name: Upload to codecov
|
||||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/translations-download.yaml
vendored
2
.github/workflows/translations-download.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
id: cpr
|
id: cpr
|
||||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
branch: actions/localazy-download
|
branch: actions/localazy-download
|
||||||
|
|||||||
16
config/config_netlify_preview_sdk.json
Normal file
16
config/config_netlify_preview_sdk.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"default_server_config": {
|
||||||
|
"m.homeserver": {
|
||||||
|
"base_url": "https://call-unstable.ems.host",
|
||||||
|
"server_name": "call-unstable.ems.host"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||||
|
"matrix_rtc_session": {
|
||||||
|
"wait_for_key_rotation_ms": 3000,
|
||||||
|
"membership_event_expiry_ms": 180000000,
|
||||||
|
"delayed_leave_event_delay_ms": 18000,
|
||||||
|
"delayed_leave_event_restart_ms": 4000,
|
||||||
|
"network_error_retry_ms": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@
|
|||||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||||
"build:embedded:production": "yarn build:embedded",
|
"build:embedded:production": "yarn build:embedded",
|
||||||
"build:embedded:development": "yarn build:embedded --mode development",
|
"build:embedded:development": "yarn build:embedded --mode development",
|
||||||
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
|
||||||
"build:sdk:development": "yarn build:sdk --mode development",
|
"build:sdk:development": "yarn build:sdk --mode development",
|
||||||
|
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||||
|
"build:sdk:production": "yarn build:sdk",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"prettier:check": "prettier -c .",
|
"prettier:check": "prettier -c .",
|
||||||
"prettier:format": "prettier -w .",
|
"prettier:format": "prettier -w .",
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
"livekit-client": "^2.13.0",
|
"livekit-client": "^2.13.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"loglevel": "^1.9.1",
|
"loglevel": "^1.9.1",
|
||||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
|
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||||
"matrix-widget-api": "^1.16.1",
|
"matrix-widget-api": "^1.16.1",
|
||||||
"node-stdlib-browser": "^1.3.1",
|
"node-stdlib-browser": "^1.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# SDK mode
|
# SDK mode (EXPERIMENTAL)
|
||||||
|
|
||||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { scan } from "rxjs";
|
import { scan } from "rxjs";
|
||||||
|
|
||||||
import { widget as _widget } from "../src/widget";
|
import { type WidgetHelpers } from "../src/widget";
|
||||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||||
|
|
||||||
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||||
|
|
||||||
if (!_widget) throw Error("No widget. This webapp can only start as a widget");
|
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||||
export const widget = _widget;
|
|
||||||
|
|
||||||
export const tryMakeSticky = (): void => {
|
|
||||||
logger.info("try making sticky MatrixRTCSdk");
|
logger.info("try making sticky MatrixRTCSdk");
|
||||||
void widget.api
|
void widget.api
|
||||||
.setAlwaysOnScreen(true)
|
.setAlwaysOnScreen(true)
|
||||||
|
|||||||
108
sdk/main.ts
108
sdk/main.ts
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* EXPERIMENTAL
|
||||||
|
*
|
||||||
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||||
* use in widgets.
|
* use in widgets.
|
||||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||||
@@ -30,8 +32,8 @@ import {
|
|||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
MatrixRTCSession,
|
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
|
MatrixRTCSessionManager,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
@@ -50,14 +52,12 @@ import { getUrlParams } from "../src/UrlParams";
|
|||||||
import { MuteStates } from "../src/state/MuteStates";
|
import { MuteStates } from "../src/state/MuteStates";
|
||||||
import { MediaDevices } from "../src/state/MediaDevices";
|
import { MediaDevices } from "../src/state/MediaDevices";
|
||||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||||
|
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||||
import {
|
import {
|
||||||
currentAndPrev,
|
ElementWidgetActions,
|
||||||
logger,
|
widget as _widget,
|
||||||
TEXT_LK_TOPIC,
|
initializeWidget,
|
||||||
tryMakeSticky,
|
} from "../src/widget";
|
||||||
widget,
|
|
||||||
} from "./helper";
|
|
||||||
import { ElementWidgetActions, initializeWidget } from "../src/widget";
|
|
||||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||||
|
|
||||||
interface MatrixRTCSdk {
|
interface MatrixRTCSdk {
|
||||||
@@ -68,7 +68,13 @@ interface MatrixRTCSdk {
|
|||||||
join: () => void;
|
join: () => void;
|
||||||
/** @throws on leave errors */
|
/** @throws on leave errors */
|
||||||
leave: () => void;
|
leave: () => void;
|
||||||
data$: Observable<{ sender: string; data: string }>;
|
/**
|
||||||
|
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
||||||
|
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
||||||
|
* A new sdk needs to be created via createMatrixRTCSdk.
|
||||||
|
*/
|
||||||
|
stop: () => void;
|
||||||
|
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||||
/**
|
/**
|
||||||
* flattened list of members
|
* flattened list of members
|
||||||
*/
|
*/
|
||||||
@@ -79,32 +85,54 @@ interface MatrixRTCSdk {
|
|||||||
participant: LocalParticipant | RemoteParticipant | null;
|
participant: LocalParticipant | RemoteParticipant | null;
|
||||||
}[]
|
}[]
|
||||||
>;
|
>;
|
||||||
|
/**
|
||||||
|
* flattened local members
|
||||||
|
*/
|
||||||
|
localMember$: Behavior<{
|
||||||
|
connection: Connection | null;
|
||||||
|
membership: CallMembership;
|
||||||
|
participant: LocalParticipant | null;
|
||||||
|
} | null>;
|
||||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||||
connected$: Behavior<boolean>;
|
connected$: Behavior<boolean>;
|
||||||
sendData?: (data: unknown) => Promise<void>;
|
sendData?: (data: unknown) => Promise<void>;
|
||||||
|
sendRoomMessage?: (message: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMatrixRTCSdk(
|
export async function createMatrixRTCSdk(
|
||||||
application: string = "m.call",
|
application: string = "m.call",
|
||||||
id: string = "",
|
id: string = "",
|
||||||
|
sticky: boolean = false,
|
||||||
): Promise<MatrixRTCSdk> {
|
): Promise<MatrixRTCSdk> {
|
||||||
initializeWidget();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
|
// widget client
|
||||||
|
initializeWidget(application, true);
|
||||||
|
const widget = _widget;
|
||||||
|
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||||
const client = await widget.client;
|
const client = await widget.client;
|
||||||
logger.info("client created");
|
logger.info("client created");
|
||||||
const scope = new ObservableScope();
|
|
||||||
|
// url params
|
||||||
const { roomId } = getUrlParams();
|
const { roomId } = getUrlParams();
|
||||||
if (roomId === null) throw Error("could not get roomId from url params");
|
if (roomId === null) throw Error("could not get roomId from url params");
|
||||||
|
|
||||||
const room = client.getRoom(roomId);
|
const room = client.getRoom(roomId);
|
||||||
if (room === null) throw Error("could not get room from client");
|
if (room === null) throw Error("could not get room from client");
|
||||||
|
|
||||||
|
// rtc session
|
||||||
|
const slot = { application, id };
|
||||||
|
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
|
||||||
|
rtcSessionManager.start();
|
||||||
|
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||||
|
|
||||||
|
// media devices
|
||||||
const mediaDevices = new MediaDevices(scope);
|
const mediaDevices = new MediaDevices(scope);
|
||||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||||
audioEnabled: true,
|
audioEnabled: false,
|
||||||
videoEnabled: true,
|
videoEnabled: false,
|
||||||
});
|
});
|
||||||
const slot = { application, id };
|
|
||||||
const rtcSession = new MatrixRTCSession(client, room, slot);
|
// call view model
|
||||||
const callViewModel = createCallViewModel$(
|
const callViewModel = createCallViewModel$(
|
||||||
scope,
|
scope,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
@@ -117,8 +145,9 @@ export async function createMatrixRTCSdk(
|
|||||||
constant({ supported: false, processor: undefined }),
|
constant({ supported: false, processor: undefined }),
|
||||||
);
|
);
|
||||||
logger.info("CallViewModelCreated");
|
logger.info("CallViewModelCreated");
|
||||||
|
|
||||||
// create data listener
|
// create data listener
|
||||||
const data$ = new Subject<{ sender: string; data: string }>();
|
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||||
|
|
||||||
const lkTextStreamHandlerFunction = async (
|
const lkTextStreamHandlerFunction = async (
|
||||||
reader: TextStreamReader,
|
reader: TextStreamReader,
|
||||||
@@ -140,7 +169,7 @@ export async function createMatrixRTCSdk(
|
|||||||
if (participants && participants.includes(participantInfo.identity)) {
|
if (participants && participants.includes(participantInfo.identity)) {
|
||||||
const text = await reader.readAll();
|
const text = await reader.readAll();
|
||||||
logger.info(`Received text: ${text}`);
|
logger.info(`Received text: ${text}`);
|
||||||
data$.next({ sender: participantInfo.identity, data: text });
|
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Received text from unknown participant",
|
"Received text from unknown participant",
|
||||||
@@ -230,6 +259,16 @@ export async function createMatrixRTCSdk(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendRoomMessage = async (message: string): Promise<void> => {
|
||||||
|
const messageString = JSON.stringify(message);
|
||||||
|
logger.info("try sending to room: ", messageString);
|
||||||
|
try {
|
||||||
|
await client.sendTextMessage(room.roomId, message);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("failed sending to room: ", messageString, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// after hangup gets called
|
// after hangup gets called
|
||||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||||
@@ -257,9 +296,6 @@ export async function createMatrixRTCSdk(
|
|||||||
|
|
||||||
// schedule close first and then leave (scope.end)
|
// schedule close first and then leave (scope.end)
|
||||||
void scheduleWidgetCloseOnLeave();
|
void scheduleWidgetCloseOnLeave();
|
||||||
|
|
||||||
// actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast)
|
|
||||||
scope.end();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("createMatrixRTCSdk done");
|
logger.info("createMatrixRTCSdk done");
|
||||||
@@ -267,15 +303,40 @@ export async function createMatrixRTCSdk(
|
|||||||
return {
|
return {
|
||||||
join: (): void => {
|
join: (): void => {
|
||||||
// first lets try making the widget sticky
|
// first lets try making the widget sticky
|
||||||
tryMakeSticky();
|
if (sticky) tryMakeSticky(widget);
|
||||||
callViewModel.join();
|
callViewModel.join();
|
||||||
},
|
},
|
||||||
leave: (): void => {
|
leave: (): void => {
|
||||||
callViewModel.hangup();
|
callViewModel.leave();
|
||||||
|
},
|
||||||
|
stop: (): void => {
|
||||||
leaveSubs.unsubscribe();
|
leaveSubs.unsubscribe();
|
||||||
livekitRoomItemsSub.unsubscribe();
|
livekitRoomItemsSub.unsubscribe();
|
||||||
|
scope.end();
|
||||||
},
|
},
|
||||||
data$,
|
data$,
|
||||||
|
localMember$: scope.behavior(
|
||||||
|
callViewModel.localMatrixLivekitMember$.pipe(
|
||||||
|
tap((member) =>
|
||||||
|
logger.info("localMatrixLivekitMember$ next: ", member),
|
||||||
|
),
|
||||||
|
switchMap((member) => {
|
||||||
|
if (member === null) return of(null);
|
||||||
|
return combineLatest([
|
||||||
|
member.connection$,
|
||||||
|
member.membership$,
|
||||||
|
member.participant.value$,
|
||||||
|
]).pipe(
|
||||||
|
map(([connection, membership, participant]) => ({
|
||||||
|
connection,
|
||||||
|
membership,
|
||||||
|
participant,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
tap((member) => logger.info("localMember$ next: ", member)),
|
||||||
|
),
|
||||||
|
),
|
||||||
connected$: callViewModel.connected$,
|
connected$: callViewModel.connected$,
|
||||||
members$: scope.behavior(
|
members$: scope.behavior(
|
||||||
callViewModel.matrixLivekitMembers$.pipe(
|
callViewModel.matrixLivekitMembers$.pipe(
|
||||||
@@ -302,5 +363,6 @@ export async function createMatrixRTCSdk(
|
|||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
sendData,
|
sendData,
|
||||||
|
sendRoomMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/settings/rageshake.test.ts
Normal file
34
src/settings/rageshake.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { init as initRageshake } from "./rageshake";
|
||||||
|
|
||||||
|
it("Logger should not crash if JSON.stringify fails", async () => {
|
||||||
|
// JSON.stringify can throw. We want to make sure that the logger can handle this gracefully.
|
||||||
|
await initRageshake();
|
||||||
|
|
||||||
|
const bigIntObj = { n: 1n };
|
||||||
|
const notStringifiable = {
|
||||||
|
bigIntObj,
|
||||||
|
};
|
||||||
|
// @ts-expect-error - we want to create an object that cannot be stringified
|
||||||
|
notStringifiable.foo = notStringifiable; // circular reference
|
||||||
|
|
||||||
|
// ensure this cannot be stringified
|
||||||
|
expect(() => JSON.stringify(notStringifiable)).toThrow();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
global.mx_rage_logger.log(
|
||||||
|
1,
|
||||||
|
"test",
|
||||||
|
"This is a test message",
|
||||||
|
notStringifiable,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
@@ -75,7 +75,14 @@ class ConsoleLogger extends EventEmitter {
|
|||||||
} else if (arg instanceof Error) {
|
} else if (arg instanceof Error) {
|
||||||
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
||||||
} else if (typeof arg === "object") {
|
} else if (typeof arg === "object") {
|
||||||
return JSON.stringify(arg, getCircularReplacer());
|
try {
|
||||||
|
return JSON.stringify(arg, getCircularReplacer());
|
||||||
|
} catch {
|
||||||
|
// Stringify can fail if the object has circular references or if
|
||||||
|
// there is a bigInt.
|
||||||
|
// Did happen even with our `getCircularReplacer`. In this case, just log
|
||||||
|
return "<$ failed to serialize object $>";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return arg;
|
return arg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
accumulate,
|
accumulate,
|
||||||
filterBehavior,
|
filterBehavior,
|
||||||
|
generateItem,
|
||||||
generateItems,
|
generateItems,
|
||||||
pauseWhen,
|
pauseWhen,
|
||||||
} from "../../utils/observable";
|
} from "../../utils/observable";
|
||||||
@@ -217,15 +218,23 @@ export interface CallViewModel {
|
|||||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
||||||
>;
|
>;
|
||||||
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope.
|
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||||
|
* - by ending the scope
|
||||||
|
* - or calling requestDisconnect
|
||||||
|
*
|
||||||
|
* TODO: it seems more reasonable to add a leave() method (that calls requestDisconnect) that will then update leave$ and remove the hangup pattern
|
||||||
*/
|
*/
|
||||||
leave$: Observable<"user" | AutoLeaveReason>;
|
leave$: Observable<"user" | AutoLeaveReason>;
|
||||||
/** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */
|
/** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
|
||||||
hangup: () => void;
|
hangup: () => void;
|
||||||
|
|
||||||
// joining
|
// joining
|
||||||
join: () => void;
|
join: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* calls requestDisconnect. The async leave state can than be observed via connected$
|
||||||
|
*/
|
||||||
|
leave: () => void;
|
||||||
// screen sharing
|
// screen sharing
|
||||||
/**
|
/**
|
||||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||||
@@ -436,35 +445,38 @@ export function createCallViewModel$(
|
|||||||
memberId: uuidv4(),
|
memberId: uuidv4(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
const localTransport$ = scope.behavior(
|
||||||
scope: scope,
|
matrixRTCMode$.pipe(
|
||||||
memberships$: memberships$,
|
generateItem(
|
||||||
ownMembershipIdentity,
|
"CallViewModel localTransport$",
|
||||||
client,
|
// Re-create LocalTransport whenever the mode changes
|
||||||
delayId$: scope.behavior(
|
(mode) => ({ keys: [mode], data: undefined }),
|
||||||
(
|
(scope, _data$, mode) =>
|
||||||
fromEvent(
|
createLocalTransport$({
|
||||||
matrixRTCSession,
|
scope: scope,
|
||||||
MembershipManagerEvent.DelayIdChanged,
|
memberships$: memberships$,
|
||||||
// The type of reemitted event includes the original emitted as the second arg.
|
ownMembershipIdentity,
|
||||||
) as Observable<[string | undefined, IMembershipManager]>
|
client,
|
||||||
).pipe(map(([delayId]) => delayId ?? null)),
|
delayId$: scope.behavior(
|
||||||
matrixRTCSession.delayId ?? null,
|
(
|
||||||
),
|
fromEvent(
|
||||||
roomId: matrixRoom.roomId,
|
matrixRTCSession,
|
||||||
forceJwtEndpoint$: scope.behavior(
|
MembershipManagerEvent.DelayIdChanged,
|
||||||
matrixRTCMode$.pipe(
|
// The type of reemitted event includes the original emitted as the second arg.
|
||||||
map((v) =>
|
) as Observable<[string | undefined, IMembershipManager]>
|
||||||
v === MatrixRTCMode.Matrix_2_0
|
).pipe(map(([delayId]) => delayId ?? null)),
|
||||||
? JwtEndpointVersion.Matrix_2_0
|
matrixRTCSession.delayId ?? null,
|
||||||
: JwtEndpointVersion.Legacy,
|
),
|
||||||
),
|
roomId: matrixRoom.roomId,
|
||||||
|
forceJwtEndpoint:
|
||||||
|
mode === MatrixRTCMode.Matrix_2_0
|
||||||
|
? JwtEndpointVersion.Matrix_2_0
|
||||||
|
: JwtEndpointVersion.Legacy,
|
||||||
|
useOldestMember: mode === MatrixRTCMode.Legacy,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
useOldestMember$: scope.behavior(
|
);
|
||||||
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const connectionFactory = new ECConnectionFactory(
|
const connectionFactory = new ECConnectionFactory(
|
||||||
client,
|
client,
|
||||||
@@ -483,6 +495,7 @@ export function createCallViewModel$(
|
|||||||
connectionFactory: connectionFactory,
|
connectionFactory: connectionFactory,
|
||||||
localTransport$: scope.behavior(
|
localTransport$: scope.behavior(
|
||||||
localTransport$.pipe(
|
localTransport$.pipe(
|
||||||
|
switchMap((t) => t.active$),
|
||||||
catchError((e: unknown) => {
|
catchError((e: unknown) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||||
@@ -516,13 +529,13 @@ export function createCallViewModel$(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
const localMembership = createLocalMembership$({
|
||||||
scope: scope,
|
scope,
|
||||||
homeserverConnected: createHomeserverConnected$(
|
homeserverConnected: createHomeserverConnected$(
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
),
|
),
|
||||||
muteStates: muteStates,
|
muteStates,
|
||||||
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
||||||
return enterRTCSession(
|
return enterRTCSession(
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
@@ -542,9 +555,11 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
connectionManager: connectionManager,
|
connectionManager,
|
||||||
matrixRTCSession: matrixRTCSession,
|
matrixRTCSession,
|
||||||
localTransport$: localTransport$,
|
localTransport$: scope.behavior(
|
||||||
|
localTransport$.pipe(switchMap((t) => t.advertised$)),
|
||||||
|
),
|
||||||
logger: logger.getChild(`[${Date.now()}]`),
|
logger: logger.getChild(`[${Date.now()}]`),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -715,6 +730,7 @@ export function createCallViewModel$(
|
|||||||
// Generate a collection of MediaItems from the list of expected (whether
|
// Generate a collection of MediaItems from the list of expected (whether
|
||||||
// present or missing) LiveKit participants.
|
// present or missing) LiveKit participants.
|
||||||
generateItems(
|
generateItems(
|
||||||
|
"CallViewModel userMedia$",
|
||||||
function* ([
|
function* ([
|
||||||
localMatrixLivekitMember,
|
localMatrixLivekitMember,
|
||||||
matrixLivekitMembers,
|
matrixLivekitMembers,
|
||||||
@@ -1496,6 +1512,7 @@ export function createCallViewModel$(
|
|||||||
leave$: leave$,
|
leave$: leave$,
|
||||||
hangup: (): void => userHangup$.next(),
|
hangup: (): void => userHangup$.next(),
|
||||||
join: localMembership.requestJoinAndPublish,
|
join: localMembership.requestJoinAndPublish,
|
||||||
|
leave: localMembership.requestDisconnect,
|
||||||
toggleScreenSharing: toggleScreenSharing,
|
toggleScreenSharing: toggleScreenSharing,
|
||||||
sharingScreen$: sharingScreen$,
|
sharingScreen$: sharingScreen$,
|
||||||
|
|
||||||
@@ -1545,7 +1562,15 @@ export function createCallViewModel$(
|
|||||||
matrixLivekitMembers$.pipe(
|
matrixLivekitMembers$.pipe(
|
||||||
map((members) => members.value),
|
map((members) => members.value),
|
||||||
tap((v) => {
|
tap((v) => {
|
||||||
logger.debug("matrixLivekitMembers$ updated (exported)", v);
|
const listForLogs = v
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
m.membership$.value.userId + "|" + m.membership$.value.deviceId,
|
||||||
|
)
|
||||||
|
.join(",");
|
||||||
|
logger.debug(
|
||||||
|
`matrixLivekitMembers$ updated (exported) [${listForLogs}]`,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { constant } from "../../Behavior";
|
|||||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||||
import { type Publisher } from "./Publisher";
|
import { type Publisher } from "./Publisher";
|
||||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
|
|
||||||
import { initializeWidget } from "../../../widget";
|
import { initializeWidget } from "../../../widget";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
@@ -216,11 +215,10 @@ describe("LocalMembership", () => {
|
|||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
it("throws error on missing RTC config error", () => {
|
||||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||||
const localTransport$ =
|
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||||
scope.behavior<null | LocalTransportWithSFUConfig>(
|
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
null,
|
||||||
null,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// we do not need any connection data since we want to fail before reaching that.
|
// we do not need any connection data since we want to fail before reaching that.
|
||||||
const mockConnectionManager = {
|
const mockConnectionManager = {
|
||||||
@@ -279,23 +277,11 @@ describe("LocalMembership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aTransport = {
|
const aTransport = {
|
||||||
transport: {
|
livekit_service_url: "a",
|
||||||
livekit_service_url: "a",
|
} as LivekitTransportConfig;
|
||||||
} as LivekitTransportConfig,
|
|
||||||
sfuConfig: {
|
|
||||||
url: "sfu-url",
|
|
||||||
jwt: "sfu-token",
|
|
||||||
},
|
|
||||||
} as LocalTransportWithSFUConfig;
|
|
||||||
const bTransport = {
|
const bTransport = {
|
||||||
transport: {
|
livekit_service_url: "b",
|
||||||
livekit_service_url: "b",
|
} as LivekitTransportConfig;
|
||||||
} as LivekitTransportConfig,
|
|
||||||
sfuConfig: {
|
|
||||||
url: "sfu-url",
|
|
||||||
jwt: "sfu-token",
|
|
||||||
},
|
|
||||||
} as LocalTransportWithSFUConfig;
|
|
||||||
|
|
||||||
const connectionTransportAConnected = {
|
const connectionTransportAConnected = {
|
||||||
livekitRoom: mockLivekitRoom({
|
livekitRoom: mockLivekitRoom({
|
||||||
@@ -305,7 +291,7 @@ describe("LocalMembership", () => {
|
|||||||
} as unknown as LocalParticipant,
|
} as unknown as LocalParticipant,
|
||||||
}),
|
}),
|
||||||
state$: constant(ConnectionState.LivekitConnected),
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
transport: aTransport.transport,
|
transport: aTransport,
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const connectionTransportAConnecting = {
|
const connectionTransportAConnecting = {
|
||||||
...connectionTransportAConnected,
|
...connectionTransportAConnected,
|
||||||
@@ -314,7 +300,7 @@ describe("LocalMembership", () => {
|
|||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const connectionTransportBConnected = {
|
const connectionTransportBConnected = {
|
||||||
state$: constant(ConnectionState.LivekitConnected),
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
transport: bTransport.transport,
|
transport: bTransport,
|
||||||
livekitRoom: mockLivekitRoom({}),
|
livekitRoom: mockLivekitRoom({}),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
|
|
||||||
@@ -368,12 +354,8 @@ describe("LocalMembership", () => {
|
|||||||
// stop the first Publisher and let the second one life.
|
// stop the first Publisher and let the second one life.
|
||||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||||
aTransport.transport,
|
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||||
);
|
|
||||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(
|
|
||||||
bTransport.transport,
|
|
||||||
);
|
|
||||||
scope.end();
|
scope.end();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// stop all tracks after ending scopes
|
// stop all tracks after ending scopes
|
||||||
@@ -446,8 +428,9 @@ describe("LocalMembership", () => {
|
|||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
const connectionManagerData = new ConnectionManagerData();
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
const localTransport$ =
|
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
|
||||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
null,
|
||||||
|
);
|
||||||
const connectionManagerData$ = new BehaviorSubject(
|
const connectionManagerData$ = new BehaviorSubject(
|
||||||
new Epoch(connectionManagerData),
|
new Epoch(connectionManagerData),
|
||||||
);
|
);
|
||||||
@@ -519,7 +502,7 @@ describe("LocalMembership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(
|
(
|
||||||
connectionManagerData2.getConnectionForTransport(aTransport.transport)!
|
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||||
.state$ as BehaviorSubject<ConnectionState>
|
.state$ as BehaviorSubject<ConnectionState>
|
||||||
).next(ConnectionState.LivekitConnected);
|
).next(ConnectionState.LivekitConnected);
|
||||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
} from "../remoteMembers/Connection.ts";
|
} from "../remoteMembers/Connection.ts";
|
||||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||||
import { and$ } from "../../../utils/observable.ts";
|
import { and$ } from "../../../utils/observable.ts";
|
||||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
|
|
||||||
|
|
||||||
export enum TransportState {
|
export enum TransportState {
|
||||||
/** Not even a transport is available to the LocalMembership */
|
/** Not even a transport is available to the LocalMembership */
|
||||||
@@ -128,7 +127,7 @@ interface Props {
|
|||||||
createPublisherFactory: (connection: Connection) => Publisher;
|
createPublisherFactory: (connection: Connection) => Publisher;
|
||||||
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
||||||
homeserverConnected: HomeserverConnected;
|
homeserverConnected: HomeserverConnected;
|
||||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
localTransport$: Behavior<LivekitTransportConfig | null>;
|
||||||
matrixRTCSession: Pick<
|
matrixRTCSession: Pick<
|
||||||
MatrixRTCSession,
|
MatrixRTCSession,
|
||||||
"updateCallIntent" | "leaveRoomSession"
|
"updateCallIntent" | "leaveRoomSession"
|
||||||
@@ -147,7 +146,7 @@ interface Props {
|
|||||||
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
||||||
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
||||||
* @param props.homeserverConnected The homeserver connected state.
|
* @param props.homeserverConnected The homeserver connected state.
|
||||||
* @param props.localTransport$ The local transport to use for publishing.
|
* @param props.localTransport$ The transport to advertise in our membership.
|
||||||
* @param props.logger The logger to use.
|
* @param props.logger The logger to use.
|
||||||
* @param props.muteStates The mute states for video and audio.
|
* @param props.muteStates The mute states for video and audio.
|
||||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||||
@@ -237,9 +236,7 @@ export const createLocalMembership$ = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectionData.getConnectionForTransport(
|
return connectionData.getConnectionForTransport(localTransport);
|
||||||
localTransport.transport,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
tap((connection) => {
|
tap((connection) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -549,7 +546,7 @@ export const createLocalMembership$ = ({
|
|||||||
if (!shouldConnect) return;
|
if (!shouldConnect) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
joinMatrixRTC(transport.transport);
|
joinMatrixRTC(transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error entering RTC session", error);
|
logger.error("Error entering RTC session", error);
|
||||||
if (error instanceof Error)
|
if (error instanceof Error)
|
||||||
|
|||||||
@@ -13,15 +13,24 @@ import {
|
|||||||
it,
|
it,
|
||||||
type MockedObject,
|
type MockedObject,
|
||||||
vi,
|
vi,
|
||||||
|
type MockInstance,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import {
|
||||||
|
type CallMembership,
|
||||||
|
type LivekitTransportConfig,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
|
import {
|
||||||
|
mockConfig,
|
||||||
|
flushPromises,
|
||||||
|
ownMemberMock,
|
||||||
|
mockRtcMembership,
|
||||||
|
} from "../../../utils/test";
|
||||||
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
||||||
import { constant } from "../../Behavior";
|
import { constant } from "../../Behavior";
|
||||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||||
import {
|
import {
|
||||||
MatrixRTCTransportMissingError,
|
MatrixRTCTransportMissingError,
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
@@ -43,10 +52,10 @@ describe("LocalTransport", () => {
|
|||||||
afterEach(() => scope.end());
|
afterEach(() => scope.end());
|
||||||
|
|
||||||
it("throws if config is missing", async () => {
|
it("throws if config is missing", async () => {
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!room:example.org",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@@ -58,14 +67,15 @@ describe("LocalTransport", () => {
|
|||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(() => localTransport$.value).toThrow(
|
expect(() => advertised$.value).toThrow(
|
||||||
new MatrixRTCTransportMissingError(""),
|
new MatrixRTCTransportMissingError(""),
|
||||||
);
|
);
|
||||||
|
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||||
@@ -83,10 +93,10 @@ describe("LocalTransport", () => {
|
|||||||
);
|
);
|
||||||
const observations: unknown[] = [];
|
const observations: unknown[] = [];
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
baseUrl: "https://lk.example.org",
|
baseUrl: "https://lk.example.org",
|
||||||
@@ -98,10 +108,10 @@ describe("LocalTransport", () => {
|
|||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
localTransport$.subscribe(
|
active$.subscribe(
|
||||||
(o) => observations.push(o),
|
(o) => observations.push(o),
|
||||||
(e) => errors.push(e),
|
(e) => errors.push(e),
|
||||||
);
|
);
|
||||||
@@ -111,7 +121,8 @@ describe("LocalTransport", () => {
|
|||||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||||
expect(observations).toStrictEqual([null]);
|
expect(observations).toStrictEqual([null]);
|
||||||
expect(errors).toStrictEqual([expectedError]);
|
expect(errors).toStrictEqual([expectedError]);
|
||||||
expect(() => localTransport$.value).toThrow(expectedError);
|
expect(() => advertised$.value).toThrow(expectedError);
|
||||||
|
expect(() => active$.value).toThrow(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits preferred transport after OpenID resolves", async () => {
|
it("emits preferred transport after OpenID resolves", async () => {
|
||||||
@@ -126,10 +137,10 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.promise,
|
openIdResolver.promise,
|
||||||
);
|
);
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!room:example.org",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@@ -140,7 +151,7 @@ describe("LocalTransport", () => {
|
|||||||
baseUrl: "https://lk.example.org",
|
baseUrl: "https://lk.example.org",
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,14 +161,17 @@ describe("LocalTransport", () => {
|
|||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||||
});
|
});
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// final
|
// final
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "jwt",
|
jwt: "jwt",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -167,51 +181,122 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates local transport when oldest member changes", async () => {
|
describe("oldest member mode", () => {
|
||||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
const aliceTransport: LivekitTransportConfig = {
|
||||||
mockConfig({
|
type: "livekit",
|
||||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
livekit_service_url: "https://alice.example.org",
|
||||||
|
};
|
||||||
|
const bobTransport: LivekitTransportConfig = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://bob.example.org",
|
||||||
|
};
|
||||||
|
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
|
||||||
|
fociPreferred: [aliceTransport],
|
||||||
});
|
});
|
||||||
const memberships$ = new BehaviorSubject(new Epoch([]));
|
const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
|
||||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
fociPreferred: [bobTransport],
|
||||||
|
|
||||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
|
||||||
openIdResolver.promise,
|
|
||||||
);
|
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
|
||||||
scope,
|
|
||||||
roomId: "!example_room_id",
|
|
||||||
useOldestMember$: constant(true),
|
|
||||||
memberships$,
|
|
||||||
client: {
|
|
||||||
getDomain: () => "",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
||||||
getOpenIdToken: vi.fn(),
|
|
||||||
getDeviceId: vi.fn(),
|
|
||||||
baseUrl: "https://lk.example.org",
|
|
||||||
},
|
|
||||||
ownMembershipIdentity: ownMemberMock,
|
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
|
||||||
delayId$: constant("delay_id_mock"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
|
||||||
expect(localTransport$.value).toBe(null);
|
beforeEach(() => {
|
||||||
await flushPromises();
|
openIdSpy = vi
|
||||||
// final
|
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||||
expect(localTransport$.value).toStrictEqual({
|
.mockResolvedValue(openIdResponse);
|
||||||
transport: {
|
});
|
||||||
livekit_service_url: "https://lk.example.org",
|
|
||||||
type: "livekit",
|
it("updates active transport when oldest member changes", async () => {
|
||||||
},
|
// Initially, Alice is the only member
|
||||||
sfuConfig: {
|
const memberships$ = new BehaviorSubject([aliceMembership]);
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
|
||||||
livekitAlias: "Akph4alDMhen",
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
livekitIdentity: "@lk_user:ABCDEF",
|
scope,
|
||||||
url: "https://lk.example.org",
|
roomId: "!example_room_id",
|
||||||
},
|
useOldestMember: true,
|
||||||
|
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||||
|
client: {
|
||||||
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
baseUrl: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
|
delayId$: constant("delay_id_mock"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
// SFU config should've been fetched
|
||||||
|
expect(openIdSpy).toHaveBeenCalled();
|
||||||
|
// Alice's transport should be active and advertised
|
||||||
|
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
|
||||||
|
// Now Bob joins the call, but Alice is still the oldest member
|
||||||
|
openIdSpy.mockClear();
|
||||||
|
memberships$.next([aliceMembership, bobMembership]);
|
||||||
|
await flushPromises();
|
||||||
|
// No new SFU config should've been fetched
|
||||||
|
expect(openIdSpy).not.toHaveBeenCalled();
|
||||||
|
// Alice's transport should still be active and advertised
|
||||||
|
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
|
||||||
|
// Now Bob takes Alice's place as the oldest member
|
||||||
|
openIdSpy.mockClear();
|
||||||
|
memberships$.next([bobMembership, aliceMembership]);
|
||||||
|
// Active transport should reset to null until we have Bob's SFU config
|
||||||
|
expect(active$.value).toStrictEqual(null);
|
||||||
|
await flushPromises();
|
||||||
|
// Bob's SFU config should've been fetched
|
||||||
|
expect(openIdSpy).toHaveBeenCalled();
|
||||||
|
// Bob's transport should be active, but Alice's should remain advertised
|
||||||
|
// (since we don't want the change in oldest member to cause a wave of new
|
||||||
|
// state events)
|
||||||
|
expect(active$.value?.transport).toStrictEqual(bobTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises preferred transport when no other member exists", async () => {
|
||||||
|
// Initially, there are no members
|
||||||
|
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
|
||||||
|
|
||||||
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
|
scope,
|
||||||
|
roomId: "!example_room_id",
|
||||||
|
useOldestMember: true,
|
||||||
|
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||||
|
client: {
|
||||||
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () =>
|
||||||
|
Promise.resolve([aliceTransport]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
baseUrl: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
|
delayId$: constant("delay_id_mock"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
// Our own preferred transport should be advertised
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
// No transport should be active however (there is still no oldest member)
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
|
||||||
|
// Now Bob joins the call and becomes the oldest member
|
||||||
|
memberships$.next([bobMembership]);
|
||||||
|
await flushPromises();
|
||||||
|
// We should still advertise our own preferred transport (to avoid
|
||||||
|
// unnecessary state changes)
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
// Bob's transport should become active
|
||||||
|
expect(active$.value?.transport).toBe(bobTransport);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,8 +314,8 @@ describe("LocalTransport", () => {
|
|||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
scope,
|
scope,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant(null),
|
delayId$: constant(null),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
@@ -256,15 +341,19 @@ describe("LocalTransport", () => {
|
|||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||||
});
|
});
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -273,13 +362,15 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via user settings", async () => {
|
it("supports getting transport via user settings", async () => {
|
||||||
customLivekitUrl.setValue("https://lk.example.org");
|
customLivekitUrl.setValue("https://lk.example.org");
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
expect(active$.value).toStrictEqual({
|
||||||
transport: {
|
transport: {
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
@@ -292,19 +383,24 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via backend", async () => {
|
it("supports getting transport via backend", async () => {
|
||||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
]);
|
]);
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -313,6 +409,7 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast if the openID request fails for backend config", async () => {
|
it("fails fast if the openID request fails for backend config", async () => {
|
||||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
@@ -320,13 +417,11 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.reject(
|
openIdResolver.reject(
|
||||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
);
|
);
|
||||||
try {
|
await expect(async () =>
|
||||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||||
throw Error("Expected test to throw");
|
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||||
} catch (ex) {
|
|
||||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via well-known", async () => {
|
it("supports getting transport via well-known", async () => {
|
||||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
@@ -334,15 +429,19 @@ describe("LocalTransport", () => {
|
|||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -352,6 +451,7 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
expect(fetchMock.done()).toEqual(true);
|
expect(fetchMock.done()).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast if the openId request fails for the well-known config", async () => {
|
it("fails fast if the openId request fails for the well-known config", async () => {
|
||||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
@@ -362,20 +462,18 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.reject(
|
openIdResolver.reject(
|
||||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
);
|
);
|
||||||
try {
|
await expect(async () =>
|
||||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||||
throw Error("Expected test to throw");
|
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||||
} catch (ex) {
|
|
||||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws if no options are available", async () => {
|
it("throws if no options are available", async () => {
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant(null),
|
delayId$: constant(null),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
@@ -390,7 +488,10 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(() => localTransport$.value).toThrow(
|
expect(() => advertised$.value).toThrow(
|
||||||
|
new MatrixRTCTransportMissingError(""),
|
||||||
|
);
|
||||||
|
expect(() => active$.value).toThrow(
|
||||||
new MatrixRTCTransportMissingError(""),
|
new MatrixRTCTransportMissingError(""),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
|
first,
|
||||||
from,
|
from,
|
||||||
map,
|
map,
|
||||||
|
merge,
|
||||||
of,
|
of,
|
||||||
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
tap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||||
@@ -58,8 +61,8 @@ interface Props {
|
|||||||
OpenIDClientParts;
|
OpenIDClientParts;
|
||||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||||
roomId: string;
|
roomId: string;
|
||||||
useOldestMember$: Behavior<boolean>;
|
useOldestMember: boolean;
|
||||||
forceJwtEndpoint$: Behavior<JwtEndpointVersion>;
|
forceJwtEndpoint: JwtEndpointVersion;
|
||||||
delayId$: Behavior<string | null>;
|
delayId$: Behavior<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,23 +96,35 @@ export interface LocalTransportWithSFUConfig {
|
|||||||
transport: LivekitTransportConfig;
|
transport: LivekitTransportConfig;
|
||||||
sfuConfig: SFUConfig;
|
sfuConfig: SFUConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocalTransportWithSFUConfig(
|
export function isLocalTransportWithSFUConfig(
|
||||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||||
): obj is LocalTransportWithSFUConfig {
|
): obj is LocalTransportWithSFUConfig {
|
||||||
return "transport" in obj && "sfuConfig" in obj;
|
return "transport" in obj && "sfuConfig" in obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocalTransport {
|
||||||
|
/**
|
||||||
|
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||||
|
* yet fetched/validated.
|
||||||
|
*/
|
||||||
|
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||||
|
/**
|
||||||
|
* The transport to connect to and publish media on. `null` when not yet known
|
||||||
|
* or available.
|
||||||
|
*/
|
||||||
|
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for managing the local transport.
|
* Connects to the JWT service and determines the transports that the local member should use.
|
||||||
* "Which transport is the local member going to use"
|
|
||||||
*
|
*
|
||||||
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
||||||
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
||||||
*
|
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||||
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
|
||||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||||
* (which is expected for non sticky event based rtc member events)
|
* (which is expected for non sticky event based rtc member events)
|
||||||
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value.
|
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
export const createLocalTransport$ = ({
|
export const createLocalTransport$ = ({
|
||||||
@@ -118,114 +133,156 @@ export const createLocalTransport$ = ({
|
|||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
client,
|
client,
|
||||||
roomId,
|
roomId,
|
||||||
useOldestMember$,
|
useOldestMember,
|
||||||
forceJwtEndpoint$,
|
forceJwtEndpoint,
|
||||||
delayId$,
|
delayId$,
|
||||||
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
|
}: Props): LocalTransport => {
|
||||||
/**
|
/**
|
||||||
* The transport over which we should be actively publishing our media.
|
* The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||||
* undefined when not joined.
|
* oldest member has no such transport.
|
||||||
*/
|
*/
|
||||||
const oldestMemberTransport$ =
|
const oldestMemberTransport$ = scope.behavior<LivekitTransportConfig | null>(
|
||||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
memberships$.pipe(
|
||||||
combineLatest([memberships$, useOldestMember$]).pipe(
|
map((memberships) => {
|
||||||
map(([memberships, useOldestMember]) => {
|
const oldestMember = memberships.value[0];
|
||||||
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member
|
if (oldestMember === undefined) {
|
||||||
const oldestMember = memberships.value[0];
|
logger.info("Oldest member: not found");
|
||||||
const transport = oldestMember?.getTransport(oldestMember);
|
return null;
|
||||||
if (!transport) return null;
|
}
|
||||||
return transport;
|
const transport = oldestMember.getTransport(oldestMember);
|
||||||
}),
|
if (transport === undefined) {
|
||||||
switchMap((transport) => {
|
logger.warn(
|
||||||
if (transport !== null && isLivekitTransportConfig(transport)) {
|
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
|
||||||
// Get the open jwt token to connect to the sfu
|
);
|
||||||
const computeLocalTransportWithSFUConfig =
|
return null;
|
||||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
}
|
||||||
return {
|
if (!isLivekitTransportConfig(transport)) {
|
||||||
transport,
|
logger.warn(
|
||||||
sfuConfig: await getSFUConfigWithOpenID(
|
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
|
||||||
client,
|
);
|
||||||
ownMembershipIdentity,
|
return null;
|
||||||
transport.livekit_service_url,
|
}
|
||||||
roomId,
|
logger.info(
|
||||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
"Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
|
||||||
logger,
|
);
|
||||||
),
|
return transport;
|
||||||
};
|
}),
|
||||||
};
|
distinctUntilChanged(areLivekitTransportsEqual),
|
||||||
return from(computeLocalTransportWithSFUConfig());
|
),
|
||||||
}
|
);
|
||||||
return of(null);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The transport that we would personally prefer to publish on (if not for the
|
* The transport that we would personally prefer to publish on (if not for the
|
||||||
* transport preferences of others, perhaps).
|
* transport preferences of others, perhaps). `null` until fetched and
|
||||||
|
* validated.
|
||||||
*
|
*
|
||||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
const preferredTransport$ = scope.behavior(
|
const preferredTransport$ =
|
||||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||||
// When using sticky events (we need to use the new endpoint).
|
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||||
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe(
|
// When using sticky events (we need to use the new endpoint).
|
||||||
switchMap(([customUrl, delayId, forceEndpoint]) => {
|
customLivekitUrl.value$.pipe(
|
||||||
logger.info(
|
switchMap((customUrl) =>
|
||||||
"Creating preferred transport based on: ",
|
startWith<LocalTransportWithSFUConfig | null>(null)(
|
||||||
"customUrl: ",
|
// Fetch the SFU config, and repeat this asynchronously for every
|
||||||
customUrl,
|
// change in delay ID.
|
||||||
"delayId: ",
|
delayId$.pipe(
|
||||||
delayId,
|
switchMap(async (delayId) => {
|
||||||
"forceEndpoint: ",
|
logger.info(
|
||||||
forceEndpoint,
|
"Creating preferred transport based on: ",
|
||||||
);
|
"customUrl: ",
|
||||||
return from(
|
customUrl,
|
||||||
makeTransport(
|
"delayId: ",
|
||||||
client,
|
delayId,
|
||||||
ownMembershipIdentity,
|
"forceJwtEndpoint: ",
|
||||||
roomId,
|
forceJwtEndpoint,
|
||||||
customUrl,
|
);
|
||||||
forceEndpoint,
|
return makeTransport(
|
||||||
delayId ?? undefined,
|
client,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
roomId,
|
||||||
|
customUrl,
|
||||||
|
forceJwtEndpoint,
|
||||||
|
delayId ?? undefined,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// We deliberately hide any changes to the SFU config because we
|
||||||
|
// do not actually want the app to reconnect whenever the JWT
|
||||||
|
// token changes due to us delegating a new delayed event. The
|
||||||
|
// initial SFU config for the transport is all the app needs.
|
||||||
|
distinctUntilChanged((prev, next) =>
|
||||||
|
areLivekitTransportsEqual(prev.transport, next.transport),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
),
|
||||||
),
|
);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
if (useOldestMember) {
|
||||||
* The chosen transport we should advertise in our MatrixRTC membership.
|
// --- Oldest member mode ---
|
||||||
*/
|
return {
|
||||||
return scope.behavior(
|
// Never update the transport that we advertise in our membership. Just
|
||||||
combineLatest([
|
// take the first valid oldest member or preferred transport that we learn
|
||||||
useOldestMember$,
|
// about, and stick with that. This avoids unnecessary SFU hops and room
|
||||||
oldestMemberTransport$,
|
// state changes.
|
||||||
preferredTransport$,
|
advertised$: scope.behavior(
|
||||||
]).pipe(
|
merge(
|
||||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => {
|
oldestMemberTransport$,
|
||||||
return useOldestMember
|
preferredTransport$.pipe(map((t) => t?.transport ?? null)),
|
||||||
? (oldestMemberTransport ?? preferredTransport)
|
).pipe(
|
||||||
: preferredTransport;
|
first((t) => t !== null),
|
||||||
}),
|
tap((t) =>
|
||||||
distinctUntilChanged((t1, t2) => {
|
logger.info(`Advertise transport: ${t.livekit_service_url}`),
|
||||||
logger.info(
|
),
|
||||||
"Local Transport Update from:",
|
),
|
||||||
t1?.transport.livekit_service_url,
|
null,
|
||||||
" to ",
|
),
|
||||||
t2?.transport.livekit_service_url,
|
// Publish on the transport used by the oldest member.
|
||||||
);
|
active$: scope.behavior(
|
||||||
return areLivekitTransportsEqual(
|
oldestMemberTransport$.pipe(
|
||||||
t1?.transport ?? null,
|
switchMap((transport) => {
|
||||||
t2?.transport ?? null,
|
// Oldest member not available (or invalid SFU config).
|
||||||
);
|
if (transport === null) return of(null);
|
||||||
}),
|
// Oldest member available: fetch the SFU config.
|
||||||
|
const fetchOldestMemberTransport =
|
||||||
|
async (): Promise<LocalTransportWithSFUConfig> => ({
|
||||||
|
transport,
|
||||||
|
sfuConfig: await getSFUConfigWithOpenID(
|
||||||
|
client,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
transport.livekit_service_url,
|
||||||
|
roomId,
|
||||||
|
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return from(fetchOldestMemberTransport()).pipe(startWith(null));
|
||||||
|
}),
|
||||||
|
tap((t) =>
|
||||||
|
logger.info(
|
||||||
|
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multi-SFU mode ---
|
||||||
|
// Always publish on and advertise the preferred transport.
|
||||||
|
return {
|
||||||
|
advertised$: scope.behavior(
|
||||||
|
preferredTransport$.pipe(
|
||||||
|
map((t) => t?.transport ?? null),
|
||||||
|
distinctUntilChanged(areLivekitTransportsEqual),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
active$: preferredTransport$,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export interface IConnectionManager {
|
|||||||
* @param props - Configuration object
|
* @param props - Configuration object
|
||||||
* @param props.scope - The observable scope used by this object
|
* @param props.scope - The observable scope used by this object
|
||||||
* @param props.connectionFactory - Used to create new connections
|
* @param props.connectionFactory - Used to create new connections
|
||||||
* @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$)
|
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
|
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
|
||||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||||
* @param props.logger - The logger to use.
|
* @param props.logger - The logger to use.
|
||||||
@@ -162,22 +162,23 @@ export function createConnectionManager$({
|
|||||||
const connections$ = scope.behavior(
|
const connections$ = scope.behavior(
|
||||||
localAndRemoteTransports$.pipe(
|
localAndRemoteTransports$.pipe(
|
||||||
generateItemsWithEpoch(
|
generateItemsWithEpoch(
|
||||||
|
"ConnectionManager connections$",
|
||||||
function* (transports) {
|
function* (transports) {
|
||||||
for (const transportWithOrWithoutSfuConfig of transports) {
|
for (const transport of transports) {
|
||||||
if (
|
if (isLocalTransportWithSFUConfig(transport)) {
|
||||||
isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig)
|
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
|
||||||
) {
|
|
||||||
// This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
|
|
||||||
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
|
|
||||||
yield {
|
yield {
|
||||||
keys: [transport.livekit_service_url, sfuConfig],
|
keys: [
|
||||||
|
transport.transport.livekit_service_url,
|
||||||
|
transport.sfuConfig,
|
||||||
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
yield {
|
yield {
|
||||||
keys: [
|
keys: [
|
||||||
transportWithOrWithoutSfuConfig.livekit_service_url,
|
transport.livekit_service_url,
|
||||||
undefined as undefined | SFUConfig,
|
undefined as SFUConfig | undefined,
|
||||||
],
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
@@ -193,6 +194,8 @@ export function createConnectionManager$({
|
|||||||
},
|
},
|
||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
logger,
|
logger,
|
||||||
|
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
|
||||||
|
// I would like it if connections always fetched the SFUConfig by themselves.
|
||||||
sfuConfig,
|
sfuConfig,
|
||||||
);
|
);
|
||||||
// Start the connection immediately
|
// Start the connection immediately
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type LivekitTransportConfig,
|
type LivekitTransportConfig,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { combineLatest, filter, map } from "rxjs";
|
import { combineLatest, filter, map } from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { type Behavior } from "../../Behavior";
|
import { type Behavior } from "../../Behavior";
|
||||||
import { type IConnectionManager } from "./ConnectionManager";
|
import { type IConnectionManager } from "./ConnectionManager";
|
||||||
@@ -19,8 +18,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope";
|
|||||||
import { type Connection } from "./Connection";
|
import { type Connection } from "./Connection";
|
||||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
|
||||||
|
|
||||||
interface LocalTaggedParticipant {
|
interface LocalTaggedParticipant {
|
||||||
type: "local";
|
type: "local";
|
||||||
value$: Behavior<LocalParticipant | null>;
|
value$: Behavior<LocalParticipant | null>;
|
||||||
@@ -94,9 +91,10 @@ export function createMatrixLivekitMembers$({
|
|||||||
),
|
),
|
||||||
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
|
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
|
||||||
generateItemsWithEpoch(
|
generateItemsWithEpoch(
|
||||||
|
"MatrixLivekitMembers",
|
||||||
// Generator function.
|
// Generator function.
|
||||||
// creates an array of `{key, data}[]`
|
// creates an array of `{key, data}[]`
|
||||||
// Each change in the keys (new key, missing key) will result in a call to the factory function.
|
// Each change in the keys (new key) will result in a call to the factory function.
|
||||||
function* ([membershipsWithTransport, managerData]) {
|
function* ([membershipsWithTransport, managerData]) {
|
||||||
for (const { membership, transport } of membershipsWithTransport) {
|
for (const { membership, transport } of membershipsWithTransport) {
|
||||||
const participants = transport
|
const participants = transport
|
||||||
@@ -111,26 +109,23 @@ export function createMatrixLivekitMembers$({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
// This could also just be the memberId without the other fields.
|
// This could just be the backend identity without the other keys.
|
||||||
// In theory we should never have the same memberId for different userIds (they are UUIDs)
|
// The user ID, device ID, and member ID are included however so
|
||||||
// This still makes us resilient agains someone who intentionally tries to use the same memberId.
|
// they show up in debug logs.
|
||||||
// If they want to do this they would now need to also use the same sender which is impossible.
|
|
||||||
keys: [
|
keys: [
|
||||||
membership.userId,
|
membership.userId,
|
||||||
membership.deviceId,
|
membership.deviceId,
|
||||||
membership.memberId,
|
membership.memberId,
|
||||||
|
membership.rtcBackendIdentity,
|
||||||
],
|
],
|
||||||
data: { membership, participant, connection },
|
data: { membership, participant, connection },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
|
||||||
(scope, data$, userId, deviceId, memberId) => {
|
(scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => {
|
||||||
logger.debug(
|
|
||||||
`Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity},keys userId:deviceId:memberId ${userId}:${deviceId}:${memberId}`,
|
|
||||||
);
|
|
||||||
const { participant$, ...rest } = scope.splitBehavior(data$);
|
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||||
// will only get called once per `participantId, userId` pair.
|
// will only get called once per backend identity.
|
||||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -257,10 +257,9 @@ abstract class BaseMediaViewModel {
|
|||||||
* The Matrix user to which this media belongs.
|
* The Matrix user to which this media belongs.
|
||||||
*/
|
*/
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly rtcBackendIdentity: string,
|
|
||||||
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
||||||
// livekit.
|
// livekit.
|
||||||
protected readonly participant$: Observable<
|
protected readonly participant$: Behavior<
|
||||||
LocalParticipant | RemoteParticipant | null
|
LocalParticipant | RemoteParticipant | null
|
||||||
>,
|
>,
|
||||||
|
|
||||||
@@ -407,8 +406,11 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
rtcBackendIdentity: string,
|
/**
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
* The expected identity of the LiveKit participant. Exposed for debugging.
|
||||||
|
*/
|
||||||
|
public readonly rtcBackendIdentity: string,
|
||||||
|
participant$: Behavior<LocalParticipant | RemoteParticipant | null>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl$: Behavior<string | undefined>,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
@@ -421,7 +423,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
rtcBackendIdentity,
|
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
@@ -677,7 +678,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
rtcBackendIdentity: string,
|
rtcBackendIdentity: string,
|
||||||
participant$: Observable<RemoteParticipant | null>,
|
participant$: Behavior<RemoteParticipant | null>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl$: Behavior<string | undefined>,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
@@ -779,8 +780,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
rtcBackendIdentity: string,
|
participant$: Behavior<LocalParticipant | RemoteParticipant>,
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl$: Behavior<string | undefined>,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
@@ -793,7 +793,6 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
rtcBackendIdentity,
|
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.ScreenShareAudio,
|
Track.Source.ScreenShareAudio,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd.
|
|||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
import { of } from "rxjs";
|
|
||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { type ObservableScope } from "./ObservableScope.ts";
|
import { type ObservableScope } from "./ObservableScope.ts";
|
||||||
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
||||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||||
import type { Behavior } from "./Behavior.ts";
|
import { constant, type Behavior } from "./Behavior.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A screen share media item to be presented in a tile. This is a thin wrapper
|
* A screen share media item to be presented in a tile. This is a thin wrapper
|
||||||
@@ -28,7 +28,6 @@ export class ScreenShare {
|
|||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
rtcBackendIdentity: string,
|
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
@@ -41,8 +40,7 @@ export class ScreenShare {
|
|||||||
this.scope,
|
this.scope,
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
rtcBackendIdentity,
|
constant(participant),
|
||||||
of(participant),
|
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom$,
|
livekitRoom$,
|
||||||
focusUrl$,
|
focusUrl$,
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class UserMedia {
|
|||||||
// MediaViewModels don't support it though since they look for a unique
|
// MediaViewModels don't support it though since they look for a unique
|
||||||
// track for the given source. So generateItems here is a bit overkill.
|
// track for the given source. So generateItems here is a bit overkill.
|
||||||
generateItems(
|
generateItems(
|
||||||
|
`${this.id} screenShares$`,
|
||||||
function* (p) {
|
function* (p) {
|
||||||
if (p.isScreenShareEnabled)
|
if (p.isScreenShareEnabled)
|
||||||
yield {
|
yield {
|
||||||
@@ -142,7 +143,6 @@ export class UserMedia {
|
|||||||
scope,
|
scope,
|
||||||
`${this.id}:${key}`,
|
`${this.id}:${key}`,
|
||||||
this.userId,
|
this.userId,
|
||||||
this.rtcBackendIdentity,
|
|
||||||
p,
|
p,
|
||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
this.livekitRoom$,
|
this.livekitRoom$,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type SpotlightTileViewModel,
|
type SpotlightTileViewModel,
|
||||||
} from "./TileViewModel.ts";
|
} from "./TileViewModel.ts";
|
||||||
import {
|
import {
|
||||||
|
type LocalUserMediaViewModel,
|
||||||
|
type RemoteUserMediaViewModel,
|
||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "./MediaViewModel.ts";
|
} from "./MediaViewModel.ts";
|
||||||
@@ -40,8 +42,8 @@ export interface SpotlightExpandedLayoutMedia {
|
|||||||
|
|
||||||
export interface OneOnOneLayoutMedia {
|
export interface OneOnOneLayoutMedia {
|
||||||
type: "one-on-one";
|
type: "one-on-one";
|
||||||
local: UserMediaViewModel;
|
local: LocalUserMediaViewModel;
|
||||||
remote: UserMediaViewModel;
|
remote: RemoteUserMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipLayoutMedia {
|
export interface PipLayoutMedia {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ test("generateItems", () => {
|
|||||||
expectObservable(
|
expectObservable(
|
||||||
hot<string>(inputMarbles).pipe(
|
hot<string>(inputMarbles).pipe(
|
||||||
generateItems(
|
generateItems(
|
||||||
|
"test items",
|
||||||
function* (input) {
|
function* (input) {
|
||||||
for (let i = 1; i <= +input; i++) {
|
for (let i = 1; i <= +input; i++) {
|
||||||
yield { keys: [i], data: undefined };
|
yield { keys: [i], data: undefined };
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type OperatorFunction,
|
type OperatorFunction,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { type Behavior } from "../state/Behavior";
|
import { type Behavior } from "../state/Behavior";
|
||||||
import { Epoch, ObservableScope } from "../state/ObservableScope";
|
import { Epoch, ObservableScope } from "../state/ObservableScope";
|
||||||
@@ -122,8 +123,9 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemHandle<Data, Item> {
|
interface ItemHandle<Keys extends unknown[], Data, Item> {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
|
keys: readonly [...Keys];
|
||||||
data$: BehaviorSubject<Data>;
|
data$: BehaviorSubject<Data>;
|
||||||
item: Item;
|
item: Item;
|
||||||
}
|
}
|
||||||
@@ -135,6 +137,7 @@ interface ItemHandle<Data, Item> {
|
|||||||
* requested at a later time, and destroyed (have their scope ended) when the
|
* requested at a later time, and destroyed (have their scope ended) when the
|
||||||
* key is no longer requested.
|
* key is no longer requested.
|
||||||
*
|
*
|
||||||
|
* @param name A name for this collection to use in debug logs.
|
||||||
* @param generator A generator function yielding a tuple of keys and the
|
* @param generator A generator function yielding a tuple of keys and the
|
||||||
* currently associated data for each item that it wants to exist.
|
* currently associated data for each item that it wants to exist.
|
||||||
* @param factory A function constructing an individual item, given the item's key,
|
* @param factory A function constructing an individual item, given the item's key,
|
||||||
@@ -146,16 +149,17 @@ export function generateItems<
|
|||||||
Data,
|
Data,
|
||||||
Item,
|
Item,
|
||||||
>(
|
>(
|
||||||
|
name: string,
|
||||||
generator: (
|
generator: (
|
||||||
input: Input,
|
input: Input,
|
||||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
factory: (
|
factory: (
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
data$: Behavior<Data>,
|
data$: Behavior<Data>,
|
||||||
...keys: Keys
|
...keys: Keys
|
||||||
) => Item,
|
) => Item,
|
||||||
): OperatorFunction<Input, Item[]> {
|
): OperatorFunction<Input, Item[]> {
|
||||||
return generateItemsInternal(generator, factory, (items) => items);
|
return generateItemsInternal(name, generator, factory, (items) => items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,9 +171,10 @@ export function generateItemsWithEpoch<
|
|||||||
Data,
|
Data,
|
||||||
Item,
|
Item,
|
||||||
>(
|
>(
|
||||||
|
name: string,
|
||||||
generator: (
|
generator: (
|
||||||
input: Input,
|
input: Input,
|
||||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
factory: (
|
factory: (
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
data$: Behavior<Data>,
|
data$: Behavior<Data>,
|
||||||
@@ -177,6 +182,7 @@ export function generateItemsWithEpoch<
|
|||||||
) => Item,
|
) => Item,
|
||||||
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
|
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
|
||||||
return generateItemsInternal(
|
return generateItemsInternal(
|
||||||
|
name,
|
||||||
function* (input) {
|
function* (input) {
|
||||||
yield* generator(input.value);
|
yield* generator(input.value);
|
||||||
},
|
},
|
||||||
@@ -207,6 +213,38 @@ export function filterBehavior<T, S extends T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a changing input value to an item whose lifetime is tied to a certain
|
||||||
|
* computed key. The item may capture some dynamic data from the input.
|
||||||
|
*/
|
||||||
|
export function generateItem<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
generator: (input: Input) => { keys: readonly [...Keys]; data: Data },
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
): OperatorFunction<Input, Item> {
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
generateItemsInternal(
|
||||||
|
name,
|
||||||
|
function* (input) {
|
||||||
|
yield generator(input);
|
||||||
|
},
|
||||||
|
factory,
|
||||||
|
(items) => items,
|
||||||
|
),
|
||||||
|
map(([item]) => item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function generateItemsInternal<
|
function generateItemsInternal<
|
||||||
Input,
|
Input,
|
||||||
Keys extends [unknown, ...unknown[]],
|
Keys extends [unknown, ...unknown[]],
|
||||||
@@ -214,9 +252,10 @@ function generateItemsInternal<
|
|||||||
Item,
|
Item,
|
||||||
Output,
|
Output,
|
||||||
>(
|
>(
|
||||||
|
name: string,
|
||||||
generator: (
|
generator: (
|
||||||
input: Input,
|
input: Input,
|
||||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
factory: (
|
factory: (
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
data$: Behavior<Data>,
|
data$: Behavior<Data>,
|
||||||
@@ -232,26 +271,34 @@ function generateItemsInternal<
|
|||||||
Input,
|
Input,
|
||||||
{
|
{
|
||||||
map: Map<any, any>;
|
map: Map<any, any>;
|
||||||
items: Set<ItemHandle<Data, Item>>;
|
items: Set<ItemHandle<Keys, Data, Item>>;
|
||||||
input: Input;
|
input: Input;
|
||||||
},
|
},
|
||||||
{ map: Map<any, any>; items: Set<ItemHandle<Data, Item>> }
|
{ map: Map<any, any>; items: Set<ItemHandle<Keys, Data, Item>> }
|
||||||
>(
|
>(
|
||||||
({ map: prevMap, items: prevItems }, input) => {
|
({ map: prevMap, items: prevItems }, input) => {
|
||||||
const nextMap = new Map();
|
const nextMap = new Map();
|
||||||
const nextItems = new Set<ItemHandle<Data, Item>>();
|
const nextItems = new Set<ItemHandle<Keys, Data, Item>>();
|
||||||
|
|
||||||
for (const { keys, data } of generator(input)) {
|
for (const { keys, data } of generator(input)) {
|
||||||
// Disable type checks for a second to grab the item out of a nested map
|
// Disable type checks for a second to grab the item out of a nested map
|
||||||
let i: any = prevMap;
|
let i: any = prevMap;
|
||||||
for (const key of keys) i = i?.get(key);
|
for (const key of keys) i = i?.get(key);
|
||||||
let item = i as ItemHandle<Data, Item> | undefined;
|
let item = i as ItemHandle<Keys, Data, Item> | undefined;
|
||||||
|
|
||||||
if (item === undefined) {
|
if (item === undefined) {
|
||||||
// First time requesting the key; create the item
|
// First time requesting the key; create the item
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
const data$ = new BehaviorSubject(data);
|
const data$ = new BehaviorSubject(data);
|
||||||
item = { scope, data$, item: factory(scope, data$, ...keys) };
|
logger.debug(
|
||||||
|
`[${name}] Creating item with keys ${keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item = {
|
||||||
|
scope,
|
||||||
|
keys,
|
||||||
|
data$,
|
||||||
|
item: factory(scope, data$, ...keys),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
item.data$.next(data);
|
item.data$.next(data);
|
||||||
}
|
}
|
||||||
@@ -269,7 +316,7 @@ function generateItemsInternal<
|
|||||||
const finalKey = keys[keys.length - 1];
|
const finalKey = keys[keys.length - 1];
|
||||||
if (m.has(finalKey))
|
if (m.has(finalKey))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Keys must be unique (tried to generate multiple items for key ${keys})`,
|
`Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`,
|
||||||
);
|
);
|
||||||
m.set(keys[keys.length - 1], item);
|
m.set(keys[keys.length - 1], item);
|
||||||
nextItems.add(item);
|
nextItems.add(item);
|
||||||
@@ -277,7 +324,12 @@ function generateItemsInternal<
|
|||||||
|
|
||||||
// Destroy all items that are no longer being requested
|
// Destroy all items that are no longer being requested
|
||||||
for (const item of prevItems)
|
for (const item of prevItems)
|
||||||
if (!nextItems.has(item)) item.scope.end();
|
if (!nextItems.has(item)) {
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item.scope.end();
|
||||||
|
}
|
||||||
|
|
||||||
return { map: nextMap, items: nextItems, input };
|
return { map: nextMap, items: nextItems, input };
|
||||||
},
|
},
|
||||||
@@ -285,7 +337,15 @@ function generateItemsInternal<
|
|||||||
),
|
),
|
||||||
finalizeValue(({ items }) => {
|
finalizeValue(({ items }) => {
|
||||||
// Destroy all remaining items when no longer subscribed
|
// Destroy all remaining items when no longer subscribed
|
||||||
for (const { scope } of items) scope.end();
|
logger.debug(
|
||||||
|
`[${name}] End of scope, destroying all ${items.size} items…`,
|
||||||
|
);
|
||||||
|
for (const item of items) {
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item.scope.end();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
map(({ items, input }) =>
|
map(({ items, input }) =>
|
||||||
project(
|
project(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, vi, it } from "vitest";
|
import { describe, expect, vi, it, beforeEach } from "vitest";
|
||||||
import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
|
import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { getUrlParams } from "./UrlParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
@@ -35,11 +35,14 @@ vi.mock("./UrlParams", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
initializeWidget();
|
beforeEach(() => {
|
||||||
describe("widget", () => {
|
createRoomWidgetClientSpy.mockClear();
|
||||||
beforeAll(() => {});
|
});
|
||||||
|
|
||||||
|
describe("widget", () => {
|
||||||
it("should create an embedded client with the correct params", () => {
|
it("should create an embedded client with the correct params", () => {
|
||||||
|
initializeWidget("ANYRTCAPP");
|
||||||
|
|
||||||
expect(getUrlParams()).toStrictEqual({
|
expect(getUrlParams()).toStrictEqual({
|
||||||
widgetId: "id",
|
widgetId: "id",
|
||||||
parentUrl: "http://parentUrl",
|
parentUrl: "http://parentUrl",
|
||||||
@@ -66,13 +69,16 @@ describe("widget", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const sendState = [
|
const sendState = [
|
||||||
"myYser", // Legacy call membership events
|
{ eventType: "org.matrix.msc3401.call.member", stateKey: "myYser" }, // Legacy call membership events
|
||||||
`_myYser_AAAAA_m.call`, // Session membership events
|
{
|
||||||
`myYser_AAAAA_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
eventType: "org.matrix.msc3401.call.member",
|
||||||
].map((stateKey) => ({
|
stateKey: `_myYser_AAAAA_ANYRTCAPP`,
|
||||||
eventType: EventType.GroupCallMemberPrefix,
|
}, // Session membership events
|
||||||
stateKey,
|
{
|
||||||
}));
|
eventType: "org.matrix.msc3401.call.member",
|
||||||
|
stateKey: `myYser_AAAAA_ANYRTCAPP`,
|
||||||
|
}, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||||
|
];
|
||||||
const receiveState = [
|
const receiveState = [
|
||||||
{ eventType: EventType.RoomCreate },
|
{ eventType: EventType.RoomCreate },
|
||||||
{ eventType: EventType.RoomName },
|
{ eventType: EventType.RoomName },
|
||||||
@@ -124,4 +130,32 @@ describe("widget", () => {
|
|||||||
});
|
});
|
||||||
expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false);
|
expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should request send message permission if requested", () => {
|
||||||
|
initializeWidget("ANYRTCAPP", true);
|
||||||
|
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
// capabilities
|
||||||
|
expect.objectContaining({
|
||||||
|
sendEvent: expect.arrayContaining(["m.room.message"]),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not request send message permission when not requested", () => {
|
||||||
|
initializeWidget("", false);
|
||||||
|
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
// capabilities
|
||||||
|
expect.objectContaining({
|
||||||
|
sendEvent: expect.not.arrayContaining(["m.room.message"]),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ export let widget: WidgetHelpers | null;
|
|||||||
*/
|
*/
|
||||||
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
|
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
|
||||||
// execution.
|
// execution.
|
||||||
export const initializeWidget = (): void => {
|
export const initializeWidget = (
|
||||||
|
rtcApplication: string = "m.call",
|
||||||
|
sendRoomEvents = false,
|
||||||
|
): void => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
widgetId,
|
widgetId,
|
||||||
@@ -116,6 +119,9 @@ export const initializeWidget = (): void => {
|
|||||||
EventType.CallNotify, // Sent as a deprecated fallback
|
EventType.CallNotify, // Sent as a deprecated fallback
|
||||||
EventType.RTCNotification,
|
EventType.RTCNotification,
|
||||||
];
|
];
|
||||||
|
if (sendRoomEvents) {
|
||||||
|
sendEvent.push(EventType.RoomMessage);
|
||||||
|
}
|
||||||
const sendRecvEvent = [
|
const sendRecvEvent = [
|
||||||
"org.matrix.rageshake_request",
|
"org.matrix.rageshake_request",
|
||||||
EventType.CallEncryptionKeysPrefix,
|
EventType.CallEncryptionKeysPrefix,
|
||||||
@@ -128,8 +134,8 @@ export const initializeWidget = (): void => {
|
|||||||
|
|
||||||
const sendState = [
|
const sendState = [
|
||||||
userId, // Legacy call membership events
|
userId, // Legacy call membership events
|
||||||
`_${userId}_${deviceId}_m.call`, // Session membership events
|
`_${userId}_${deviceId}_${rtcApplication}`, // Session membership events
|
||||||
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
`${userId}_${deviceId}_${rtcApplication}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||||
].map((stateKey) => ({
|
].map((stateKey) => ({
|
||||||
eventType: EventType.GroupCallMemberPrefix,
|
eventType: EventType.GroupCallMemberPrefix,
|
||||||
stateKey,
|
stateKey,
|
||||||
|
|||||||
14
yarn.lock
14
yarn.lock
@@ -8364,7 +8364,7 @@ __metadata:
|
|||||||
livekit-client: "npm:^2.13.0"
|
livekit-client: "npm:^2.13.0"
|
||||||
lodash-es: "npm:^4.17.21"
|
lodash-es: "npm:^4.17.21"
|
||||||
loglevel: "npm:^1.9.1"
|
loglevel: "npm:^1.9.1"
|
||||||
matrix-js-sdk: "matrix-org/matrix-js-sdk#develop"
|
matrix-js-sdk: "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf"
|
||||||
matrix-widget-api: "npm:^1.16.1"
|
matrix-widget-api: "npm:^1.16.1"
|
||||||
node-stdlib-browser: "npm:^1.3.1"
|
node-stdlib-browser: "npm:^1.3.1"
|
||||||
normalize.css: "npm:^8.0.1"
|
normalize.css: "npm:^8.0.1"
|
||||||
@@ -11452,9 +11452,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"matrix-js-sdk@matrix-org/matrix-js-sdk#develop":
|
"matrix-js-sdk@matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf":
|
||||||
version: 40.1.0
|
version: 40.1.0
|
||||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=f2157f28bbadf2898fe21991f69ccb2af40df326"
|
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=6e3efef0c5f660df47cf00874927dec1c75cc3cf"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.5"
|
"@babel/runtime": "npm:^7.12.5"
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0"
|
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0"
|
||||||
@@ -11470,17 +11470,17 @@ __metadata:
|
|||||||
sdp-transform: "npm:^3.0.0"
|
sdp-transform: "npm:^3.0.0"
|
||||||
unhomoglyph: "npm:^1.0.6"
|
unhomoglyph: "npm:^1.0.6"
|
||||||
uuid: "npm:13"
|
uuid: "npm:13"
|
||||||
checksum: 10c0/d646b9214abbf0b9126760105edd9c57be7ffe8b53ae4acd5fefe841a51ad7d78fa57130922b3eac65ff2266b43f31ea60b4bdda9481e6bf8f1808d96726ed8a
|
checksum: 10c0/2c4db56fd0164d801c2f125ab2a442e3659314d4cc2fd640ea152b829d0db8b05ff808020e387a761afde4ff7a07b271c25431337de9f7c765c523c8cd837e36
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"matrix-widget-api@npm:^1.16.1":
|
"matrix-widget-api@npm:^1.16.1":
|
||||||
version: 1.16.1
|
version: 1.17.0
|
||||||
resolution: "matrix-widget-api@npm:1.16.1"
|
resolution: "matrix-widget-api@npm:1.17.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/events": "npm:^3.0.0"
|
"@types/events": "npm:^3.0.0"
|
||||||
events: "npm:^3.2.0"
|
events: "npm:^3.2.0"
|
||||||
checksum: 10c0/d88180f514104b84d3018055fc955138d65195465480a51e9afe5dbf2f3175b54e3483b4c4f1feab2dd27440f403051d9c8b293bd0532c09b136c6b23606e1ee
|
checksum: 10c0/3651c860900149ecc2fe74640b47687bab8a347eb718a522085189e2b84efe462c9d81c1e8caff08d122f0b3e9cef8303a5802837673e5c9b465f7624c56a8f3
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user