mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
6 Commits
robin/ring
...
hs/disable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53727bac58 | ||
|
|
016c4dcac9 | ||
|
|
44a2809f79 | ||
|
|
a33fda5637 | ||
|
|
eca716b74d | ||
|
|
8691473329 |
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "rxjs", "jsdoc"],
|
||||
plugins: ["matrix-org", "rxjs"],
|
||||
extends: [
|
||||
"plugin:matrix-org/react",
|
||||
"plugin:matrix-org/a11y",
|
||||
@@ -26,13 +26,6 @@ module.exports = {
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"jsdoc/no-types": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/check-values": "error",
|
||||
"jsdoc/check-param-names": "warn",
|
||||
// "jsdoc/require-param": "warn",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"react/display-name": "error",
|
||||
@@ -50,29 +43,6 @@ module.exports = {
|
||||
// To encourage good usage of RxJS:
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
"rxjs/finnish": ["error", { names: { "^this$": false } }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
|
||||
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
|
||||
.map((path) => `${lib}/${path}`)
|
||||
.map((name) => ({ name, message: `Please use ${lib} instead` })),
|
||||
),
|
||||
patterns: [
|
||||
...["matrix-widget-api"].map((lib) => ({
|
||||
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
|
||||
(path) => `${lib}/${path}`,
|
||||
),
|
||||
message: `Please use ${lib} instead`,
|
||||
})),
|
||||
// XXX: We use /lib in lots of places, so allow for now.
|
||||
...["matrix-js-sdk"].map((lib) => ({
|
||||
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
|
||||
message: `Please use ${lib} instead`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
@@ -82,30 +52,6 @@ module.exports = {
|
||||
"no-console": ["error"],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/test.ts",
|
||||
"**/test.tsx",
|
||||
"**/test-**",
|
||||
],
|
||||
rules: {
|
||||
"jsdoc/no-types": "off",
|
||||
"jsdoc/empty-tags": "off",
|
||||
"jsdoc/check-property-names": "off",
|
||||
"jsdoc/check-values": "off",
|
||||
"jsdoc/check-param-names": "off",
|
||||
"jsdoc/require-param-description": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["playwright/**"],
|
||||
rules: {
|
||||
// Playwright as a `use` function that has nothing to do with React hooks.
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,39 +0,0 @@
|
||||
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
|
||||
|
||||
## Content
|
||||
|
||||
<!-- Describe shortly what has been changed -->
|
||||
|
||||
## Motivation and context
|
||||
|
||||
<!-- Provide link to the corresponding issue if applicable or explain the context -->
|
||||
|
||||
## Screenshots / GIFs
|
||||
|
||||
<!--
|
||||
|
||||
You can use a table like this to show screenshots comparison.
|
||||
Uncomment this markdown table below and edit the last line `|||`:
|
||||
|copy screenshot of before here|copy screenshot of after here|
|
||||
|
||||
|Before|After|
|
||||
|-|-|
|
||||
|||
|
||||
-->
|
||||
|
||||
## Tests
|
||||
|
||||
<!-- Explain how you tested your development -->
|
||||
|
||||
- Step 1
|
||||
- Step 2
|
||||
- Step ...
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
|
||||
- [ ] Pull request includes screenshots or videos if containing UI changes
|
||||
- [ ] Tests written for new code (and old code if feasible).
|
||||
- [ ] Linter and other CI checks pass.
|
||||
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)
|
||||
11
.github/workflows/blocked.yaml
vendored
11
.github/workflows/blocked.yaml
vendored
@@ -1,16 +1,7 @@
|
||||
name: Prevent blocked
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This workflow does not checkout code or use secrets.
|
||||
# It only reads labels to set a failure status on the PR.
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Required to fail the check on the PR
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
@@ -19,7 +10,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
|
||||
57
.github/workflows/build-and-publish-docker.yaml
vendored
57
.github/workflows/build-and-publish-docker.yaml
vendored
@@ -20,13 +20,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
id-token: write # needed for login into tailscale with GitHub OIDC Token
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -37,64 +34,26 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
audience: ${{ secrets.TS_AUDIENCE }}
|
||||
tags: tag:github-actions
|
||||
|
||||
- name: Compute vault jwt role name
|
||||
id: vault-jwt-role
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get team registry token
|
||||
id: import-secrets
|
||||
uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
url: https://vault.infra.ci.i.element.dev
|
||||
role: ${{ steps.vault-jwt-role.outputs.role_name }}
|
||||
path: service-management/github-actions
|
||||
jwtGithubAudience: https://vault.infra.ci.i.element.dev
|
||||
method: jwt
|
||||
secrets: |
|
||||
services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ;
|
||||
services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
|
||||
|
||||
- name: Login to oci.element.io Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: oci-push.vpn.infra.element.io
|
||||
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
|
||||
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
oci-push.vpn.infra.element.io/element-call
|
||||
tags: ${{ inputs.docker_tags }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
labels: |
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
10
.github/workflows/build-element-call.yaml
vendored
10
.github/workflows/build-element-call.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
type: string
|
||||
package:
|
||||
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', 'embedded', or 'sdk'
|
||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
||||
required: true
|
||||
build_mode:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
@@ -32,9 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -45,7 +43,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build Element Call
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -54,8 +52,6 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PACKAGE: ${{ inputs.package }}
|
||||
BUILD_MODE: ${{ inputs.build_mode }}
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
16
.github/workflows/build.yaml
vendored
16
.github/workflows/build.yaml
vendored
@@ -49,9 +49,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.run_id }}
|
||||
docker_tags: |
|
||||
@@ -71,17 +69,3 @@ jobs:
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_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 }}
|
||||
|
||||
8
.github/workflows/changelog-label.yml
vendored
8
.github/workflows/changelog-label.yml
vendored
@@ -1,16 +1,8 @@
|
||||
name: PR changelog label
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# This is safe because we do not use actions/checkout or execute untrusted code.
|
||||
# Using pull_request_target is necessary to allow status writes for PRs from forks.
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
20
.github/workflows/deploy-to-netlify.yaml
vendored
20
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -14,10 +14,6 @@ on:
|
||||
deployment_ref:
|
||||
required: true
|
||||
type: string
|
||||
package:
|
||||
required: true
|
||||
type: string
|
||||
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
@@ -54,7 +50,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output-${{ inputs.package }}
|
||||
name: build-output-full
|
||||
path: webapp
|
||||
|
||||
- name: Add redirects file
|
||||
@@ -62,23 +58,15 @@ jobs:
|
||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||
|
||||
- name: Add config file
|
||||
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
|
||||
env:
|
||||
INPUTS_PACKAGE: ${{ inputs.package }}
|
||||
INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }}
|
||||
INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }}
|
||||
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||
alias: pr${{ inputs.pr_number }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -7,9 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
33
.github/workflows/pr-deploy.yaml
vendored
33
.github/workflows/pr-deploy.yaml
vendored
@@ -1,7 +1,5 @@
|
||||
name: Deploy previews for PRs
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This is now restricted to internal PRs only using the 'if' condition below.
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
@@ -9,14 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
prdetails:
|
||||
# Logic:
|
||||
# 1. Build must be successful
|
||||
# 2. Event must be a pull_request
|
||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
@@ -29,7 +20,7 @@ jobs:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
netlify-full:
|
||||
netlify:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
@@ -40,24 +31,6 @@ jobs:
|
||||
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: 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:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -69,9 +42,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
|
||||
99
.github/workflows/publish-embedded-packages.yaml
vendored
99
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -22,18 +22,8 @@ jobs:
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
steps:
|
||||
- name: Calculate VERSION
|
||||
# Safely store dynamic values in environment variables
|
||||
# to prevent shell injection (template-injection)
|
||||
run: |
|
||||
# The logic is executed within the shell using the env variables
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
# We should only use the hard coded test value for a dry run
|
||||
run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV"
|
||||
- id: dry_run
|
||||
name: Set DRY_RUN
|
||||
# We perform a dry run for all events except releases.
|
||||
@@ -81,9 +71,7 @@ jobs:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
- name: Determine filename
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -92,12 +80,12 @@ jobs:
|
||||
name: build-output-embedded
|
||||
path: ${{ env.FILENAME_PREFIX}}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Checksum
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -112,12 +100,10 @@ jobs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Allow npm to authenticate as a trusted publisher
|
||||
id-token: write # required for the provenance flag on npm publish
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -137,16 +123,15 @@ jobs:
|
||||
- name: Publish npm
|
||||
working-directory: embedded/web
|
||||
run: |
|
||||
npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version
|
||||
npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version
|
||||
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
|
||||
npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_android:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -159,9 +144,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -172,7 +155,7 @@ jobs:
|
||||
path: embedded/android/lib/src/main/assets/element-call
|
||||
|
||||
- name: ☕️ Setup Java
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -180,19 +163,16 @@ jobs:
|
||||
- name: Get artifact version
|
||||
# Anything that is not a final release will be tagged as a snapshot
|
||||
run: |
|
||||
if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
|
||||
- name: Publish AAR
|
||||
working-directory: embedded/android
|
||||
@@ -206,7 +186,7 @@ jobs:
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_ios:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -219,10 +199,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
path: element-call
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -233,23 +212,20 @@ jobs:
|
||||
path: element-call/embedded/ios/Sources/dist
|
||||
|
||||
- name: Checkout element-call-swift
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: element-hq/element-call-swift
|
||||
path: element-call-swift
|
||||
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Copy files
|
||||
run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift
|
||||
|
||||
- name: Get artifact version
|
||||
run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
|
||||
- name: Test build
|
||||
working-directory: element-call-swift
|
||||
@@ -261,22 +237,17 @@ jobs:
|
||||
git config --global user.email "ci@element.io"
|
||||
git config --global user.name "Element CI"
|
||||
git add -A
|
||||
git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}"
|
||||
git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
|
||||
git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}"
|
||||
git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}"
|
||||
|
||||
- name: Push
|
||||
working-directory: element-call-swift
|
||||
run: |
|
||||
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_notes:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
@@ -288,16 +259,12 @@ jobs:
|
||||
steps:
|
||||
- name: Log versions
|
||||
run: |
|
||||
echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}"
|
||||
env:
|
||||
NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
|
||||
echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}"
|
||||
echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}"
|
||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
11
.github/workflows/publish.yaml
vendored
11
.github/workflows/publish.yaml
vendored
@@ -38,11 +38,11 @@ jobs:
|
||||
name: build-output-full
|
||||
path: ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Checksum
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -55,15 +55,12 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
# Like before, using ${{ env.VERSION }} above doesn't work
|
||||
add_docker_release_note:
|
||||
needs: publish_docker
|
||||
@@ -71,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
12
.github/workflows/test.yaml
vendored
12
.github/workflows/test.yaml
vendored
@@ -9,9 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -24,7 +22,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -32,12 +30,10 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
6
.github/workflows/translations-download.yaml
vendored
6
.github/workflows/translations-download.yaml
vendored
@@ -13,9 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -44,7 +42,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
4
.github/workflows/translations-upload.yaml
vendored
4
.github/workflows/translations-upload.yaml
vendored
@@ -14,9 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
|
||||
23
.github/workflows/zizmor.yml
vendored
23
.github/workflows/zizmor.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
@@ -1 +1 @@
|
||||
24
|
||||
22
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
[](https://matrix.to/#/#webrtc:matrix.org)
|
||||
[](https://localazy.com/p/element-call)
|
||||
[](LICENSE-AGPL-3.0)
|
||||
[](https://app.codecov.io/gh/element-hq/element-call)
|
||||
|
||||
[🎬 Live Demo 🎬](https://call.element.io)
|
||||
|
||||
|
||||
@@ -38,8 +38,6 @@ experimental_features:
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -38,8 +38,6 @@ experimental_features:
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -181,10 +181,7 @@ server {
|
||||
|
||||
}
|
||||
|
||||
# Convenience reverse proxy for the call.m.localhost domain to element call
|
||||
# running on the host either via
|
||||
# - yarn dev --host or
|
||||
# - falling back to http (the element call docker container)
|
||||
# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@@ -200,7 +197,7 @@ server {
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
# 1. Attempt HTTPS first
|
||||
|
||||
location ^~ / {
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -211,23 +208,8 @@ server {
|
||||
proxy_pass https://host.docker.internal:3000;
|
||||
proxy_ssl_verify off;
|
||||
|
||||
# 2. Redirect specific errors (e.g., 502 Bad Gateway or 504 Timeout)
|
||||
# to the named fallback location
|
||||
error_page 502 503 504 = @http_fallback;
|
||||
|
||||
}
|
||||
|
||||
# 3. Fallback location using HTTP
|
||||
location @http_fallback {
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://host.docker.internal:8080;
|
||||
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ experimental_features:
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -38,8 +38,6 @@ experimental_features:
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -13,6 +13,7 @@ coverage:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
# Enforce 80% coverage on all lines that a PR
|
||||
# Encourage (but don't enforce) 80% coverage on all lines that a PR
|
||||
# touches
|
||||
target: 80%
|
||||
informational: true
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
},
|
||||
"rageshake": {
|
||||
"submit_url": "https://rageshakes.element.io/api/submit"
|
||||
"submit_url": "https://element.io/bugreports/submit"
|
||||
},
|
||||
"sentry": {
|
||||
"environment": "netlify-pr-preview",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
# OpenTelemetry Collector for development
|
||||
|
||||
## Edit:
|
||||
|
||||
Open telemetry has been removed in: https://github.com/element-hq/element-call/pull/3586
|
||||
Check this PR to get back the implementation or to use it as reference to add it back.
|
||||
|
||||
---
|
||||
|
||||
This directory contains a docker compose file that starts a jaeger all-in-one instance
|
||||
with an in-memory database, along with a standalone OpenTelemetry collector that forwards
|
||||
traces into the jaeger. Jaeger has a built-in OpenTelemetry collector, but it can't be
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
image: livekit/livekit-server:latest
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
image: livekit/livekit-server:latest
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
image: docker.io/matrixdotorg/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -106,7 +106,7 @@ services:
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
image: docker.io/matrixdotorg/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
|
||||
@@ -136,8 +136,8 @@ handle @jwt_service {
|
||||
reverse_proxy http://[::1]:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_addr}
|
||||
header_up X-Forwarded-For {remote_addr}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,46 +146,12 @@ handle {
|
||||
reverse_proxy http://localhost:7880 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_addr}
|
||||
header_up X-Forwarded-For {remote_addr}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Using Haproxy, you can achieve this by:
|
||||
|
||||
```
|
||||
# Frontend
|
||||
# Match /livekit/sfu/ path
|
||||
acl is_sfu path_beg -i /livekit/sfu/
|
||||
use_backend sfu_backend if is_sfu matrixrtc_domain
|
||||
|
||||
acl is_mxrtc_auth path_beg -i /sfu/get
|
||||
use_backend mxrtc_auth_backend if is_mxrtc_auth matrixrtc_domain
|
||||
|
||||
# Backend
|
||||
## MatrixRTC backend
|
||||
backend sfu_backend
|
||||
server livekit 127.0.0.1:7880
|
||||
http-request set-path %[path,regsub(^/livekit/sfu/,/)]
|
||||
http-request set-header Host %[req.hdr(host)]
|
||||
timeout server 120s
|
||||
# WebSocket support
|
||||
option forwardfor
|
||||
option http-server-close
|
||||
option http-buffer-request
|
||||
|
||||
backend mxrtc_auth_backend
|
||||
server sfu 127.0.0.1:8070
|
||||
http-request set-header Host %[req.hdr(host)]
|
||||
timeout server 120s
|
||||
# WebSocket support
|
||||
option forwardfor
|
||||
option http-server-close
|
||||
option http-buffer-request
|
||||
|
||||
```
|
||||
|
||||
#### MatrixRTC backend announcement
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -317,7 +283,6 @@ self-hosters and developers working with Element Call.
|
||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||
- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/)
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
|
||||
@@ -46,32 +46,32 @@ possible to support encryption.
|
||||
|
||||
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
@@ -96,6 +96,6 @@ These parameters are only supported in the [embedded](./embedded-standalone.md)
|
||||
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `posthogApiHost` | Posthog server URL | No | e.g. `https://posthog-element-call.element.io`. Only supported in embedded package. In full package the value from config is used. |
|
||||
| `posthogApiKey` | Posthog project API key | No | Only supported in embedded package. In full package the value from config is used. |
|
||||
| `rageshakeSubmitUrl` | Rageshake server URL endpoint | No | e.g. `https://rageshakes.element.io/api/submit`. In full package the value from config is used. |
|
||||
| `rageshakeSubmitUrl` | Rageshake server URL endpoint | No | e.g. `https://element.io/bugreports/submit`. In full package the value from config is used. |
|
||||
| `sentryDsn` | Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | No | In full package the value from config is used. |
|
||||
| `sentryEnvironment` | Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/) | No | In full package the value from config is used. |
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.13.2"
|
||||
android_gradle_plugin = "8.11.1"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
12
knip.ts
12
knip.ts
@@ -9,7 +9,7 @@ import { type KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
vite: {
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts"],
|
||||
},
|
||||
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
||||
ignoreBinaries: [
|
||||
@@ -34,12 +34,10 @@ export default {
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// Yarn plugins are allowed to depend on packages provided by the Yarn
|
||||
// runtime. These shouldn't be listed in package.json, because plugins
|
||||
// should work before Yarn even installs dependencies for the first time.
|
||||
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
|
||||
"@yarnpkg/core",
|
||||
"@yarnpkg/parsers",
|
||||
// We need this so that TypeScript is happy with @livekit/track-processors.
|
||||
// This might be a bug in the LiveKit repo but for now we fix it on the
|
||||
// Element Call side.
|
||||
"@types/dom-mediacapture-transform",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
@@ -64,14 +64,6 @@
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Currently set to: ",
|
||||
"from_config": "Currently, no overwrite is set. Url from well-known or config is used.",
|
||||
"label": "Custom Livekit-url",
|
||||
"reset": "Reset overwrite",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"debug_tile_layout_label": "Debug tile layout",
|
||||
"device_id": "Device ID: {{id}}",
|
||||
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||
@@ -80,24 +72,12 @@
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
|
||||
"label": "Compatibility: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Compatible with old versions of EC that do not support multi SFU",
|
||||
"label": "Legacy: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
},
|
||||
"title": "MatrixRTC mode"
|
||||
},
|
||||
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
|
||||
"show_connection_stats": "Show connection statistics",
|
||||
"url_params": "URL parameters"
|
||||
"show_non_member_tiles": "Show tiles for non-member media",
|
||||
"url_params": "URL parameters",
|
||||
"use_new_membership_manager": "Use the new implementation of the call MembershipManager",
|
||||
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"error": {
|
||||
@@ -108,15 +88,11 @@
|
||||
"connection_lost_description": "You were disconnected from the call.",
|
||||
"e2ee_unsupported": "Incompatible browser",
|
||||
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||
"failed_to_start_livekit": "Failed to start Livekit connection",
|
||||
"generic": "Something went wrong",
|
||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
||||
"insufficient_capacity": "Insufficient capacity",
|
||||
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"membership_manager": "Membership Manager Error",
|
||||
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
|
||||
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
|
||||
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"open_elsewhere": "Opened in another tab",
|
||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
||||
"room_creation_restricted": "Failed to create call",
|
||||
@@ -249,14 +225,12 @@
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"call_ended": "Call ended",
|
||||
"calling": "Calling…",
|
||||
"camera_starting": "Video loading...",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
}
|
||||
|
||||
49
package.json
49
package.json
@@ -13,9 +13,6 @@
|
||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "yarn build:embedded",
|
||||
"build:embedded:development": "yarn build:embedded --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",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
@@ -43,14 +40,21 @@
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
"@formatjs/intl-durationformat": "^0.10.0",
|
||||
"@formatjs/intl-durationformat": "^0.7.0",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.42.2",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@livekit/protocol": "^1.38.0",
|
||||
"@livekit/track-processors": "^0.5.5",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/core": "^2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
@@ -63,10 +67,11 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -79,7 +84,7 @@
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
@@ -88,26 +93,23 @@
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-deprecate": "^0.8.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-rxjs": "^5.0.3",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"fetch-mock": "11.1.5",
|
||||
"global-jsdom": "^26.0.0",
|
||||
"i18next": "^25.0.0",
|
||||
"i18next": "^24.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-parser": "^9.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.86.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
|
||||
"matrix-widget-api": "^1.13.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
@@ -118,7 +120,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^16.0.0 <16.6.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -126,24 +128,17 @@
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
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.
|
||||
@@ -39,7 +38,6 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: [
|
||||
@@ -58,9 +56,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
ignoreHTTPSErrors: true,
|
||||
@@ -72,27 +70,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
testMatch: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Pixel 7"],
|
||||
ignoreHTTPSErrors: true,
|
||||
permissions: [
|
||||
"clipboard-write",
|
||||
"clipboard-read",
|
||||
"microphone",
|
||||
"camera",
|
||||
],
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
|
||||
// clear http to the homeserver
|
||||
|
||||
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
|
||||
test("Sign up a new account, then login, then logout", async ({ browser }) => {
|
||||
const userId = `test_user-id_${Date.now()}`;
|
||||
|
||||
@@ -71,7 +69,12 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
// ========
|
||||
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||
// ========
|
||||
await SpaHelpers.createCall(creatorPage, "Inviter", "Welcome");
|
||||
await creatorPage.getByTestId("home_callName").click();
|
||||
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||
await creatorPage.getByTestId("home_displayName").click();
|
||||
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||
await creatorPage.getByTestId("home_go").click();
|
||||
await expect(creatorPage.locator("video")).toBeVisible();
|
||||
|
||||
// join
|
||||
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||
@@ -79,7 +82,19 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// Get the invite link
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(creatorPage);
|
||||
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await creatorPage.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
// ========
|
||||
// ACT: The other user use the invite link to join the call as a guest
|
||||
@@ -88,7 +103,13 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink);
|
||||
|
||||
await guestPage.goto(inviteLink);
|
||||
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await guestPage.getByTestId("joincall_joincall").click();
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
await guestPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// ========
|
||||
// ASSERT: check that there are two members in the call
|
||||
|
||||
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { createJTWToken } from "./fixtures/jwt-token";
|
||||
|
||||
test("Should show error screen if fails to get JWT token", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -77,12 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
|
||||
|
||||
test("Should show error screen if call creation is restricted", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment.",
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
// We need the socket connection to fail, but this cannot be done by using the websocket route.
|
||||
@@ -95,7 +88,7 @@ test("Should show error screen if call creation is restricted", async ({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "wss://badurltotricktest/livekit/sfu",
|
||||
jwt: createJTWToken("@fake:user", "!fake:room"),
|
||||
jwt: "FAKE",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Browser, type Page, test, expect } from "@playwright/test";
|
||||
|
||||
export interface MobileCreateFixtures {
|
||||
asMobile: {
|
||||
creatorPage: Page;
|
||||
inviteLink: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const mobileTest = test.extend<MobileCreateFixtures>({
|
||||
asMobile: async ({ browser }, pUse) => {
|
||||
const fixtures = await createCallAndInvite(browser);
|
||||
await pUse({
|
||||
creatorPage: fixtures.page,
|
||||
inviteLink: fixtures.inviteLink,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a call and generate an invite link
|
||||
*/
|
||||
async function createCallAndInvite(
|
||||
browser: Browser,
|
||||
): Promise<{ page: Page; inviteLink: string }> {
|
||||
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const creatorPage = await creatorContext.newPage();
|
||||
|
||||
await creatorPage.goto("/");
|
||||
|
||||
// ========
|
||||
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||
// ========
|
||||
await creatorPage.getByTestId("home_callName").click();
|
||||
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||
await creatorPage.getByTestId("home_displayName").click();
|
||||
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||
await creatorPage.getByTestId("home_go").click();
|
||||
await expect(creatorPage.locator("video")).toBeVisible();
|
||||
|
||||
await creatorPage
|
||||
.getByRole("button", { name: "Continue in browser" })
|
||||
.click();
|
||||
// join
|
||||
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// Get the invite link
|
||||
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await creatorPage.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
return {
|
||||
page: creatorPage,
|
||||
inviteLink,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
export function createJTWToken(sub: string, room: string): string {
|
||||
return [
|
||||
{}, // header
|
||||
{
|
||||
// payload
|
||||
sub,
|
||||
video: {
|
||||
room,
|
||||
},
|
||||
},
|
||||
{}, // signature
|
||||
]
|
||||
.map((d) => global.btoa(JSON.stringify(d)))
|
||||
.join(".");
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
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 { type Page, test, expect, type JSHandle } from "@playwright/test";
|
||||
import {
|
||||
type Browser,
|
||||
type Page,
|
||||
test,
|
||||
expect,
|
||||
type JSHandle,
|
||||
} from "@playwright/test";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import { HOST1, TestHelpers } from "../widget/test-helpers.ts";
|
||||
|
||||
export type UserBaseFixture = {
|
||||
mxId: string;
|
||||
displayName: string;
|
||||
page: Page;
|
||||
clientHandle: JSHandle<MatrixClient>;
|
||||
};
|
||||
@@ -25,10 +28,10 @@ export type BaseWidgetSetup = {
|
||||
|
||||
export interface MyFixtures {
|
||||
asWidget: BaseWidgetSetup;
|
||||
callType: "room" | "dm";
|
||||
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
|
||||
}
|
||||
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
// Minimal config.json for the local element-web instance
|
||||
const CONFIG_JSON = {
|
||||
default_server_config: {
|
||||
@@ -62,91 +65,132 @@ const CONFIG_JSON = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
||||
* @param page
|
||||
*/
|
||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
||||
? async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"http://localhost:8080/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
: async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a new user and returns page, clientHandle and mxId.
|
||||
*/
|
||||
async function registerUser(
|
||||
browser: Browser,
|
||||
username: string,
|
||||
): Promise<{ page: Page; clientHandle: JSHandle<MatrixClient>; mxId: string }> {
|
||||
const userContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
await page.goto("http://localhost:8081/#/welcome");
|
||||
await page.getByRole("link", { name: "Create Account" }).click();
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
||||
try {
|
||||
await expect(continueButton).toBeVisible({ timeout: 5000 });
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await continueButton.click();
|
||||
} catch {
|
||||
// continueButton not visible, continue as normal
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||
).toBeVisible();
|
||||
await setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const mxId = (await clientHandle.evaluate(
|
||||
(cli: MatrixClient) => cli.getUserId(),
|
||||
clientHandle,
|
||||
))!;
|
||||
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
export const widgetTest = test.extend<MyFixtures>({
|
||||
// allow per-test override: `widgetTest.use({ callType: "dm" })`
|
||||
callType: ["room", { option: true }],
|
||||
asWidget: async ({ browser, context, callType }, pUse) => {
|
||||
asWidget: async ({ browser, context }, pUse) => {
|
||||
await context.route(`http://localhost:8081/config.json*`, async (route) => {
|
||||
await route.fulfill({ json: CONFIG_JSON });
|
||||
});
|
||||
|
||||
const brooksDisplayName = `brooks_${Date.now()}`;
|
||||
const whistlerDisplayName = `whistler_${Date.now()}`;
|
||||
const userA = `brooks_${Date.now()}`;
|
||||
const userB = `whistler_${Date.now()}`;
|
||||
|
||||
// Register users
|
||||
const {
|
||||
page: ewPage1,
|
||||
clientHandle: brooksClientHandle,
|
||||
mxId: brooksMxId,
|
||||
} = await TestHelpers.registerUser(browser, brooksDisplayName);
|
||||
} = await registerUser(browser, userA);
|
||||
const {
|
||||
page: ewPage2,
|
||||
clientHandle: whistlerClientHandle,
|
||||
mxId: whistlerMxId,
|
||||
} = await TestHelpers.registerUser(browser, whistlerDisplayName);
|
||||
} = await registerUser(browser, userB);
|
||||
|
||||
// Invite the second user
|
||||
if (callType === "room") {
|
||||
await TestHelpers.createRoom("Welcome Room", ewPage1);
|
||||
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
|
||||
await ewPage1.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
|
||||
await expect(ewPage1.getByText("Encryption enabled")).toBeVisible();
|
||||
|
||||
await ewPage1
|
||||
.getByRole("button", { name: "Invite to this room", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
|
||||
).toBeVisible();
|
||||
await ewPage1
|
||||
.getByRole("button", { name: "Invite to this room", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
|
||||
).toBeVisible();
|
||||
|
||||
// To get the invite textbox we need to specifically select within the
|
||||
// dialog, since there is another textbox in the background (the message
|
||||
// composer). In theory the composer shouldn't be visible to Playwright at
|
||||
// all because the invite dialog has trapped focus, but the focus trap
|
||||
// doesn't quite work right on Firefox.
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").click();
|
||||
await ewPage1.getByRole("button", { name: "Invite" }).click();
|
||||
// To get the invite textbox we need to specifically select within the
|
||||
// dialog, since there is another textbox in the background (the message
|
||||
// composer). In theory the composer shouldn't be visible to Playwright at
|
||||
// all because the invite dialog has trapped focus, but the focus trap
|
||||
// doesn't quite work right on Firefox.
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").click();
|
||||
await ewPage1.getByRole("button", { name: "Invite" }).click();
|
||||
|
||||
// Accept the invite
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
.getByRole("main")
|
||||
.getByRole("heading", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
} else if (callType === "dm") {
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
|
||||
await ewPage1.getByRole("button", { name: "Go" }).click();
|
||||
|
||||
// Wait and send the first message to create the DM
|
||||
await expect(
|
||||
ewPage1.getByText(/Send your first message to invite/),
|
||||
).toBeVisible();
|
||||
|
||||
await ewPage1.locator(".mx_BasicMessageComposer_input > div").click();
|
||||
await ewPage1
|
||||
.getByRole("textbox", { name: "Send a message…" })
|
||||
.fill("Hello!");
|
||||
await ewPage1.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect(
|
||||
ewPage1.getByText("This is the beginning of your"),
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("option", { name: "Open room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
// Accept the invite
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Renamed use to pUse, as a workaround for eslint error that was thinking this use was a react use.
|
||||
await pUse({
|
||||
@@ -154,40 +198,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
mxId: brooksMxId,
|
||||
page: ewPage1,
|
||||
clientHandle: brooksClientHandle,
|
||||
displayName: brooksDisplayName,
|
||||
},
|
||||
whistler: {
|
||||
mxId: whistlerMxId,
|
||||
page: ewPage2,
|
||||
clientHandle: whistlerClientHandle,
|
||||
displayName: whistlerDisplayName,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a way to add additional users within a test.
|
||||
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
|
||||
*/
|
||||
addUser: async ({ browser }, use) => {
|
||||
await use(
|
||||
async (
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<UserBaseFixture> => {
|
||||
const uniqueSuffix = Date.now();
|
||||
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
|
||||
browser,
|
||||
`${username.toLowerCase()}_${uniqueSuffix}`,
|
||||
host,
|
||||
);
|
||||
return {
|
||||
mxId,
|
||||
displayName: username,
|
||||
page,
|
||||
clientHandle,
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mobileTest } from "../fixtures/fixture-mobile-create.ts";
|
||||
|
||||
test("@mobile Start a new call then leave and show the feedback screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("HelloCall");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill("John Doe");
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
// await page.pause();
|
||||
await expect(page.locator("video")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Continue in browser" }).click();
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// Ensure that the call is connected
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^HelloCall$/ })
|
||||
.click();
|
||||
// Check the number of participants
|
||||
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
|
||||
// The tooltip with the name should be visible
|
||||
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
|
||||
|
||||
// leave the call
|
||||
await page.getByTestId("incall_leave").click();
|
||||
await expect(page.getByRole("heading")).toContainText(
|
||||
"John Doe, your call has ended. How did it go?",
|
||||
);
|
||||
await expect(page.getByRole("main")).toContainText(
|
||||
"Why not finish by setting up a password to keep your account?",
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Not now, return to home screen" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
mobileTest(
|
||||
"Test earpiece overlay in controlledAudioDevices mode",
|
||||
async ({ asMobile, browser }) => {
|
||||
test.slow(); // Triples the timeout
|
||||
const { creatorPage, inviteLink } = asMobile;
|
||||
|
||||
// ========
|
||||
// ACT: The other user use the invite link to join the call as a guest
|
||||
// ========
|
||||
const guestInviteeContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
|
||||
|
||||
await guestPage
|
||||
.getByRole("button", { name: "Continue in browser" })
|
||||
.click();
|
||||
|
||||
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await guestPage.getByTestId("joincall_joincall").click();
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// ========
|
||||
// ASSERT: check that there are two members in the call
|
||||
// ========
|
||||
|
||||
// There should be two participants now
|
||||
await expect(
|
||||
guestPage.getByTestId("roomHeader_participants_count"),
|
||||
).toContainText("2");
|
||||
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
|
||||
|
||||
// Same in creator page
|
||||
await expect(
|
||||
creatorPage.getByTestId("roomHeader_participants_count"),
|
||||
).toContainText("2");
|
||||
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
|
||||
|
||||
// TEST: control audio devices from the invitee page
|
||||
|
||||
await guestPage.evaluate(() => {
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||
{ id: "headphones", name: "Headphones" },
|
||||
]);
|
||||
window.controls.setAudioDevice("earpiece");
|
||||
});
|
||||
await expect(
|
||||
guestPage.getByRole("heading", { name: "Handset Mode" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
guestPage.getByRole("button", { name: "Back to Speaker Mode" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Should auto-mute the video when earpiece is selected
|
||||
await expect(guestPage.getByTestId("incall_videomute")).toBeDisabled();
|
||||
},
|
||||
);
|
||||
@@ -68,6 +68,11 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestCPage = await guestC.newPage();
|
||||
let sfuGetCallCount = 0;
|
||||
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
|
||||
sfuGetCallCount++;
|
||||
await route.continue();
|
||||
});
|
||||
// Track WebSocket connections
|
||||
let wsConnectionCount = 0;
|
||||
await guestCPage.routeWebSocket("**", (ws) => {
|
||||
@@ -95,4 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
// https://github.com/element-hq/element-call/issues/3344
|
||||
// The app used to request a new jwt token then to reconnect to the SFU
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
|
||||
});
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
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,
|
||||
type Page,
|
||||
test,
|
||||
type Request,
|
||||
type Browser,
|
||||
} from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
let androlHasSentStickyEvent = false;
|
||||
|
||||
await interceptEventSend(
|
||||
page,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
(req) => {
|
||||
androlHasSentStickyEvent =
|
||||
androlHasSentStickyEvent || isStickySend(req.url());
|
||||
},
|
||||
);
|
||||
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// Other
|
||||
const guestInviteeContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
await interceptEventSend(
|
||||
guestPage,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
(req) => {
|
||||
pevaraHasSentStickyEvent =
|
||||
pevaraHasSentStickyEvent || isStickySend(req.url());
|
||||
},
|
||||
);
|
||||
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
inviteLink,
|
||||
"Pevara",
|
||||
"2_0",
|
||||
);
|
||||
// Assert both sides have sent sticky membership events
|
||||
expect(androlHasSentStickyEvent).toEqual(true);
|
||||
expect(pevaraHasSentStickyEvent).toEqual(true);
|
||||
|
||||
return { guestPage };
|
||||
}
|
||||
|
||||
test("One to One call using matrix rtc 2.0 aka sticky events", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
});
|
||||
|
||||
// This issue occurs when a member leave but does not clean up their sticky event.
|
||||
// If they rejoin they will use a new stickye key (stickyKey = member.id = UUID())
|
||||
// We end up with two memberships with the same user and device id. This previously
|
||||
// was a impossible case since that would be the same state event. Now its possible.
|
||||
// We need to ALWAYS key by userId, deviceId and member.id. This test checks that.
|
||||
test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
await guestPage.reload();
|
||||
await expect(guestPage.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Check if rejoining with the same browser context (device) breaks EC.
|
||||
// This has happened on versions that do not consider the member.id as part of the key for a media tile.
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3);
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
|
||||
});
|
||||
|
||||
function isStickySend(url: string): boolean {
|
||||
return !!new URL(url).searchParams.get(
|
||||
"org.matrix.msc4354.sticky_duration_ms",
|
||||
);
|
||||
}
|
||||
|
||||
async function interceptEventSend(
|
||||
page: Page,
|
||||
eventType: string,
|
||||
callback: (request: Request) => void,
|
||||
): Promise<void> {
|
||||
await page.route(
|
||||
`**/_matrix/client/v3/rooms/**/send/${eventType}/**`,
|
||||
async (route, req) => {
|
||||
callback(req);
|
||||
return route.continue();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
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, type Page } from "@playwright/test";
|
||||
|
||||
import { type RtcMode } from "./widget/test-helpers.ts";
|
||||
|
||||
/**
|
||||
* Create and join a call from the SPA home page.
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
* @param userName - The display name to use for the call
|
||||
* @param callName - The name of the call to create
|
||||
* @param autoJoin - Whether to automatically join the call after creating it
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function createCall(
|
||||
page: Page,
|
||||
userName: string,
|
||||
callName: string,
|
||||
autoJoin: boolean = false,
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill(callName);
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill(userName);
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
await expect(page.locator("video")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
if (autoJoin) {
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the invite link for the current call.
|
||||
*/
|
||||
async function getCallInviteLink(page: Page): Promise<string> {
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await page.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await page.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
return inviteLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a call from an invitation link.
|
||||
* @param page - The Playwright page object
|
||||
* @param inviteLink - The invite link to join
|
||||
* @param displayName - The display name to use when joining the call
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function joinCallFromInviteLink(
|
||||
page: Page,
|
||||
inviteLink: string,
|
||||
displayName: string = "Invitee",
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.goto(inviteLink);
|
||||
await page.getByTestId("joincall_displayName").fill(displayName);
|
||||
await expect(page.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await page.getByTestId("joincall_joincall").click();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.getByRole("radio", { name: "Spotlight" }).check();
|
||||
}
|
||||
|
||||
async function setRtcModeFromSettings(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Preferences" }).click();
|
||||
await page.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
|
||||
|
||||
// Move to Developer tab now
|
||||
await page.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await page.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
await page.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
await page.getByText("Compatibility: state events").click();
|
||||
}
|
||||
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await page.locator("video").all();
|
||||
expect(videoElements.length).toBe(count);
|
||||
|
||||
const blockDisplayCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(count);
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers";
|
||||
|
||||
const modePairs: [RtcMode, RtcMode][] = [
|
||||
["compat", "compat"],
|
||||
["legacy", "legacy"],
|
||||
["legacy", "compat"],
|
||||
["compat", "legacy"],
|
||||
];
|
||||
|
||||
modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
widgetTest(
|
||||
`Test federated call with rtc modes ${rtcMode1} and ${rtcMode2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
rtcMode1,
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
rtcMode2,
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
// timo joins
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
}
|
||||
|
||||
// await florian.page.pause();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
// ## Issue
|
||||
// This test reproduces an issue with the publisher.
|
||||
// When switching local focus, we need to recreate the publisher.
|
||||
// This failed because of a dead lock in the old publishers destruction.
|
||||
//
|
||||
// There are numerus ways to enforece this situation:
|
||||
// - oldest member swap (manually set the oldest member focus and leave with the prev oldest member)
|
||||
// This almost never happens in the real worls since clients will set their preferredFoci list to what the oldest member is.
|
||||
// - switch from oldest member to multi sfu as the NOT the first joiner + the first joiner is on a different sfu than your preferred sfu.
|
||||
//
|
||||
// This test uses the "switch from oldest member to multi sfu" approach.
|
||||
//
|
||||
// It is a copy of federated-call.test.ts in the `["legacy", "legacy"]` setup,
|
||||
// which once connected will make the second user switch to multi sfu.
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
// timo joins
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
}
|
||||
|
||||
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
|
||||
await timo.page.waitForTimeout(3000);
|
||||
const blockDisplayCount = await timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
},
|
||||
);
|
||||
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // We are registering multiple users here, give it more time
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
const robin = await addUser("Robin", HOST1);
|
||||
const halfshot = await addUser("Halfshot", HOST1);
|
||||
const florian = await addUser("florian", HOST1);
|
||||
|
||||
const roomName = "Group Call Room";
|
||||
await TestHelpers.createRoom(roomName, valere.page, [
|
||||
timo.mxId,
|
||||
robin.mxId,
|
||||
halfshot.mxId,
|
||||
florian.mxId,
|
||||
]);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
// Accept the invite
|
||||
// This isn't super stable to get this as this super generic locator,
|
||||
// but it works for now.
|
||||
await TestHelpers.acceptRoomInvite(roomName, user.page);
|
||||
}
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// No lobby, should start with video on
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// video should be on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
}
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
for (const user of [valere, timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
|
||||
for (const participant of [valere, timo, robin, halfshot, florian]) {
|
||||
// Check the names are correct
|
||||
await expect(frame.getByText(participant.displayName)).toBeVisible();
|
||||
}
|
||||
|
||||
// There is no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(5000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(5);
|
||||
}
|
||||
|
||||
// Quickly test muting one participant to see it reflects and that our asserts works
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const florianMuteButton = florianFrame.getByTestId("incall_videomute");
|
||||
await florianMuteButton.click();
|
||||
// Now the button should indicate we can start video
|
||||
await expect(florianMuteButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Start video$/,
|
||||
);
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
{
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
|
||||
// out of 5 ONLY 4 are visible (display:block) !!
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
expect(blockDisplayCount).toBe(4);
|
||||
}
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
|
||||
const callRoom = "CallRoom";
|
||||
await TestHelpers.createRoom("CallRoom", valere.page);
|
||||
|
||||
await TestHelpers.createRoom("OtherRoom", valere.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
// wait a bit so that the PIP has rendered
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
|
||||
|
||||
// We should see the PIP overlay
|
||||
const iFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
{
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const hangupBtn = iFrame.getByRole("button", { name: "End call" });
|
||||
const audioBtn = iFrame.getByTestId("incall_mute");
|
||||
const videoBtn = iFrame.getByTestId("incall_videomute");
|
||||
await expect(hangupBtn).toBeVisible();
|
||||
await expect(audioBtn).toBeVisible();
|
||||
await expect(videoBtn).toBeVisible();
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
await videoBtn.click();
|
||||
await audioBtn.click();
|
||||
|
||||
// stop hovering on any of the buttons
|
||||
await iFrame.getByTestId("videoTile").hover();
|
||||
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/);
|
||||
}
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
|
||||
const callRoom = "TeamRoom";
|
||||
await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.createRoom("DoubleTask", valere.page);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(callRoom, timo.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
{
|
||||
const frame = timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// check that the video is on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
}
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
|
||||
// We should see the PIP overlay
|
||||
await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible();
|
||||
|
||||
{
|
||||
// wait a bit so that the PIP has rendered the video
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(1);
|
||||
|
||||
const pipVideo = videoElements[0];
|
||||
await expect(pipVideo).toHaveCSS("object-fit", "cover");
|
||||
}
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
/*
|
||||
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 {
|
||||
type Browser,
|
||||
expect,
|
||||
type JSHandle,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
export const HOST1 = "https://app.m.localhost/#/welcome";
|
||||
export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
|
||||
|
||||
export type RtcMode = "legacy" | "compat" | "2_0";
|
||||
|
||||
export class TestHelpers {
|
||||
public static async startCallInCurrentRoom(
|
||||
page: Page,
|
||||
voice: boolean = false,
|
||||
): Promise<void> {
|
||||
const buttonName = voice ? "Voice call" : "Video call";
|
||||
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
|
||||
await page.getByRole("button", { name: buttonName }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Element Call" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
}
|
||||
|
||||
public static async joinCallFromLobby(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall"),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall")
|
||||
.click();
|
||||
}
|
||||
|
||||
public static async joinCallInCurrentRoom(
|
||||
page: Page,
|
||||
audioOnly: boolean = false,
|
||||
): Promise<void> {
|
||||
// This is the header button that notifies about an ongoing call
|
||||
const label = audioOnly ? "Voice call started" : "Video call started";
|
||||
await expect(page.getByText(label)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Join" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user and returns page, clientHandle and mxId.
|
||||
*/
|
||||
public static async registerUser(
|
||||
browser: Browser,
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<{
|
||||
page: Page;
|
||||
clientHandle: JSHandle<MatrixClient>;
|
||||
mxId: string;
|
||||
}> {
|
||||
const userContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
await page.goto(host);
|
||||
await page.getByRole("link", { name: "Create Account" }).click();
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Confirm password" })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||
).toBeVisible({
|
||||
// Increase timeout as registration can be slow :/
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await this.maybeDismissBrowserNotSupportedToast(page);
|
||||
await this.maybeDismissServiceWorkerWarningToast(page);
|
||||
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const mxId = (await clientHandle.evaluate(
|
||||
(cli: MatrixClient) => cli.getUserId(),
|
||||
clientHandle,
|
||||
))!;
|
||||
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
private static async maybeDismissBrowserNotSupportedToast(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const browserUnsupportedToast = page
|
||||
.getByText("Element does not support this browser")
|
||||
.locator("..")
|
||||
.locator("..");
|
||||
|
||||
// Dismiss incompatible browser toast
|
||||
const dismissButton = browserUnsupportedToast.getByRole("button", {
|
||||
name: "Dismiss",
|
||||
});
|
||||
try {
|
||||
await expect(dismissButton).toBeVisible({ timeout: 700 });
|
||||
await dismissButton.click();
|
||||
} catch {
|
||||
// dismissButton not visible, continue as normal
|
||||
}
|
||||
}
|
||||
|
||||
private static async maybeDismissServiceWorkerWarningToast(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const toast = page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByText("Failed to load service worker");
|
||||
|
||||
try {
|
||||
await expect(toast).toBeVisible({ timeout: 700 });
|
||||
await page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByRole("button", { name: "OK" })
|
||||
.click();
|
||||
} catch {
|
||||
// toast not visible, continue as normal
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
andInvite: string[] = [],
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible();
|
||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||
|
||||
// Invite users if any
|
||||
if (andInvite.length > 0) {
|
||||
await page
|
||||
.getByRole("button", { name: "Invite to this room", exact: true })
|
||||
.click();
|
||||
|
||||
const inviteInput = page.getByRole("dialog").getByRole("textbox");
|
||||
for (const mxId of andInvite) {
|
||||
await inviteInput.focus();
|
||||
await inviteInput.fill(mxId);
|
||||
await inviteInput.press("Enter");
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a room invite using the room name.
|
||||
* Locatest the invite in the room list.
|
||||
*
|
||||
*/
|
||||
public static async acceptRoomInvite(
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
|
||||
await page.getByRole("option", { name: roomName }).click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("heading", { name: roomName }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the widget and then goes to the settings to set the RTC mode.
|
||||
* then closes the widget lobby.
|
||||
*
|
||||
* intended to be used before joining!
|
||||
*
|
||||
* WORKS IF A ROOM IS CURRENTLY OPENED IN THE PAGE
|
||||
*/
|
||||
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
}
|
||||
/**
|
||||
* Goes to the settings to set the RTC mode.
|
||||
* then closes the settings modal.
|
||||
*
|
||||
* WORKS IF A ROOM IS CURRENTLY SHOWING THE EC WIDGET
|
||||
*/
|
||||
public static async setEmbeddedElementCallRtcMode(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
await iframe.getByRole("button", { name: "Settings" }).click();
|
||||
await iframe.getByRole("tab", { name: "Preferences" }).click();
|
||||
|
||||
// await iframe.getByText("Developer mode", { exact: true }).click();
|
||||
await iframe.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
|
||||
|
||||
// Move to Developer tab now
|
||||
await iframe.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await iframe.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
await iframe.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
await iframe.getByText("Compatibility: state events").click();
|
||||
}
|
||||
await iframe.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current Element Web app to use the dev Element Call URL.
|
||||
* @param page - The EW page
|
||||
*/
|
||||
public static async setDevToolElementCallDevUrl(page: Page): Promise<void> {
|
||||
if (process.env.USE_DOCKER) {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://call.m.localhost/room",
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
* @param roomName - The name of the room to switch to
|
||||
*/
|
||||
public static async switchToRoomNamed(
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/*
|
||||
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, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest.use({ callType: "dm" });
|
||||
|
||||
widgetTest(
|
||||
"Start a new voice call in DM as widget",
|
||||
async ({ asWidget, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
|
||||
|
||||
await expect(
|
||||
brooks.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const brooksFrame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const whistlerFrame = whistler.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
brooks.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).toBeVisible();
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
widgetTest(
|
||||
"Start a new video call in DM as widget",
|
||||
async ({ asWidget, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
|
||||
await expect(
|
||||
brooks.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const brooksFrame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const whistlerFrame = whistler.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
brooks.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).toBeVisible();
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
widgetTest(
|
||||
"Decline a new video call in DM as widget",
|
||||
async ({ asWidget, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
|
||||
await expect(
|
||||
brooks.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const brooksFrame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Decline" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// The widget should be closed and the timeline should be back on screen
|
||||
await expect(
|
||||
brooks.page.locator('iframe[title="Element Call"]'),
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
brooks.page.getByText("This is the beginning of your"),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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.
|
||||
|
||||
It allows to use matrixRTC in combination with livekit without relying on element call.
|
||||
|
||||
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
||||
|
||||
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
||||
|
||||
## Getting started
|
||||
|
||||
To get started run
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn build:sdk
|
||||
```
|
||||
|
||||
in the repository root.
|
||||
|
||||
It will create a `dist` folder containing the compiled js file.
|
||||
|
||||
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
||||
|
||||
Now you just need to add the widget to element web via:
|
||||
|
||||
```
|
||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
|
||||
To create a widget see the example `index.html` file in this folder. And add it to EW via:
|
||||
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
||||
|
||||
### url parameters
|
||||
|
||||
The url parameters are needed to pass initial data to the widget. They will automatically be used
|
||||
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
|
||||
|
||||
```
|
||||
widgetId = $matrix_widget_id
|
||||
perParticipantE2EE = true
|
||||
userId = $matrix_user_id
|
||||
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||
```
|
||||
|
||||
`parentUrl = // will be inserted automatically`
|
||||
|
||||
Full template use as `<widgetUrl>`:
|
||||
|
||||
```
|
||||
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file contains helper functions and types for the MatrixRTC SDK.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { scan } from "rxjs";
|
||||
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.then(() => {
|
||||
logger.info("sticky MatrixRTCSdk");
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("failed to make sticky MatrixRTCSdk", error);
|
||||
});
|
||||
};
|
||||
export const TEXT_LK_TOPIC = "matrixRTC";
|
||||
/**
|
||||
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
|
||||
*
|
||||
* I think there should be a builtin for this but i did not find it...
|
||||
*/
|
||||
export const currentAndPrev = scan<
|
||||
LivekitRoomItem[],
|
||||
{
|
||||
prev: LivekitRoomItem[];
|
||||
current: LivekitRoomItem[];
|
||||
}
|
||||
>(
|
||||
({ current: lastCurrentVal }, items) => ({
|
||||
prev: lastCurrentVal,
|
||||
current: items,
|
||||
}),
|
||||
{
|
||||
prev: [],
|
||||
current: [],
|
||||
},
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MatrixRTC Widget</title>
|
||||
<meta charset="utf-8" />
|
||||
<script type="module">
|
||||
// TODO use the url where the matrixrtc-sdk.js file from dist is hosted
|
||||
import { createMatrixRTCSdk } from "http://localhost:8123/matrixrtc-sdk.js";
|
||||
|
||||
try {
|
||||
window.matrixRTCSdk = await createMatrixRTCSdk(
|
||||
"com.github.toger5.rtc-application-type", // rtc application type
|
||||
);
|
||||
console.info("createMatrixRTCSdk was created!");
|
||||
} catch (e) {
|
||||
console.error("createMatrixRTCSdk", e);
|
||||
}
|
||||
const sdk = window.matrixRTCSdk;
|
||||
|
||||
console.info("matrixRTCSdk join ", sdk);
|
||||
const connectionState = sdk.join();
|
||||
console.info("matrixRTCSdk joined");
|
||||
|
||||
const div = document.getElementById("data");
|
||||
div.innerHTML = "<h3>Data:</h3>";
|
||||
|
||||
sdk.data$.subscribe((data) => {
|
||||
const child = document.createElement("p");
|
||||
child.innerHTML = JSON.stringify(data);
|
||||
div.appendChild(child);
|
||||
});
|
||||
|
||||
sdk.members$.subscribe((memberObjects) => {
|
||||
const div = document.getElementById("members");
|
||||
div.innerHTML = "<h3>Members:</h3>";
|
||||
|
||||
// Create member list
|
||||
const members = memberObjects.map((member) => member.membership.sender);
|
||||
console.info("members changed", members);
|
||||
for (const m of members) {
|
||||
console.info("member", m);
|
||||
const child = document.createElement("p");
|
||||
child.innerHTML = m;
|
||||
div.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
sdk.connected$.subscribe((connected) => {
|
||||
console.info("connected changed", connected);
|
||||
const div = document.getElementById("connect_status");
|
||||
div.innerHTML = connected ? "Connected" : "Disconnected";
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
style="position: absolute; top: 0; right: 0; background-color: #ffffff10"
|
||||
>
|
||||
<div id="connect_status"></div>
|
||||
<button onclick="window.matrixRTCSdk.leave()">Leave</button>
|
||||
<button onclick="window.matrixRTCSdk.sendData({ prop: 'Hello, world!' })">
|
||||
Send Text
|
||||
</button>
|
||||
<div id="members"></div>
|
||||
<div id="data"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
368
sdk/main.ts
368
sdk/main.ts
@@ -1,368 +0,0 @@
|
||||
/*
|
||||
Copyright 2025-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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
* It takes care of all the tricky bits:
|
||||
* - sending delayed events
|
||||
* - finding the right sfu
|
||||
* - handling the media stream
|
||||
* - sending join/leave state or sticky events
|
||||
* - setting up encryption and scharing keys
|
||||
*/
|
||||
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
type TextStreamReader,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
|
||||
// TODO how can this get fixed? to just be part of `livekit-client`
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
* observe connected$ to track the state.
|
||||
* @returns
|
||||
*/
|
||||
join: () => void;
|
||||
/** @throws on leave errors */
|
||||
leave: () => void;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
members$: Behavior<
|
||||
{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
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 */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
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;
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const { roomId } = getUrlParams();
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
|
||||
// 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 muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
});
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
);
|
||||
logger.info("CallViewModelCreated");
|
||||
|
||||
// create data listener
|
||||
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||
|
||||
const lkTextStreamHandlerFunction = async (
|
||||
reader: TextStreamReader,
|
||||
participantInfo: { identity: string },
|
||||
livekitRoom: LivekitRoom,
|
||||
): Promise<void> => {
|
||||
const info = reader.info;
|
||||
logger.info(
|
||||
`Received text stream from ${participantInfo.identity}\n` +
|
||||
` Topic: ${info.topic}\n` +
|
||||
` Timestamp: ${info.timestamp}\n` +
|
||||
` ID: ${info.id}\n` +
|
||||
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
|
||||
);
|
||||
|
||||
const participants = callViewModel.livekitRoomItems$.value.find(
|
||||
(i) => i.livekitRoom === livekitRoom,
|
||||
)?.participants;
|
||||
if (participants && participants.includes(participantInfo.identity)) {
|
||||
const text = await reader.readAll();
|
||||
logger.info(`Received text: ${text}`);
|
||||
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||
} else {
|
||||
logger.warn(
|
||||
"Received text from unknown participant",
|
||||
participantInfo.identity,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
|
||||
.pipe(
|
||||
tap((beforecurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
|
||||
beforecurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
currentAndPrev,
|
||||
tap((aftercurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
|
||||
aftercurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ prev, current }) => {
|
||||
const prevRooms = prev.map((i) => i.livekitRoom);
|
||||
const currentRooms = current.map((i) => i.livekitRoom);
|
||||
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
|
||||
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
|
||||
addedRooms.forEach((r) => {
|
||||
logger.info(`Registering text stream handler for room `);
|
||||
r.registerTextStreamHandler(
|
||||
TEXT_LK_TOPIC,
|
||||
(reader, participantInfo) =>
|
||||
void lkTextStreamHandlerFunction(reader, participantInfo, r),
|
||||
);
|
||||
});
|
||||
removedRooms.forEach((r) => {
|
||||
logger.info(`Unregistering text stream handler for room `);
|
||||
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
logger.info("Livekit room items subscription completed");
|
||||
for (const item of callViewModel.livekitRoomItems$.value) {
|
||||
logger.info("unregistering room item from room", item.url);
|
||||
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// create sendData function
|
||||
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
|
||||
scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
switchMap((m) => {
|
||||
if (!m)
|
||||
return of((data: string): never => {
|
||||
throw Error("local membership not yet ready.");
|
||||
});
|
||||
return m.participant.value$.pipe(
|
||||
map((p) => {
|
||||
if (p === null) {
|
||||
return (data: string): never => {
|
||||
throw Error("local participant not yet ready to send data.");
|
||||
};
|
||||
} else {
|
||||
return async (data: string): Promise<TextStreamInfo> =>
|
||||
p.sendText(data, { topic: TEXT_LK_TOPIC });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendData = async (data: unknown): Promise<void> => {
|
||||
const dataString = JSON.stringify(data);
|
||||
logger.info("try sending: ", dataString);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
const info = await sendFn.value(dataString);
|
||||
logger.info(`Sent text with stream ID: ${info.id}`);
|
||||
} catch (e) {
|
||||
logger.error("failed sending: ", dataString, e);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||
const leaveResolver = Promise.withResolvers<void>();
|
||||
logger.info("waiting for RTC leave");
|
||||
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
|
||||
logger.info("received RTC join update: ", isJoined);
|
||||
if (!isJoined) leaveResolver.resolve();
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await widget.api
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => logger.error("Failed to send close action", e));
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
void scheduleWidgetCloseOnLeave();
|
||||
});
|
||||
|
||||
logger.info("createMatrixRTCSdk done");
|
||||
|
||||
return {
|
||||
join: (): void => {
|
||||
// first lets try making the widget sticky
|
||||
if (sticky) tryMakeSticky(widget);
|
||||
callViewModel.join();
|
||||
},
|
||||
leave: (): void => {
|
||||
callViewModel.leave();
|
||||
},
|
||||
stop: (): void => {
|
||||
leaveSubs.unsubscribe();
|
||||
livekitRoomItemsSub.unsubscribe();
|
||||
scope.end();
|
||||
},
|
||||
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$,
|
||||
members$: scope.behavior(
|
||||
callViewModel.matrixLivekitMembers$.pipe(
|
||||
switchMap((members) => {
|
||||
const listOfMemberObservables = members.map((member) =>
|
||||
combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
// using shareReplay instead of a Behavior here because the behavior would need
|
||||
// a tricky scope.end() setup.
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
),
|
||||
);
|
||||
return combineLatest(listOfMemberObservables);
|
||||
}),
|
||||
),
|
||||
[],
|
||||
),
|
||||
sendData,
|
||||
sendRoomMessage,
|
||||
};
|
||||
}
|
||||
147
src/@types/dom-mediacapture-transform.d.ts
vendored
147
src/@types/dom-mediacapture-transform.d.ts
vendored
@@ -1,147 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// The contents of this file below the line are copied from
|
||||
// @types/dom-mediacapture-transform, which is inlined here into Element Call so
|
||||
// that we can avoid the package's dependency on @types/dom-webcodecs, which is
|
||||
// broken in TypeScript 5.9.
|
||||
// (https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/74294)
|
||||
// If that issue is resolved, we can remove this file and return to depending on
|
||||
// @types/dom-mediacapture-transform.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// This project is licensed under the MIT license.
|
||||
// Copyrights are respective of each contributor listed at the beginning of each definition file.
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
// In general, these types are only available behind a command line flag or an origin trial in
|
||||
// Chrome 90+.
|
||||
|
||||
// This API depends on WebCodecs.
|
||||
|
||||
// Versioning:
|
||||
// Until the above-mentioned spec is finalized, the major version number is 0. Although not
|
||||
// necessary for version 0, consider incrementing the minor version number for breaking changes.
|
||||
|
||||
// The following modify existing DOM types to allow defining type-safe APIs on audio and video tracks.
|
||||
|
||||
/** Specialize MediaStreamTrack so that we can refer specifically to an audio track. */
|
||||
interface MediaStreamAudioTrack extends MediaStreamTrack {
|
||||
readonly kind: "audio";
|
||||
clone(): MediaStreamAudioTrack;
|
||||
}
|
||||
|
||||
/** Specialize MediaStreamTrack so that we can refer specifically to a video track. */
|
||||
interface MediaStreamVideoTrack extends MediaStreamTrack {
|
||||
readonly kind: "video";
|
||||
clone(): MediaStreamVideoTrack;
|
||||
}
|
||||
|
||||
/** Assert that getAudioTracks and getVideoTracks return the tracks with the appropriate kind. */
|
||||
interface MediaStream {
|
||||
getAudioTracks(): MediaStreamAudioTrack[];
|
||||
getVideoTracks(): MediaStreamVideoTrack[];
|
||||
}
|
||||
|
||||
// The following were originally generated from the spec using
|
||||
// https://github.com/microsoft/TypeScript-DOM-lib-generator, then heavily modified.
|
||||
|
||||
/**
|
||||
* A track sink that is capable of exposing the unencoded frames from the track to a
|
||||
* ReadableStream, and exposes a control channel for signals going in the oppposite direction.
|
||||
*/
|
||||
interface MediaStreamTrackProcessor<T extends AudioData | VideoFrame> {
|
||||
/**
|
||||
* Allows reading the frames flowing through the MediaStreamTrack provided to the constructor.
|
||||
*/
|
||||
readonly readable: ReadableStream<T>;
|
||||
/** Allows sending control signals to the MediaStreamTrack provided to the constructor. */
|
||||
readonly writableControl: WritableStream<MediaStreamTrackSignal>;
|
||||
}
|
||||
|
||||
declare var MediaStreamTrackProcessor: {
|
||||
prototype: MediaStreamTrackProcessor<any>;
|
||||
|
||||
/** Constructor overrides based on the type of track. */
|
||||
new (
|
||||
init: MediaStreamTrackProcessorInit & { track: MediaStreamAudioTrack },
|
||||
): MediaStreamTrackProcessor<AudioData>;
|
||||
new (
|
||||
init: MediaStreamTrackProcessorInit & { track: MediaStreamVideoTrack },
|
||||
): MediaStreamTrackProcessor<VideoFrame>;
|
||||
};
|
||||
|
||||
interface MediaStreamTrackProcessorInit {
|
||||
track: MediaStreamTrack;
|
||||
/**
|
||||
* If media frames are not read from MediaStreamTrackProcessor.readable quickly enough, the
|
||||
* MediaStreamTrackProcessor will internally buffer up to maxBufferSize of the frames produced
|
||||
* by the track. If the internal buffer is full, each time the track produces a new frame, the
|
||||
* oldest frame in the buffer will be dropped and the new frame will be added to the buffer.
|
||||
*/
|
||||
maxBufferSize?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes video frames as input, and emits control signals that result from subsequent processing.
|
||||
*/
|
||||
interface MediaStreamTrackGenerator<
|
||||
T extends AudioData | VideoFrame,
|
||||
> extends MediaStreamTrack {
|
||||
/**
|
||||
* Allows writing media frames to the MediaStreamTrackGenerator, which is itself a
|
||||
* MediaStreamTrack. When a frame is written to writable, the frame’s close() method is
|
||||
* automatically invoked, so that its internal resources are no longer accessible from
|
||||
* JavaScript.
|
||||
*/
|
||||
readonly writable: WritableStream<T>;
|
||||
/**
|
||||
* Allows reading control signals sent from any sinks connected to the
|
||||
* MediaStreamTrackGenerator.
|
||||
*/
|
||||
readonly readableControl: ReadableStream<MediaStreamTrackSignal>;
|
||||
}
|
||||
|
||||
type MediaStreamAudioTrackGenerator = MediaStreamTrackGenerator<AudioData> &
|
||||
MediaStreamAudioTrack;
|
||||
type MediaStreamVideoTrackGenerator = MediaStreamTrackGenerator<VideoFrame> &
|
||||
MediaStreamVideoTrack;
|
||||
|
||||
declare var MediaStreamTrackGenerator: {
|
||||
prototype: MediaStreamTrackGenerator<any>;
|
||||
|
||||
/** Constructor overrides based on the type of track. */
|
||||
new (
|
||||
init: MediaStreamTrackGeneratorInit & {
|
||||
kind: "audio";
|
||||
signalTarget?: MediaStreamAudioTrack | undefined;
|
||||
},
|
||||
): MediaStreamAudioTrackGenerator;
|
||||
new (
|
||||
init: MediaStreamTrackGeneratorInit & {
|
||||
kind: "video";
|
||||
signalTarget?: MediaStreamVideoTrack | undefined;
|
||||
},
|
||||
): MediaStreamVideoTrackGenerator;
|
||||
};
|
||||
|
||||
interface MediaStreamTrackGeneratorInit {
|
||||
kind: MediaStreamTrackGeneratorKind;
|
||||
/**
|
||||
* (Optional) track to which the MediaStreamTrackGenerator will automatically forward control
|
||||
* signals. If signalTarget is provided and signalTarget.kind and kind do not match, the
|
||||
* MediaStreamTrackGenerator’s constructor will raise an exception.
|
||||
*/
|
||||
signalTarget?: MediaStreamTrack | undefined;
|
||||
}
|
||||
|
||||
type MediaStreamTrackGeneratorKind = "audio" | "video";
|
||||
|
||||
type MediaStreamTrackSignalType = "request-frame";
|
||||
|
||||
interface MediaStreamTrackSignal {
|
||||
signalType: MediaStreamTrackSignalType;
|
||||
}
|
||||
@@ -23,6 +23,14 @@ export function useMediaDevices(): MediaDevices {
|
||||
return mediaDevices;
|
||||
}
|
||||
|
||||
export const useIsEarpiece = (): boolean => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const available = useObservableEagerState(devices.audioOutput.available$);
|
||||
if (!audioOutput?.id) return false;
|
||||
return available.get(audioOutput.id)?.type === "earpiece";
|
||||
};
|
||||
|
||||
/**
|
||||
* A convenience hook to get the audio node configuration for the earpiece.
|
||||
* It will check the `useAsEarpiece` of the `audioOutput` device and return
|
||||
|
||||
@@ -19,28 +19,10 @@ import mediaViewStyles from "../src/tile/MediaView.module.css";
|
||||
interface Props {
|
||||
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
focusUrl?: string;
|
||||
rtcBackendIdentity?: string;
|
||||
}
|
||||
|
||||
const extractDomain = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname; // Returns "kdk.cpm"
|
||||
} catch (error) {
|
||||
console.error("Invalid URL:", error);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// This is only used in developer mode for debugging purposes, so we don't need full localization
|
||||
export const RTCConnectionStats: FC<Props> = ({
|
||||
audio,
|
||||
video,
|
||||
focusUrl,
|
||||
rtcBackendIdentity,
|
||||
...rest
|
||||
}) => {
|
||||
export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalContents, setModalContents] = useState<
|
||||
"video" | "audio" | "none"
|
||||
@@ -73,16 +55,6 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
<Text as="span" size="xs" title="rtcBackendIdentity">
|
||||
rtcBackendIdentity:{rtcBackendIdentity}
|
||||
</Text>
|
||||
{focusUrl && (
|
||||
<div>
|
||||
<Text as="span" size="xs" title="focusURL">
|
||||
{extractDomain(focusUrl)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{audio && (
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
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.
|
||||
@@ -257,6 +256,8 @@ describe("UrlParams", () => {
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
defaultAudioEnabled: true,
|
||||
defaultVideoEnabled: true,
|
||||
});
|
||||
it("use no-intent-defaults with unknown intent", () => {
|
||||
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
|
||||
@@ -331,42 +332,6 @@ describe("UrlParams", () => {
|
||||
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("noiseSuppression", () => {
|
||||
it("defaults to true", () => {
|
||||
expect(computeUrlParams().noiseSuppression).toBe(true);
|
||||
});
|
||||
|
||||
it("is parsed", () => {
|
||||
expect(
|
||||
computeUrlParams("?intent=start_call&noiseSuppression=true")
|
||||
.noiseSuppression,
|
||||
).toBe(true);
|
||||
expect(
|
||||
computeUrlParams("?intent=start_call&noiseSuppression&bar=foo")
|
||||
.noiseSuppression,
|
||||
).toBe(true);
|
||||
expect(computeUrlParams("?noiseSuppression=false").noiseSuppression).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("echoCancellation", () => {
|
||||
it("defaults to true", () => {
|
||||
expect(computeUrlParams().echoCancellation).toBe(true);
|
||||
});
|
||||
|
||||
it("is parsed", () => {
|
||||
expect(computeUrlParams("?echoCancellation=true").echoCancellation).toBe(
|
||||
true,
|
||||
);
|
||||
expect(computeUrlParams("?echoCancellation=false").echoCancellation).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
it("uses header if provided", () => {
|
||||
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||
@@ -394,6 +359,8 @@ describe("UrlParams", () => {
|
||||
expect.any(Object),
|
||||
"configuration:",
|
||||
expect.any(Object),
|
||||
"intentAndPlatformDerivedConfiguration:",
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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.
|
||||
@@ -234,26 +233,26 @@ export interface UrlConfiguration {
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
interface IntentAndPlatformDerivedConfiguration {
|
||||
defaultAudioEnabled?: boolean;
|
||||
defaultVideoEnabled?: boolean;
|
||||
}
|
||||
interface IntentAndPlatformDerivedConfiguration {
|
||||
defaultAudioEnabled?: boolean;
|
||||
defaultVideoEnabled?: boolean;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||
export interface UrlParams
|
||||
extends UrlProperties,
|
||||
UrlConfiguration,
|
||||
IntentAndPlatformDerivedConfiguration {}
|
||||
|
||||
// This is here as a stopgap, but what would be far nicer is a function that
|
||||
// takes a UrlParams and returns a query string. That would enable us to
|
||||
@@ -453,6 +452,29 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
};
|
||||
}
|
||||
|
||||
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
|
||||
{};
|
||||
// Desktop also includes web. Its anything that is not mobile.
|
||||
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
|
||||
switch (desktopMobile) {
|
||||
case "desktop":
|
||||
case "mobile":
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
case UserIntent.JoinExistingCall:
|
||||
case UserIntent.StartNewCallDM:
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
|
||||
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
|
||||
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
parentUrl,
|
||||
@@ -473,7 +495,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
posthogApiKey: parser.getParam("posthogApiKey"),
|
||||
posthogUserId: parser.getParam("posthogUserId"),
|
||||
posthogUserId:
|
||||
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
@@ -502,8 +525,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
]),
|
||||
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
||||
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||
noiseSuppression: parser.getFlagParam("noiseSuppression", true),
|
||||
echoCancellation: parser.getFlagParam("echoCancellation", true),
|
||||
};
|
||||
|
||||
// Log the final configuration for debugging purposes.
|
||||
@@ -516,12 +537,15 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
properties,
|
||||
"configuration:",
|
||||
configuration,
|
||||
"intentAndPlatformDerivedConfiguration:",
|
||||
intentAndPlatformDerivedConfiguration,
|
||||
);
|
||||
|
||||
return {
|
||||
...properties,
|
||||
...intentPreset,
|
||||
...pickBy(configuration, (v?: unknown) => v !== undefined),
|
||||
...intentAndPlatformDerivedConfiguration,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ exports[`AppBar > renders 1`] = `
|
||||
class="nav leftNav"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_0_"
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
@@ -21,7 +21,7 @@ exports[`AppBar > renders 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`the content is rendered when the modal is open 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-_r_4_"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate modal dialog _glass_sepwu_8"
|
||||
data-state="open"
|
||||
id="radix-_r_3_"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
id="radix-_r_4_"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
My modal
|
||||
</h2>
|
||||
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
|
||||
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-_r_a_"
|
||||
aria-labelledby="radix-«ra»"
|
||||
class="overlay modal drawer"
|
||||
data-state="open"
|
||||
data-vaul-animate="true"
|
||||
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
data-vaul-drawer=""
|
||||
data-vaul-drawer-direction="bottom"
|
||||
data-vaul-snap-points="false"
|
||||
id="radix-_r_9_"
|
||||
id="radix-«r9»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
class="handle"
|
||||
/>
|
||||
<h2
|
||||
id="radix-_r_a_"
|
||||
id="radix-«ra»"
|
||||
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
|
||||
>
|
||||
My modal
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-_r_4_"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate toast"
|
||||
data-state="open"
|
||||
id="radix-_r_3_"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
|
||||
>
|
||||
<h3
|
||||
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
||||
id="radix-_r_4_"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
Hello world!
|
||||
</h3>
|
||||
|
||||
@@ -247,8 +247,9 @@ export class PosthogAnalytics {
|
||||
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
||||
// until the next time account data is refreshed and this function is called (most likely on next
|
||||
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
||||
analyticsID = analyticsIdGenerator();
|
||||
await this.setAccountAnalyticsId(analyticsID);
|
||||
const accountDataAnalyticsId = analyticsIdGenerator();
|
||||
await this.setAccountAnalyticsId(accountDataAnalyticsId);
|
||||
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
|
||||
}
|
||||
} catch (e) {
|
||||
// The above could fail due to network requests, but not essential to starting the application,
|
||||
@@ -269,14 +270,37 @@ export class PosthogAnalytics {
|
||||
|
||||
private async getAnalyticsId(): Promise<string | null> {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
let accountAnalyticsId: string | null;
|
||||
if (widget) {
|
||||
return getUrlParams().posthogUserId;
|
||||
accountAnalyticsId = getUrlParams().posthogUserId;
|
||||
} else {
|
||||
const accountData = await client.getAccountDataFromServer(
|
||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||
);
|
||||
return accountData?.id ?? null;
|
||||
accountAnalyticsId = accountData?.id ?? null;
|
||||
}
|
||||
if (accountAnalyticsId) {
|
||||
// we dont just use the element web analytics ID because that would allow to associate
|
||||
// users between the two posthog instances. By using a hash from the username and the element web analytics id
|
||||
// it is not possible to conclude the element web posthog user id from the element call user id and vice versa.
|
||||
return await this.hashedEcAnalyticsId(accountAnalyticsId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async hashedEcAnalyticsId(
|
||||
accountAnalyticsId: string,
|
||||
): Promise<string> {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
|
||||
const bufferForPosthogId = await crypto.subtle.digest(
|
||||
"sha-256",
|
||||
new TextEncoder().encode(posthogIdMaterial),
|
||||
);
|
||||
const view = new Int32Array(bufferForPosthogId);
|
||||
return Array.from(view)
|
||||
.map((b) => Math.abs(b).toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||
|
||||
157
src/analytics/PosthogSpanProcessor.ts
Normal file
157
src/analytics/PosthogSpanProcessor.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type SpanProcessor,
|
||||
type ReadableSpan,
|
||||
type Span,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { hrTimeToMilliseconds } from "@opentelemetry/core";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
|
||||
interface PrevCall {
|
||||
callId: string;
|
||||
hangupTs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum time between hanging up and joining the same call that we would
|
||||
* consider a 'rejoin' on the user's part.
|
||||
*/
|
||||
const maxRejoinMs = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Span processor that extracts certain metrics from spans to send to PostHog
|
||||
*/
|
||||
export class PosthogSpanProcessor implements SpanProcessor {
|
||||
public async forceFlush(): Promise<void> {}
|
||||
|
||||
public onStart(span: Span): void {
|
||||
// Hack: Yield to allow attributes to be set before processing
|
||||
try {
|
||||
switch (span.name) {
|
||||
case "matrix.groupCallMembership":
|
||||
this.onGroupCallMembershipStart(span);
|
||||
return;
|
||||
case "matrix.groupCallMembership.summaryReport":
|
||||
this.onSummaryReportStart(span);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// log to avoid tripping @typescript-eslint/no-unused-vars
|
||||
logger.debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
public onEnd(span: ReadableSpan): void {
|
||||
switch (span.name) {
|
||||
case "matrix.groupCallMembership":
|
||||
this.onGroupCallMembershipEnd(span);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private get prevCall(): PrevCall | null {
|
||||
// This is stored in localStorage so we can remember the previous call
|
||||
// across app restarts
|
||||
const data = localStorage.getItem("matrix-prev-call");
|
||||
if (data === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
logger.warn("Invalid prev call data", data, "error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private set prevCall(data: PrevCall | null) {
|
||||
localStorage.setItem("matrix-prev-call", JSON.stringify(data));
|
||||
}
|
||||
|
||||
private onGroupCallMembershipStart(span: ReadableSpan): void {
|
||||
const prevCall = this.prevCall;
|
||||
const newCallId = span.attributes["matrix.confId"] as string;
|
||||
|
||||
// If the user joined the same call within a short time frame, log this as a
|
||||
// rejoin. This is interesting as a call quality metric, since rejoins may
|
||||
// indicate that users had to intervene to make the product work.
|
||||
if (prevCall !== null && newCallId === prevCall.callId) {
|
||||
const duration = hrTimeToMilliseconds(span.startTime) - prevCall.hangupTs;
|
||||
if (duration <= maxRejoinMs) {
|
||||
PosthogAnalytics.instance.trackEvent({
|
||||
eventName: "Rejoin",
|
||||
callId: prevCall.callId,
|
||||
rejoinDuration: duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onGroupCallMembershipEnd(span: ReadableSpan): void {
|
||||
this.prevCall = {
|
||||
callId: span.attributes["matrix.confId"] as string,
|
||||
hangupTs: hrTimeToMilliseconds(span.endTime),
|
||||
};
|
||||
}
|
||||
|
||||
private onSummaryReportStart(span: ReadableSpan): void {
|
||||
// Searching for an event like this:
|
||||
// matrix.stats.summary
|
||||
// matrix.stats.summary.percentageReceivedAudioMedia: 0.75
|
||||
// matrix.stats.summary.percentageReceivedMedia: 1
|
||||
// matrix.stats.summary.percentageReceivedVideoMedia: 0.75
|
||||
// matrix.stats.summary.maxJitter: 100
|
||||
// matrix.stats.summary.maxPacketLoss: 20
|
||||
const event = span.events.find((e) => e.name === "matrix.stats.summary");
|
||||
if (event !== undefined) {
|
||||
const attributes = event.attributes;
|
||||
if (attributes) {
|
||||
const mediaReceived = `${attributes["matrix.stats.summary.percentageReceivedMedia"]}`;
|
||||
const videoReceived = `${attributes["matrix.stats.summary.percentageReceivedVideoMedia"]}`;
|
||||
const audioReceived = `${attributes["matrix.stats.summary.percentageReceivedAudioMedia"]}`;
|
||||
const maxJitter = `${attributes["matrix.stats.summary.maxJitter"]}`;
|
||||
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
|
||||
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
|
||||
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
|
||||
const opponentUsersInCall = `${attributes["matrix.stats.summary.opponentUsersInCall"]}`;
|
||||
const opponentDevicesInCall = `${attributes["matrix.stats.summary.opponentDevicesInCall"]}`;
|
||||
const diffDevicesToPeerConnections = `${attributes["matrix.stats.summary.diffDevicesToPeerConnections"]}`;
|
||||
const ratioPeerConnectionToDevices = `${attributes["matrix.stats.summary.ratioPeerConnectionToDevices"]}`;
|
||||
|
||||
PosthogAnalytics.instance.trackEvent(
|
||||
{
|
||||
eventName: "MediaReceived",
|
||||
callId: span.attributes["matrix.confId"] as string,
|
||||
mediaReceived: mediaReceived,
|
||||
audioReceived: audioReceived,
|
||||
videoReceived: videoReceived,
|
||||
maxJitter: maxJitter,
|
||||
maxPacketLoss: maxPacketLoss,
|
||||
peerConnections: peerConnections,
|
||||
percentageConcealedAudio: percentageConcealedAudio,
|
||||
opponentUsersInCall: opponentUsersInCall,
|
||||
opponentDevicesInCall: opponentDevicesInCall,
|
||||
diffDevicesToPeerConnections: diffDevicesToPeerConnections,
|
||||
ratioPeerConnectionToDevices: ratioPeerConnectionToDevices,
|
||||
},
|
||||
// Send instantly because the window might be closing
|
||||
{ send_instantly: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the processor.
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
135
src/analytics/RageshakeSpanProcessor.ts
Normal file
135
src/analytics/RageshakeSpanProcessor.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type AttributeValue, type Attributes } from "@opentelemetry/api";
|
||||
import { hrTimeToMicroseconds } from "@opentelemetry/core";
|
||||
import {
|
||||
type SpanProcessor,
|
||||
type ReadableSpan,
|
||||
type Span,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
|
||||
const dumpAttributes = (
|
||||
attr: Attributes,
|
||||
): {
|
||||
key: string;
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "bigint"
|
||||
| "boolean"
|
||||
| "symbol"
|
||||
| "undefined"
|
||||
| "object"
|
||||
| "function";
|
||||
value: AttributeValue | undefined;
|
||||
}[] =>
|
||||
Object.entries(attr).map(([key, value]) => ({
|
||||
key,
|
||||
type: typeof value,
|
||||
value,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Exports spans on demand to the Jaeger JSON format, which can be attached to
|
||||
* rageshakes and loaded into analysis tools like Jaeger and Stalk.
|
||||
*/
|
||||
export class RageshakeSpanProcessor implements SpanProcessor {
|
||||
private readonly spans: ReadableSpan[] = [];
|
||||
|
||||
public async forceFlush(): Promise<void> {}
|
||||
|
||||
public onStart(span: Span): void {
|
||||
this.spans.push(span);
|
||||
}
|
||||
|
||||
public onEnd(): void {}
|
||||
|
||||
/**
|
||||
* Dumps the spans collected so far as Jaeger-compatible JSON.
|
||||
*/
|
||||
public dump(): string {
|
||||
const now = Date.now() * 1000; // Jaeger works in microseconds
|
||||
const traces = new Map<string, ReadableSpan[]>();
|
||||
|
||||
// Organize spans by their trace IDs
|
||||
for (const span of this.spans) {
|
||||
const traceId = span.spanContext().traceId;
|
||||
let trace = traces.get(traceId);
|
||||
|
||||
if (trace === undefined) {
|
||||
trace = [];
|
||||
traces.set(traceId, trace);
|
||||
}
|
||||
|
||||
trace.push(span);
|
||||
}
|
||||
|
||||
const processId = "p1";
|
||||
const processes = {
|
||||
[processId]: {
|
||||
serviceName: "element-call",
|
||||
tags: [],
|
||||
},
|
||||
warnings: null,
|
||||
};
|
||||
|
||||
return JSON.stringify({
|
||||
// Honestly not sure what some of these fields mean, I just know that
|
||||
// they're present in Jaeger JSON exports
|
||||
total: 0,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
errors: null,
|
||||
data: [...traces.entries()].map(([traceId, spans]) => ({
|
||||
traceID: traceId,
|
||||
warnings: null,
|
||||
processes,
|
||||
spans: spans.map((span) => {
|
||||
const ctx = span.spanContext();
|
||||
const startTime = hrTimeToMicroseconds(span.startTime);
|
||||
// If the span has not yet ended, pretend that it ends now
|
||||
const duration =
|
||||
span.duration[0] === -1
|
||||
? now - startTime
|
||||
: hrTimeToMicroseconds(span.duration);
|
||||
|
||||
return {
|
||||
traceID: traceId,
|
||||
spanID: ctx.spanId,
|
||||
operationName: span.name,
|
||||
processID: processId,
|
||||
warnings: null,
|
||||
startTime,
|
||||
duration,
|
||||
references:
|
||||
span.parentSpanContext?.spanId === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
refType: "CHILD_OF",
|
||||
traceID: traceId,
|
||||
spanID: span.parentSpanContext?.spanId,
|
||||
},
|
||||
],
|
||||
tags: dumpAttributes(span.attributes),
|
||||
logs: span.events.map((event) => ({
|
||||
timestamp: hrTimeToMicroseconds(event.time),
|
||||
// The name of the event is in the "event" field, aparently.
|
||||
fields: [
|
||||
...dumpAttributes(event.attributes ?? {}),
|
||||
{ key: "event", type: "string", value: event.name },
|
||||
],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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.
|
||||
@@ -23,7 +22,6 @@ import styles from "./Button.module.css";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
@@ -37,7 +35,6 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
{...props}
|
||||
@@ -48,7 +45,6 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
@@ -62,7 +58,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
{...props}
|
||||
@@ -73,7 +68,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -97,11 +91,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
@@ -112,7 +102,6 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
aria-label={t("hangup_button_label")}
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
@@ -121,10 +110,9 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
|
||||
props,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,23 +6,21 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { type ReactNode } from "react";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ReactionToggleButton } from "./ReactionToggleButton";
|
||||
import { ElementCallReactionEventType } from "../reactions";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local, localRtcMember } from "../utils/test-fixtures";
|
||||
import { type MockRTCSession } from "../utils/test";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
|
||||
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
@@ -35,7 +33,7 @@ function TestComponent({
|
||||
<TooltipProvider>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
||||
</ReactionsSenderProvider>
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
ReactionsRowSize,
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
@@ -166,7 +166,6 @@ export function ReactionPopupMenu({
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
identifier: string;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
|
||||
@@ -9,8 +9,8 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
aria-labelledby="«rbb»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -43,8 +43,8 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_7m_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
aria-labelledby="«r7m»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -74,8 +74,8 @@ exports[`Can lower hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_36_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
aria-labelledby="«r36»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -108,8 +108,8 @@ exports[`Can open menu 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_0_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
aria-labelledby="«r0»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -139,8 +139,8 @@ exports[`Can raise hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_1j_"
|
||||
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
aria-labelledby="«r1j»"
|
||||
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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.
|
||||
@@ -123,7 +122,7 @@ export interface ConfigOptions {
|
||||
delayed_leave_event_delay_ms?: number;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which we consider a delayed event restart http request to have failed.
|
||||
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
|
||||
*
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
@@ -164,6 +163,10 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
};
|
||||
};
|
||||
ssla: string;
|
||||
media_devices: {
|
||||
enable_audio: boolean;
|
||||
enable_video: boolean;
|
||||
};
|
||||
app_prompt: boolean;
|
||||
}
|
||||
|
||||
@@ -178,5 +181,9 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
media_devices: {
|
||||
enable_audio: true,
|
||||
enable_video: true,
|
||||
},
|
||||
app_prompt: true,
|
||||
};
|
||||
|
||||
@@ -6,13 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BaseKeyProvider } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
const logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
|
||||
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
private rtcSession?: MatrixRTCSession;
|
||||
@@ -42,10 +40,9 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
}
|
||||
|
||||
private onEncryptionKeyChanged = (
|
||||
encryptionKey: Uint8Array<ArrayBuffer>,
|
||||
encryptionKey: Uint8Array,
|
||||
encryptionKeyIndex: number,
|
||||
membershipParts: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
participantId: string,
|
||||
): void => {
|
||||
crypto.subtle
|
||||
.importKey("raw", encryptionKey, "HKDF", false, [
|
||||
@@ -56,17 +53,17 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
(keyMaterial) => {
|
||||
this.onSetEncryptionKey(
|
||||
keyMaterial,
|
||||
rtcBackendIdentity,
|
||||
participantId,
|
||||
encryptionKeyIndex,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -34,8 +34,8 @@ const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||
`room-shared-key-${roomId}`;
|
||||
|
||||
/**
|
||||
* An up-to-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
||||
* @param roomId The room ID we want the shared key for.
|
||||
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
||||
* @param roomId
|
||||
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
|
||||
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
|
||||
*/
|
||||
|
||||
@@ -200,11 +200,8 @@ interface Drag {
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
interface LayoutMemoProps<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
R extends HTMLElement,
|
||||
> extends LayoutProps<LayoutModel, TileModel, R> {
|
||||
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
|
||||
extends LayoutProps<LayoutModel, TileModel, R> {
|
||||
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
@@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
id={model.spotlight.id}
|
||||
model={model.spotlight}
|
||||
id={model.remote.id}
|
||||
model={model.remote}
|
||||
className={styles.container}
|
||||
style={{ width: tileWidth, height: tileHeight }}
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
id={model.local.id}
|
||||
model={model.local}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
@@ -27,13 +27,7 @@ interface Props<M, R extends HTMLElement> {
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||
) => void
|
||||
> | null;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
model: M;
|
||||
Tile: ComponentType<TileProps<M, R>>;
|
||||
|
||||
@@ -113,49 +113,19 @@ const roomIsJoinable = (room: Room): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a given room has call events in it, and therefore
|
||||
* is likely to be a call room.
|
||||
* @param room The Matrix room instance.
|
||||
* @returns `true` if the room has call events.
|
||||
*/
|
||||
const roomHasCallMembershipEvents = (room: Room): boolean => {
|
||||
// Check our room membership first, to rule out any rooms
|
||||
// we can't have a call in.
|
||||
const myMembership = room.getMyMembership();
|
||||
if (myMembership === KnownMembership.Knock) {
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
} else if (myMembership !== KnownMembership.Join) {
|
||||
// Otherwise, non-joined rooms should never show up.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy member state checks (cheaper to check.)
|
||||
const timeline = room.getLiveTimeline();
|
||||
if (
|
||||
timeline
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.has(EventType.GroupCallMemberPrefix)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for *active* calls using sticky events.
|
||||
for (const sticky of room._unstable_getStickyEvents()) {
|
||||
if (sticky.getType() === EventType.RTCMembership) {
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
return !!room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.get(EventType.GroupCallMemberPrefix);
|
||||
case KnownMembership.Knock:
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise, check recent event history to see if anyone had
|
||||
// sent a call membership in here.
|
||||
return timeline.getEvents().some(
|
||||
(e) =>
|
||||
// Membership events only count if both of these are true
|
||||
e.unstableStickyInfo && e.getType() === EventType.GroupCallMemberPrefix,
|
||||
);
|
||||
// Otherwise, it's *unlikely* this room was ever a call.
|
||||
};
|
||||
|
||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
|
||||
@@ -16,7 +16,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";
|
||||
import {
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementCallOpenTelemetry } from "./otel/otel";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -101,6 +101,7 @@ enum LoadState {
|
||||
class DependencyLoadStates {
|
||||
public config: LoadState = LoadState.None;
|
||||
public sentry: LoadState = LoadState.None;
|
||||
public openTelemetry: LoadState = LoadState.None;
|
||||
|
||||
public allDepsAreLoaded(): boolean {
|
||||
return !Object.values(this).some((s) => s !== LoadState.Loaded);
|
||||
@@ -116,15 +117,13 @@ export class Initializer {
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
initializeWidget();
|
||||
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
||||
}
|
||||
|
||||
if (shouldPolyfillDurationFormat()) {
|
||||
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
|
||||
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
|
||||
}
|
||||
|
||||
await Promise.all(polyfills);
|
||||
@@ -267,6 +266,15 @@ export class Initializer {
|
||||
this.loadStates.sentry = LoadState.Loaded;
|
||||
}
|
||||
|
||||
// OpenTelemetry (also only after config loaded)
|
||||
if (
|
||||
this.loadStates.openTelemetry === LoadState.None &&
|
||||
this.loadStates.config === LoadState.Loaded
|
||||
) {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
this.loadStates.openTelemetry = LoadState.Loaded;
|
||||
}
|
||||
|
||||
if (this.loadStates.allDepsAreLoaded()) {
|
||||
// resolve if there is no dependency that is not loaded
|
||||
resolve();
|
||||
|
||||
@@ -6,37 +6,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
getTrackReferenceId,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
type Participant,
|
||||
type RemoteAudioTrack,
|
||||
type Room,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { type RemoteAudioTrack } from "livekit-client";
|
||||
import { type ReactNode } from "react";
|
||||
import { useTracks } from "@livekit/components-react";
|
||||
|
||||
import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import { mockMediaDevices, mockTrack } from "../utils/test";
|
||||
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
@@ -63,203 +48,42 @@ vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
let tracks: TrackReference[] = [];
|
||||
|
||||
/**
|
||||
* Render the test component with given rtc members and livekit participant identities.
|
||||
*
|
||||
* It is possible to have rtc members that are not in livekit (e.g. not yet joined) and vice versa.
|
||||
*
|
||||
* @param rtcMembers - Array of active rtc members with userId and deviceId.
|
||||
* @param livekitParticipantIdentities - Array of livekit participant (that are publishing).
|
||||
* @param explicitTracks - Array of tracks available in livekit, if not provided, one audio track per livekitParticipantIdentities will be created.
|
||||
* */
|
||||
|
||||
function renderTestComponent(
|
||||
rtcMembers: { userId: string; deviceId: string }[],
|
||||
livekitParticipantIdentities: string[],
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[],
|
||||
): RenderResult {
|
||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||
mockRemoteParticipant({ identity }),
|
||||
);
|
||||
const participants = rtcMembers.flatMap(({ userId, deviceId }) => {
|
||||
const p = liveKitParticipants.find(
|
||||
(p) => p.identity === `${userId}:${deviceId}`,
|
||||
);
|
||||
return p === undefined ? [] : [p];
|
||||
});
|
||||
const livekitRoom = {
|
||||
remoteParticipants: new Map<string, Participant>(
|
||||
liveKitParticipants.map((p) => [p.identity, p]),
|
||||
),
|
||||
} as unknown as Room;
|
||||
|
||||
if (explicitTracks?.length ?? 0 > 0) {
|
||||
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
|
||||
const participant =
|
||||
liveKitParticipants.find((p) => p.identity === participantId) ??
|
||||
mockRemoteParticipant({ identity: participantId });
|
||||
return mockTrack(participant, kind, source);
|
||||
});
|
||||
} else {
|
||||
tracks = participants.map((p) => mockTrack(p));
|
||||
}
|
||||
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
return render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={participants.map((p) => p.identity)}
|
||||
livekitRoom={livekitRoom}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
}
|
||||
const tracks = [mockTrack("test:123")];
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
|
||||
it("should render for member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
const memberships = [
|
||||
{ sender: "othermember", deviceId: "123" },
|
||||
] as CallMembership[];
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer members={memberships} />
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
});
|
||||
|
||||
const TEST_CASES: {
|
||||
name: string;
|
||||
rtcUsers: { userId: string; deviceId: string }[];
|
||||
livekitParticipantIdentities: string[];
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[];
|
||||
expectedAudioTracks: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "single user single device",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@alice", deviceId: "DEV1" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@alice:DEV1"],
|
||||
expectedAudioTracks: 3,
|
||||
},
|
||||
// Charlie is a rtc member but not in livekit
|
||||
{
|
||||
name: "Charlie is rtc member but not in livekit",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
{ userId: "@charlie", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
// Charlie is in livekit but not rtc member
|
||||
{
|
||||
name: "Charlie is in livekit but not rtc member",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@charlie:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
{
|
||||
name: "no audio track, only video track",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Video,
|
||||
source: Track.Source.Camera,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "Audio track from unknown source",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Unknown,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 1,
|
||||
},
|
||||
{
|
||||
name: "Audio track from other device",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV1",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "two audio tracks, microphone and screenshare",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(TEST_CASES)(
|
||||
`should render sound test cases $name`,
|
||||
({
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
expectedAudioTracks,
|
||||
}) => {
|
||||
const { queryAllByTestId } = renderTestComponent(
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(expectedAudioTracks);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
|
||||
@@ -276,8 +100,13 @@ it("should setup audioContext gain and pan", () => {
|
||||
pan: 1,
|
||||
volume: 0.1,
|
||||
});
|
||||
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalled();
|
||||
|
||||
@@ -6,14 +6,14 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
@@ -22,16 +22,11 @@ import * as controls from "../controls";
|
||||
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The service URL of the LiveKit room.
|
||||
*/
|
||||
url: string;
|
||||
livekitRoom: LivekitRoom;
|
||||
/**
|
||||
* The list of participant identities to render audio for.
|
||||
* The list of participants to render audio for.
|
||||
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
|
||||
* that are not expected to be in the rtc session (local user is excluded).
|
||||
* that are not expected to be in the rtc session.
|
||||
*/
|
||||
validIdentities: string[];
|
||||
members: CallMembership[];
|
||||
/**
|
||||
* If set to `true`, mutes all audio tracks rendered by the component.
|
||||
* @remarks
|
||||
@@ -40,9 +35,9 @@ export interface MatrixAudioRendererProps {
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
/**
|
||||
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
|
||||
* It takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
* It also takes care of the earpiece audio configuration for iOS devices.
|
||||
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
|
||||
@@ -54,12 +49,35 @@ const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function LivekitRoomAudioRenderer({
|
||||
url,
|
||||
livekitRoom,
|
||||
validIdentities,
|
||||
export function MatrixAudioRenderer({
|
||||
members,
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const validIdentities = useMemo(
|
||||
() =>
|
||||
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)),
|
||||
[members],
|
||||
);
|
||||
|
||||
const loggedInvalidIdentities = useRef(new Set<string>());
|
||||
|
||||
/**
|
||||
* Log an invalid livekit track identity.
|
||||
* A invalid identity is one that does not match any of the matrix rtc members.
|
||||
*
|
||||
* @param identity The identity of the track that is invalid
|
||||
* @param validIdentities The list of valid identities
|
||||
*/
|
||||
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
|
||||
if (loggedInvalidIdentities.current.has(identity)) return;
|
||||
logger.warn(
|
||||
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
|
||||
`current members: ${Array.from(validIdentities.values())}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
loggedInvalidIdentities.current.add(identity);
|
||||
};
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -69,26 +87,25 @@ export function LivekitRoomAudioRenderer({
|
||||
{
|
||||
updateOnlyOn: [],
|
||||
onlySubscribed: true,
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
prefixedLogger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
).filter((ref) => {
|
||||
const isValid = validIdentities?.has(ref.participant.identity);
|
||||
if (!isValid && !ref.participant.isLocal)
|
||||
logInvalid(ref.participant.identity, validIdentities);
|
||||
return (
|
||||
!ref.participant.isLocal &&
|
||||
ref.publication.kind === Track.Kind.Audio &&
|
||||
isValid
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
}, [tracks, validIdentities]);
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
// responsible for mimicking earpiece audio on iPhones.
|
||||
@@ -166,11 +183,7 @@ interface StereoPanAudioTrackProps {
|
||||
* It main purpose is to remount the AudioTrack component when switching from
|
||||
* audioContext to normal audio playback.
|
||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||
* @param props The component props
|
||||
* @param props.trackRef The track reference
|
||||
* @param props.muted If the track should be muted
|
||||
* @param props.audioContext The audio context to use
|
||||
* @param props.audioNodes The audio nodes to use
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
function AudioTrackWithAudioNodes({
|
||||
|
||||
@@ -19,22 +19,14 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
//TODO-MULTI-SFU: This is not yet fully there.
|
||||
// it is a combination of exposing observable and react hooks.
|
||||
// preferably we should not make this a context anymore and instead just a vm?
|
||||
|
||||
export type ProcessorState = {
|
||||
type ProcessorState = {
|
||||
supported: boolean | undefined;
|
||||
processor: undefined | ProcessorWrapper<BackgroundOptions>;
|
||||
};
|
||||
@@ -50,43 +42,6 @@ export function useTrackProcessor(): ProcessorState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
const state = use(ProcessorContext);
|
||||
if (state === undefined)
|
||||
throw new Error(
|
||||
"useTrackProcessor must be used within a ProcessorProvider",
|
||||
);
|
||||
const state$ = useObservable(
|
||||
(init$) => init$.pipe(map(([init]) => init)),
|
||||
[state],
|
||||
);
|
||||
|
||||
return state$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates your video tracks to always use the given processor.
|
||||
*/
|
||||
export const trackProcessorSync = (
|
||||
scope: ObservableScope,
|
||||
videoTrack$: Behavior<LocalVideoTrack | null>,
|
||||
processor$: Behavior<ProcessorState>,
|
||||
): void => {
|
||||
combineLatest([videoTrack$, processor$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([videoTrack, processorState]) => {
|
||||
if (!processorState) return;
|
||||
if (!videoTrack) return;
|
||||
const { processor } = processorState;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useTrackProcessorSync = (
|
||||
videoTrack: LocalVideoTrack | null,
|
||||
): void => {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||
import { testJWTToken } from "../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../utils/test";
|
||||
|
||||
const sfuUrl = "https://sfu.example.org";
|
||||
|
||||
describe("getSFUConfigWithOpenID", () => {
|
||||
let matrixClient: MockedObject<OpenIDClientParts>;
|
||||
beforeEach(() => {
|
||||
matrixClient = {
|
||||
getOpenIdToken: vitest.fn(),
|
||||
getDeviceId: vitest.fn(),
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
vitest.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
it("should handle fetching a token", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: sfuUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
);
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: sfuUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
void (await fetchMock.flush());
|
||||
});
|
||||
|
||||
it("should fail if the SFU errors", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
return;
|
||||
}
|
||||
expect.fail("Expected test to throw;");
|
||||
});
|
||||
|
||||
it("should try legacy and then new endpoint with delay delegation", async () => {
|
||||
fetchMock.post("https://sfu.example.org/get_token", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
};
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
{
|
||||
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||
delayId: "mock_delay_id",
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
}
|
||||
const calls = fetchMock.calls();
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||
expect(calls[0][1]).toStrictEqual({
|
||||
// check if it uses correct delayID!
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
|
||||
|
||||
expect(calls[1][1]).toStrictEqual({
|
||||
body: '{"room":"!example_room_id","device_id":"DEVICE"}',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("dont try legacy if endpoint with delay delegation is sucessful", async () => {
|
||||
fetchMock.post("https://sfu.example.org/get_token", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: sfuUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
{
|
||||
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||
delayId: "mock_delay_id",
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
}
|
||||
const calls = fetchMock.calls();
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||
expect(calls[0][1]).toStrictEqual({
|
||||
// check if it uses correct delayID!
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry fetching the openid token", async () => {
|
||||
let count = 0;
|
||||
matrixClient.getOpenIdToken.mockImplementation(async () => {
|
||||
count++;
|
||||
if (count < 2) {
|
||||
throw Error("Test failure");
|
||||
}
|
||||
return Promise.resolve({
|
||||
token_type: "Bearer",
|
||||
access_token: "foobar",
|
||||
matrix_server_name: "example.org",
|
||||
expires_in: 30,
|
||||
});
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: sfuUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
);
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: sfuUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
void (await fetchMock.flush());
|
||||
});
|
||||
});
|
||||
@@ -5,64 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
retryNetworkOperation,
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
NoMatrix2AuthorizationService,
|
||||
} from "../utils/errors";
|
||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||
import { useErrorBoundary } from "../useErrorBoundary";
|
||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||
import { Config } from "../config/Config";
|
||||
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport";
|
||||
|
||||
/**
|
||||
* Configuration and access tokens provided by the SFU on successful authentication.
|
||||
*/
|
||||
export interface SFUConfig {
|
||||
url: string;
|
||||
jwt: string;
|
||||
livekitAlias: string;
|
||||
// NOTE: Currently unused.
|
||||
livekitIdentity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded details from the JWT.
|
||||
*/
|
||||
interface SFUJWTPayload {
|
||||
/**
|
||||
* Expiration time for the JWT.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
exp: number;
|
||||
/**
|
||||
* Name of the instance which authored the JWT
|
||||
*/
|
||||
iss: string;
|
||||
/**
|
||||
* Time at which the JWT can start to be used.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
nbf: number;
|
||||
/**
|
||||
* Subject. The Livekit alias in this context.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* The set of permissions for the user.
|
||||
*/
|
||||
video: {
|
||||
canPublish: boolean;
|
||||
canSubscribe: boolean;
|
||||
room: string;
|
||||
roomJoin: boolean;
|
||||
};
|
||||
export function sfuConfigEquals(a?: SFUConfig, b?: SFUConfig): boolean {
|
||||
if (a === undefined && b === undefined) return true;
|
||||
if (a === undefined || b === undefined) return false;
|
||||
|
||||
return a.jwt === b.jwt && a.url === b.url;
|
||||
}
|
||||
|
||||
// The bits we need from MatrixClient
|
||||
@@ -70,38 +33,39 @@ export type OpenIDClientParts = Pick<
|
||||
MatrixClient,
|
||||
"getOpenIdToken" | "getDeviceId"
|
||||
>;
|
||||
/**
|
||||
* Gets a bearer token from the homeserver and then use it to authenticate
|
||||
* to the matrix RTC backend in order to get acces to the SFU.
|
||||
* It has built-in retry for calls to the homeserver with a backoff policy.
|
||||
* @param client The Matrix client
|
||||
* @param membership Our own membership identity parts used to send to jwt service.
|
||||
* @param serviceUrl The URL of the livekit SFU service
|
||||
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
|
||||
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
|
||||
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatenation
|
||||
* instead of a hash.
|
||||
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
|
||||
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
|
||||
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
|
||||
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
|
||||
* @param opts.delayId The delay id used for the jwt service to manage.
|
||||
* @param logger optional logger.
|
||||
* @returns Object containing the token information
|
||||
* @throws FailToGetOpenIdToken
|
||||
*/
|
||||
|
||||
export function useOpenIDSFU(
|
||||
client: OpenIDClientParts,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): SFUConfig | undefined {
|
||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||
|
||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||
const { showErrorBoundary } = useErrorBoundary();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFocus) {
|
||||
getSFUConfigWithOpenID(client, activeFocus).then(
|
||||
(sfuConfig) => {
|
||||
setSFUConfig(sfuConfig);
|
||||
},
|
||||
(e) => {
|
||||
showErrorBoundary(new FailToGetOpenIdToken(e));
|
||||
logger.error("Failed to get SFU config", e);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setSFUConfig(undefined);
|
||||
}
|
||||
}, [client, activeFocus, showErrorBoundary]);
|
||||
|
||||
return sfuConfig;
|
||||
}
|
||||
|
||||
export async function getSFUConfigWithOpenID(
|
||||
client: OpenIDClientParts,
|
||||
membership: CallMembershipIdentityParts,
|
||||
serviceUrl: string,
|
||||
roomId: string,
|
||||
opts?: {
|
||||
forceJwtEndpoint?: JwtEndpointVersion;
|
||||
delayEndpointBaseUrl?: string;
|
||||
delayId?: string;
|
||||
},
|
||||
logger?: Logger,
|
||||
): Promise<SFUConfig> {
|
||||
activeFocus: LivekitFocus,
|
||||
): Promise<SFUConfig | undefined> {
|
||||
let openIdToken: IOpenIDToken;
|
||||
try {
|
||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||
@@ -112,179 +76,53 @@ export async function getSFUConfigWithOpenID(
|
||||
error instanceof Error ? error : new Error("Unknown error"),
|
||||
);
|
||||
}
|
||||
logger?.debug("Got openID token", openIdToken);
|
||||
logger.debug("Got openID token", openIdToken);
|
||||
|
||||
logger?.info(`Trying to get JWT for focus ${serviceUrl}...`);
|
||||
|
||||
let sfuConfig: { url: string; jwt: string } | undefined;
|
||||
|
||||
const tryBothJwtEndpoints = opts?.forceJwtEndpoint === undefined; // This is for SFUs where we do not publish.
|
||||
|
||||
const forceMatrix2Jwt =
|
||||
opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0;
|
||||
|
||||
// We want to start using the new endpoint (with optional delay delegation)
|
||||
// if we can use both or if we are forced to use the new one.
|
||||
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
|
||||
try {
|
||||
sfuConfig = await getLiveKitJWTWithDelayDelegation(
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
openIdToken,
|
||||
opts?.delayEndpointBaseUrl,
|
||||
opts?.delayId,
|
||||
);
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
} catch (e) {
|
||||
if (e instanceof NotSupportedError) {
|
||||
logger?.warn(
|
||||
`Failed fetching jwt with matrix 2.0 endpoint (retry with legacy) Not supported`,
|
||||
e,
|
||||
);
|
||||
sfuConfig = undefined;
|
||||
} else {
|
||||
logger?.warn(
|
||||
`Failed fetching jwt with matrix 2.0 endpoint other issues ->`,
|
||||
`(not going to try with legacy endpoint: forceOldJwtEndpoint is set to false, we did not get a not supported error from the sfu)`,
|
||||
e,
|
||||
);
|
||||
// Make this throw a hard error in case we force the matrix2.0 endpoint.
|
||||
if (forceMatrix2Jwt)
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
// NEVER get bejond this point if we forceMatrix2 and it failed!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED
|
||||
// here we either have a sfuConfig or we alredy exited because of `if (forceMatrix2) throw ...`
|
||||
// The only case we can get into this condition is, if `forceMatrix2` is `false`
|
||||
if (sfuConfig === undefined) {
|
||||
sfuConfig = await getLiveKitJWT(
|
||||
membership.deviceId,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
try {
|
||||
logger.info(
|
||||
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`,
|
||||
);
|
||||
const sfuConfig = await getLiveKitJWT(
|
||||
client,
|
||||
activeFocus.livekit_service_url,
|
||||
activeFocus.livekit_alias,
|
||||
openIdToken,
|
||||
);
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
}
|
||||
logger.info(`Got JWT from call's active focus URL.`);
|
||||
|
||||
if (!sfuConfig) {
|
||||
throw new Error("No `sfuConfig` after trying with old and new endpoints");
|
||||
return sfuConfig;
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to get JWT from RTC session's active focus URL of ${activeFocus.livekit_service_url}.`,
|
||||
e,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Pull the details from the JWT
|
||||
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||
// TODO: Prefer Uint8Array.fromBase64 when widely available
|
||||
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||
return {
|
||||
jwt: sfuConfig.jwt,
|
||||
url: sfuConfig.url,
|
||||
livekitAlias: payload.video.room,
|
||||
// NOTE: Currently unused.
|
||||
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
|
||||
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
|
||||
livekitIdentity: payload.sub,
|
||||
};
|
||||
}
|
||||
const RETRIES = 4;
|
||||
|
||||
async function getLiveKitJWT(
|
||||
deviceId: string,
|
||||
client: OpenIDClientParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
roomName: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
let res: Response | undefined;
|
||||
await retryNetworkOperation(RETRIES, async () => {
|
||||
res = await fetch(livekitServiceURL + "/sfu/get", {
|
||||
): Promise<SFUConfig> {
|
||||
try {
|
||||
const res = await fetch(livekitServiceURL + "/sfu/get", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// This is the actual livekit room alias. For the legacy jwt endpoint simply the room id was used.
|
||||
room: matrixRoomId,
|
||||
room: roomName,
|
||||
openid_token: openIDToken,
|
||||
device_id: deviceId,
|
||||
device_id: client.getDeviceId(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
if (!res) {
|
||||
throw new Error(
|
||||
`Network error while connecting to jwt service after ${RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error("SFU Config fetch failed with status code " + res.status);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
class NotSupportedError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NotSupported";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLiveKitJWTWithDelayDelegation(
|
||||
membership: CallMembershipIdentityParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
const { userId, deviceId, memberId } = membership;
|
||||
|
||||
const body = {
|
||||
room_id: matrixRoomId,
|
||||
slot_id: "m.call#ROOM",
|
||||
openid_token: openIDToken,
|
||||
member: {
|
||||
id: memberId,
|
||||
claimed_user_id: userId,
|
||||
claimed_device_id: deviceId,
|
||||
},
|
||||
};
|
||||
|
||||
let bodyDalayParts = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
const delayTimeoutMs =
|
||||
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms ?? 1000;
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout: delayTimeoutMs,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
let res: Response | undefined;
|
||||
|
||||
await retryNetworkOperation(RETRIES, async () => {
|
||||
res = await fetch(livekitServiceURL + "/get_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...body, ...bodyDalayParts }),
|
||||
});
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new Error(
|
||||
`Network error while connecting to jwt service after ${RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = "SFU Config fetch failed with status code " + res.status;
|
||||
if (res.status === 404) {
|
||||
throw new NotSupportedError(msg);
|
||||
} else {
|
||||
throw new Error(msg);
|
||||
if (!res.ok) {
|
||||
throw new Error("SFU Config fetch failed with status code " + res.status);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw new Error("SFU Config fetch failed with exception " + e);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
184
src/livekit/useECConnectionState.test.tsx
Normal file
184
src/livekit/useECConnectionState.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { describe, expect, test, vi, vitest } from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type Room,
|
||||
} from "livekit-client";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { useECConnectionState } from "./useECConnectionState";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
|
||||
|
||||
test.each<[string, ConnectionError]>([
|
||||
[
|
||||
"LiveKit hits track limit",
|
||||
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
|
||||
],
|
||||
[
|
||||
"LiveKit hits room participant limit",
|
||||
new ConnectionError("", ConnectionErrorReason.ServerUnreachable, 200),
|
||||
],
|
||||
[
|
||||
"LiveKit Cloud hits connection limit",
|
||||
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
|
||||
],
|
||||
])(
|
||||
"useECConnectionState throws error when %s hits track limit",
|
||||
async (_server, error) => {
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: () => {
|
||||
throw error;
|
||||
},
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: () => [],
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent: FC = () => {
|
||||
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const connect = useCallback(
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
screen.getByText("Insufficient capacity");
|
||||
},
|
||||
);
|
||||
|
||||
describe("Leaking connection prevention", () => {
|
||||
function createTestComponent(mockRoom: Room): FC {
|
||||
const TestComponent: FC = () => {
|
||||
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const connect = useCallback(
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
return TestComponent;
|
||||
}
|
||||
|
||||
test("Should cancel pending connections when the component is unmounted", async () => {
|
||||
const connectCall = vi.fn();
|
||||
const pendingConnection = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: async () => {
|
||||
connectCall.call(undefined);
|
||||
return await pendingConnection.promise;
|
||||
},
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: () => [],
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(connectCall).toHaveBeenCalled();
|
||||
// unmount while the connection is pending
|
||||
unmount();
|
||||
|
||||
// resolve the pending connection
|
||||
pendingConnection.resolve();
|
||||
|
||||
await vitest.waitUntil(
|
||||
() => {
|
||||
return disconnectMock.mock.calls.length > 0;
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
interval: 100,
|
||||
},
|
||||
);
|
||||
|
||||
// There should be some cleaning up to avoid leaking an open connection
|
||||
expect(disconnectMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Should cancel about to open but not yet opened connection", async () => {
|
||||
const createTracksCall = vi.fn();
|
||||
const pendingCreateTrack = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: connectMock,
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: async () => {
|
||||
createTracksCall.call(undefined);
|
||||
await pendingCreateTrack.promise;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(createTracksCall).toHaveBeenCalled();
|
||||
// unmount while createTracks is pending
|
||||
unmount();
|
||||
|
||||
// resolve createTracks
|
||||
pendingCreateTrack.resolve();
|
||||
|
||||
// Yield to the event loop to let the connection attempt finish
|
||||
await sleep(100);
|
||||
|
||||
// The operation should have been aborted before even calling connect.
|
||||
expect(connectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
362
src/livekit/useECConnectionState.ts
Normal file
362
src/livekit/useECConnectionState.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionState,
|
||||
type LocalTrack,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { AbortHandle } from "../utils/abortHandle.ts";
|
||||
|
||||
/*
|
||||
* Additional values for states that a call can be in, beyond what livekit
|
||||
* provides in ConnectionState. Also reconnects the call if the SFU Config
|
||||
* changes.
|
||||
*/
|
||||
export enum ECAddonConnectionState {
|
||||
// We are switching from one focus to another (or between livekit room aliases on the same focus)
|
||||
ECSwitchingFocus = "ec_switching_focus",
|
||||
// The call has just been initialised and is waiting for credentials to arrive before attempting
|
||||
// to connect. This distinguishes from the 'Disconnected' state which is now just for when livekit
|
||||
// gives up on connectivity and we consider the call to have failed.
|
||||
ECWaiting = "ec_waiting",
|
||||
}
|
||||
|
||||
export type ECConnectionState = ConnectionState | ECAddonConnectionState;
|
||||
|
||||
// This is mostly necessary because an empty useRef is an empty object
|
||||
// which is truthy, so we can't just use Boolean(currentSFUConfig.current)
|
||||
function sfuConfigValid(sfuConfig?: SFUConfig): boolean {
|
||||
return Boolean(sfuConfig?.url) && Boolean(sfuConfig?.jwt);
|
||||
}
|
||||
|
||||
async function doConnect(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
audioEnabled: boolean,
|
||||
initialDeviceId: string | undefined,
|
||||
abortHandle: AbortHandle,
|
||||
): Promise<void> {
|
||||
// Always create an audio track manually.
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
if (
|
||||
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
) {
|
||||
logger.warn(
|
||||
"Pre-creating audio track but participant already appears to have an microphone track: this shouldn't happen!",
|
||||
);
|
||||
Sentry.captureMessage(
|
||||
"Pre-creating audio track but participant already appears to have an microphone track!",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Pre-creating microphone track");
|
||||
let preCreatedAudioTrack: LocalTrack | undefined;
|
||||
try {
|
||||
const audioTracks = await livekitRoom!.localParticipant.createTracks({
|
||||
audio: { deviceId: initialDeviceId },
|
||||
});
|
||||
|
||||
if (audioTracks.length < 1) {
|
||||
logger.info("Tried to pre-create local audio track but got no tracks");
|
||||
} else {
|
||||
preCreatedAudioTrack = audioTracks[0];
|
||||
}
|
||||
// There was a yield point previously (awaiting for the track to be created) so we need to check
|
||||
// if the operation was cancelled and stop connecting if needed.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Pre-created microphone track");
|
||||
} catch (e) {
|
||||
logger.error("Failed to pre-create microphone track", e);
|
||||
}
|
||||
|
||||
if (!audioEnabled) {
|
||||
await preCreatedAudioTrack?.mute();
|
||||
// There was a yield point. Check if the operation was cancelled and stop connecting.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// check again having awaited for the track to create
|
||||
if (
|
||||
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
) {
|
||||
logger.warn(
|
||||
"Pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[Lifecycle] Connecting & publishing");
|
||||
try {
|
||||
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
|
||||
);
|
||||
livekitRoom?.disconnect().catch((err) => {
|
||||
logger.error("Failed to disconnect from SFU", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
preCreatedAudioTrack?.stop();
|
||||
logger.debug("Stopped precreated audio tracks.");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the SFU and publish specific tracks, if provided.
|
||||
* This is very specific to what we need to do: for instance, we don't
|
||||
* currently have a need to prepublish video tracks. We just prepublish
|
||||
* a mic track at the start of a call and copy any srceenshare tracks over
|
||||
* when switching focus (because we can't re-acquire them without the user
|
||||
* going through the dialog to choose them again).
|
||||
*/
|
||||
async function connectAndPublish(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
micTrack: LocalTrack | undefined,
|
||||
screenshareTracks: MediaStreamTrack[],
|
||||
): Promise<void> {
|
||||
const tracker = PosthogAnalytics.instance.eventCallConnectDuration;
|
||||
// Track call connect duration
|
||||
tracker.cacheConnectStart();
|
||||
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
|
||||
try {
|
||||
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
logger.info(`[Lifecycle] ... connected to livekit room`);
|
||||
} catch (e) {
|
||||
logger.error("[Lifecycle] Failed to connect", e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (e instanceof ConnectionError) {
|
||||
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||
throw new InsufficientCapacityError();
|
||||
}
|
||||
if (e.status === 404) {
|
||||
// error msg is "Could not establish signal connection: requested room does not exist"
|
||||
// The room does not exist. There are two different modes of operation for the SFU:
|
||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
|
||||
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
tracker.track({ log: true });
|
||||
|
||||
if (micTrack) {
|
||||
logger.info(`Publishing precreated mic track`);
|
||||
await livekitRoom.localParticipant.publishTrack(micTrack, {
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Publishing ${screenshareTracks.length} precreated screenshare tracks`,
|
||||
);
|
||||
for (const st of screenshareTracks) {
|
||||
livekitRoom.localParticipant
|
||||
.publishTrack(st, {
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to publish screenshare track", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useECConnectionState(
|
||||
initialDeviceId: string | undefined,
|
||||
initialAudioEnabled: boolean,
|
||||
livekitRoom?: Room,
|
||||
sfuConfig?: SFUConfig,
|
||||
): ECConnectionState {
|
||||
const [connState, setConnState] = useState(
|
||||
sfuConfig && livekitRoom
|
||||
? livekitRoom.state
|
||||
: ECAddonConnectionState.ECWaiting,
|
||||
);
|
||||
|
||||
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
||||
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
||||
const [error, setError] = useState<ElementCallError | null>(null);
|
||||
if (error !== null) throw error;
|
||||
|
||||
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
||||
if (state == ConnectionState.Connected) setSwitchingFocus(false);
|
||||
setConnState(state);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const oldRoom = livekitRoom;
|
||||
|
||||
if (livekitRoom) {
|
||||
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (oldRoom)
|
||||
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
};
|
||||
}, [livekitRoom, onConnStateChanged]);
|
||||
|
||||
const doFocusSwitch = useCallback(async (): Promise<void> => {
|
||||
const screenshareTracks: MediaStreamTrack[] = [];
|
||||
for (const t of livekitRoom!.localParticipant.videoTrackPublications.values()) {
|
||||
if (t.track && t.source == Track.Source.ScreenShare) {
|
||||
const newTrack = t.track.mediaStreamTrack.clone();
|
||||
newTrack.enabled = true;
|
||||
screenshareTracks.push(newTrack);
|
||||
}
|
||||
}
|
||||
|
||||
// Flag that we're currently switching focus. This will get reset when the
|
||||
// connection state changes back to connected in onConnStateChanged above.
|
||||
setSwitchingFocus(true);
|
||||
await livekitRoom?.disconnect();
|
||||
setIsInDoConnect(true);
|
||||
try {
|
||||
await connectAndPublish(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
undefined,
|
||||
screenshareTracks,
|
||||
);
|
||||
} finally {
|
||||
setIsInDoConnect(false);
|
||||
}
|
||||
}, [livekitRoom, sfuConfig]);
|
||||
|
||||
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
|
||||
|
||||
// Protection against potential leaks, where the component to be unmounted and there is
|
||||
// still a pending doConnect promise. This would lead the user to still be in the call even
|
||||
// if the component is unmounted.
|
||||
const abortHandlesBag = useRef(new Set<AbortHandle>());
|
||||
|
||||
// This is a cleanup function that will be called when the component is about to be unmounted.
|
||||
// It will cancel all abortHandles in the bag
|
||||
useEffect(() => {
|
||||
const bag = abortHandlesBag.current;
|
||||
return (): void => {
|
||||
bag.forEach((handle) => {
|
||||
handle.abort();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Id we are transitioning from a valid config to another valid one, we need
|
||||
// to explicitly switch focus
|
||||
useEffect(() => {
|
||||
if (
|
||||
sfuConfigValid(sfuConfig) &&
|
||||
sfuConfigValid(currentSFUConfig.current) &&
|
||||
!sfuConfigEquals(currentSFUConfig.current, sfuConfig)
|
||||
) {
|
||||
logger.info(
|
||||
`SFU config changed! URL was ${currentSFUConfig.current?.url} now ${sfuConfig?.url}`,
|
||||
);
|
||||
|
||||
doFocusSwitch().catch((e) => {
|
||||
logger.error("Failed to switch focus", e);
|
||||
});
|
||||
} else if (
|
||||
!sfuConfigValid(currentSFUConfig.current) &&
|
||||
sfuConfigValid(sfuConfig)
|
||||
) {
|
||||
// if we're transitioning from an invalid config to a valid one (ie. connecting)
|
||||
// then do an initial connection, including publishing the microphone track:
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
setIsInDoConnect(true);
|
||||
const abortHandle = new AbortHandle();
|
||||
abortHandlesBag.current.add(abortHandle);
|
||||
doConnect(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
initialAudioEnabled,
|
||||
initialDeviceId,
|
||||
abortHandle,
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e instanceof ElementCallError) {
|
||||
setError(e); // Bubble up any error screens to React
|
||||
} else if (e instanceof Error) {
|
||||
setError(new UnknownCallError(e));
|
||||
} else logger.error("Failed to connect to SFU", e);
|
||||
})
|
||||
.finally(() => {
|
||||
abortHandlesBag.current.delete(abortHandle);
|
||||
setIsInDoConnect(false);
|
||||
});
|
||||
}
|
||||
|
||||
currentSFUConfig.current = Object.assign({}, sfuConfig);
|
||||
}, [
|
||||
sfuConfig,
|
||||
livekitRoom,
|
||||
initialDeviceId,
|
||||
initialAudioEnabled,
|
||||
doFocusSwitch,
|
||||
]);
|
||||
|
||||
// Because we create audio tracks by hand, there's more to connecting than
|
||||
// just what LiveKit does in room.connect, and we should continue to return
|
||||
// ConnectionState.Connecting for the entire duration of the doConnect promise
|
||||
return isSwitchingFocus
|
||||
? ECAddonConnectionState.ECSwitchingFocus
|
||||
: isInDoConnect
|
||||
? ConnectionState.Connecting
|
||||
: connState;
|
||||
}
|
||||
431
src/livekit/useLivekit.ts
Normal file
431
src/livekit/useLivekit.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionState,
|
||||
type E2EEManagerOptions,
|
||||
ExternalE2EEKeyProvider,
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
Room,
|
||||
type RoomOptions,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import {
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { defaultLiveKitOptions } from "./options";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { type MuteStates } from "../room/MuteStates";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import {
|
||||
type ECConnectionState,
|
||||
useECConnectionState,
|
||||
} from "./useECConnectionState";
|
||||
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "./TrackProcessorContext";
|
||||
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type SelectedDevice } from "../state/MediaDevices";
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
connState: ECConnectionState;
|
||||
}
|
||||
|
||||
export function useLivekit(
|
||||
rtcSession: MatrixRTCSession,
|
||||
muteStates: MuteStates,
|
||||
sfuConfig: SFUConfig | undefined,
|
||||
e2eeSystem: EncryptionSystem,
|
||||
): UseLivekitResult {
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
|
||||
const initialMuteStates = useInitial(() => muteStates);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const initialAudioInputId = useInitial(
|
||||
() => getValue(devices.audioInput.selected$)?.id,
|
||||
);
|
||||
|
||||
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
||||
// to setMicrophoneEnabled/setCameraEnabled
|
||||
const audioMuteUpdating = useRef(false);
|
||||
const videoMuteUpdating = useRef(false);
|
||||
// Store the current button mute state that gets passed to this hook via props.
|
||||
// We need to store it for awaited code that relies on the current value.
|
||||
const buttonEnabled = useRef({
|
||||
audio: initialMuteStates.audio.enabled,
|
||||
video: initialMuteStates.video.enabled,
|
||||
});
|
||||
|
||||
const { processor } = useTrackProcessor();
|
||||
|
||||
// Only ever create the room once via useInitial.
|
||||
const room = useInitial(() => {
|
||||
logger.info("[LivekitRoom] Create LiveKit room");
|
||||
|
||||
let e2ee: E2EEManagerOptions | undefined;
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
logger.info("Created MatrixKeyProvider (per participant)");
|
||||
e2ee = {
|
||||
keyProvider: new MatrixKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
||||
e2ee = {
|
||||
keyProvider: new ExternalE2EEKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
}
|
||||
|
||||
const roomOptions: RoomOptions = {
|
||||
...defaultLiveKitOptions,
|
||||
videoCaptureDefaults: {
|
||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||
deviceId: getValue(devices.videoInput.selected$)?.id,
|
||||
processor,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||
deviceId: initialAudioInputId,
|
||||
},
|
||||
audioOutput: {
|
||||
// When using controlled audio devices, we don't want to set the
|
||||
// deviceId here, because it will be set by the native app.
|
||||
// (also the id does not need to match a browser device id)
|
||||
deviceId: controlledAudioDevices
|
||||
? undefined
|
||||
: getValue(devices.audioOutput.selected$)?.id,
|
||||
},
|
||||
e2ee,
|
||||
};
|
||||
// We have to create the room manually here due to a bug inside
|
||||
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
const room = new Room(roomOptions);
|
||||
room.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
||||
logger.error("Failed to set E2EE enabled on room", e);
|
||||
});
|
||||
|
||||
return room;
|
||||
});
|
||||
|
||||
// Setup and update the keyProvider which was create by `createRoom`
|
||||
useEffect(() => {
|
||||
const e2eeOptions = room.options.e2ee;
|
||||
if (
|
||||
e2eeSystem.kind === E2eeType.NONE ||
|
||||
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
||||
)
|
||||
return;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider)
|
||||
.setKey(e2eeSystem.secret)
|
||||
.catch((e) => {
|
||||
logger.error("Failed to set shared key for E2EE", e);
|
||||
});
|
||||
}
|
||||
}, [room.options.e2ee, e2eeSystem, rtcSession]);
|
||||
|
||||
// Sync the requested track processors with LiveKit
|
||||
useTrackProcessorSync(
|
||||
useObservableEagerState(
|
||||
useObservable(
|
||||
(room$) =>
|
||||
room$.pipe(
|
||||
switchMap(([room]) =>
|
||||
observeTrackReference$(
|
||||
room.localParticipant,
|
||||
Track.Source.Camera,
|
||||
),
|
||||
),
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication?.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
}),
|
||||
),
|
||||
[room],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
initialAudioInputId,
|
||||
initialMuteStates.audio.enabled,
|
||||
room,
|
||||
sfuConfig,
|
||||
);
|
||||
|
||||
// Log errors when local participant has issues publishing a track.
|
||||
useEffect(() => {
|
||||
const localTrackUnpublishedFn = (
|
||||
publication: LocalTrackPublication,
|
||||
): void => {
|
||||
logger.info(
|
||||
"Local track unpublished",
|
||||
publication.trackName,
|
||||
publication.trackInfo,
|
||||
);
|
||||
};
|
||||
const mediaDevicesErrorFn = (error: Error): void => {
|
||||
logger.warn("Media devices error when publishing a track", error);
|
||||
};
|
||||
|
||||
room.localParticipant.on("localTrackUnpublished", localTrackUnpublishedFn);
|
||||
room.localParticipant.on("mediaDevicesError", mediaDevicesErrorFn);
|
||||
|
||||
return (): void => {
|
||||
room.localParticipant.off(
|
||||
"localTrackUnpublished",
|
||||
localTrackUnpublishedFn,
|
||||
);
|
||||
room.localParticipant.off("mediaDevicesError", mediaDevicesErrorFn);
|
||||
};
|
||||
}, [room.localParticipant]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the requested mute states with LiveKit's mute states. We do it this
|
||||
// way around rather than using LiveKit as the source of truth, so that the
|
||||
// states can be consistent throughout the lobby and loading screens.
|
||||
// It's important that we only do this in the connected state, because
|
||||
// LiveKit's internal mute states aren't consistent during connection setup,
|
||||
// and setting tracks to be enabled during this time causes errors.
|
||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
||||
const participant = room.localParticipant;
|
||||
// Always update the muteButtonState Ref so that we can read the current
|
||||
// state in awaited blocks.
|
||||
buttonEnabled.current = {
|
||||
audio: muteStates.audio.enabled,
|
||||
video: muteStates.video.enabled,
|
||||
};
|
||||
|
||||
enum MuteDevice {
|
||||
Microphone,
|
||||
Camera,
|
||||
}
|
||||
|
||||
const syncMuteState = async (
|
||||
iterCount: number,
|
||||
type: MuteDevice,
|
||||
): Promise<void> => {
|
||||
// The approach for muting is to always bring the actual livekit state in sync with the button
|
||||
// This allows for a very predictable and reactive behavior for the user.
|
||||
// (the new state is the old state when pressing the button n times (where n is even))
|
||||
// (the new state is different to the old state when pressing the button n times (where n is uneven))
|
||||
// In case there are issues with the device there might be situations where setMicrophoneEnabled/setCameraEnabled
|
||||
// return immediately. This should be caught with the Error("track with new mute state could not be published").
|
||||
// For now we are still using an iterCount to limit the recursion loop to 10.
|
||||
// This could happen if the device just really does not want to turn on (hardware based issue)
|
||||
// but the mute button is in unmute state.
|
||||
// For now our fail mode is to just stay in this state.
|
||||
// TODO: decide for a UX on how that fail mode should be treated (disable button, hide button, sync button back to muted without user input)
|
||||
|
||||
if (iterCount > 10) {
|
||||
logger.error(
|
||||
"Stop trying to sync the input device with current mute state after 10 failed tries",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let devEnabled;
|
||||
let btnEnabled;
|
||||
let updating;
|
||||
switch (type) {
|
||||
case MuteDevice.Microphone:
|
||||
devEnabled = participant.isMicrophoneEnabled;
|
||||
btnEnabled = buttonEnabled.current.audio;
|
||||
updating = audioMuteUpdating.current;
|
||||
break;
|
||||
case MuteDevice.Camera:
|
||||
devEnabled = participant.isCameraEnabled;
|
||||
btnEnabled = buttonEnabled.current.video;
|
||||
updating = videoMuteUpdating.current;
|
||||
break;
|
||||
}
|
||||
if (devEnabled !== btnEnabled && !updating) {
|
||||
try {
|
||||
let trackPublication;
|
||||
switch (type) {
|
||||
case MuteDevice.Microphone:
|
||||
audioMuteUpdating.current = true;
|
||||
trackPublication = await participant.setMicrophoneEnabled(
|
||||
buttonEnabled.current.audio,
|
||||
room.options.audioCaptureDefaults,
|
||||
);
|
||||
audioMuteUpdating.current = false;
|
||||
break;
|
||||
case MuteDevice.Camera:
|
||||
videoMuteUpdating.current = true;
|
||||
trackPublication = await participant.setCameraEnabled(
|
||||
buttonEnabled.current.video,
|
||||
room.options.videoCaptureDefaults,
|
||||
);
|
||||
videoMuteUpdating.current = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (trackPublication) {
|
||||
// await participant.setMicrophoneEnabled can return immediately in some instances,
|
||||
// so that participant.isMicrophoneEnabled !== buttonEnabled.current.audio still holds true.
|
||||
// This happens if the device is still in a pending state
|
||||
// "sleeping" here makes sure we let react do its thing so that participant.isMicrophoneEnabled is updated,
|
||||
// so we do not end up in a recursion loop.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// track got successfully changed to mute/unmute
|
||||
// Run the check again after the change is done. Because the user
|
||||
// can update the state (presses mute button) while the device is enabling
|
||||
// itself we need might need to update the mute state right away.
|
||||
// This async recursion makes sure that setCamera/MicrophoneEnabled is
|
||||
// called as little times as possible.
|
||||
await syncMuteState(iterCount + 1, type);
|
||||
} else {
|
||||
throw new Error(
|
||||
"track with new mute state could not be published",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as DOMException).name === "NotAllowedError") {
|
||||
logger.error(
|
||||
"Fatal error while syncing mute state: resetting",
|
||||
e,
|
||||
);
|
||||
if (type === MuteDevice.Microphone) {
|
||||
audioMuteUpdating.current = false;
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
} else {
|
||||
videoMuteUpdating.current = false;
|
||||
muteStates.video.setEnabled?.(false);
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
"Failed to sync audio mute state with LiveKit (will retry to sync in 1s):",
|
||||
e,
|
||||
);
|
||||
setTimeout(() => {
|
||||
syncMuteState(iterCount + 1, type).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to sync ${MuteDevice[type]} mute state with LiveKit iterCount=${iterCount + 1}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncMuteState(0, MuteDevice.Microphone).catch((e) => {
|
||||
logger.error("Failed to sync audio mute state with LiveKit", e);
|
||||
});
|
||||
syncMuteState(0, MuteDevice.Camera).catch((e) => {
|
||||
logger.error("Failed to sync video mute state with LiveKit", e);
|
||||
});
|
||||
}
|
||||
}, [room, muteStates, connectionState]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the requested devices with LiveKit's devices
|
||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription =>
|
||||
selected$.subscribe((device) => {
|
||||
logger.info(
|
||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||
room.getActiveDevice(kind),
|
||||
" !== ",
|
||||
device?.id,
|
||||
);
|
||||
if (
|
||||
device !== undefined &&
|
||||
room.getActiveDevice(kind) !== device.id
|
||||
) {
|
||||
room
|
||||
.switchActiveDevice(kind, device.id)
|
||||
.catch((e) =>
|
||||
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const subscriptions = [
|
||||
syncDevice("audioinput", devices.audioInput.selected$),
|
||||
!controlledAudioDevices
|
||||
? syncDevice("audiooutput", devices.audioOutput.selected$)
|
||||
: undefined,
|
||||
syncDevice("videoinput", devices.videoInput.selected$),
|
||||
// Restart the audio input track whenever we detect that the active media
|
||||
// device has changed to refer to a different hardware device. We do this
|
||||
// for the sake of Chrome, which provides a "default" device that is meant
|
||||
// to match the system's default audio input, whatever that may be.
|
||||
// This is special-cased for only audio inputs because we need to dig around
|
||||
// in the LocalParticipant object for the track object and there's not a nice
|
||||
// way to do that generically. There is usually no OS-level default video capture
|
||||
// device anyway, and audio outputs work differently.
|
||||
devices.audioInput.selected$
|
||||
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
|
||||
.subscribe(() => {
|
||||
const activeMicTrack = Array.from(
|
||||
room.localParticipant.audioTrackPublications.values(),
|
||||
).find((d) => d.source === Track.Source.Microphone)?.track;
|
||||
|
||||
if (
|
||||
activeMicTrack &&
|
||||
// only restart if the stream is still running: LiveKit will detect
|
||||
// when a track stops & restart appropriately, so this is not our job.
|
||||
// Plus, we need to avoid restarting again if the track is already in
|
||||
// the process of being restarted.
|
||||
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
||||
) {
|
||||
// Restart the track, which will cause Livekit to do another
|
||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||
// the deviceId hasn't changed (was & still is default).
|
||||
room.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.audioTrack?.restartTrack()
|
||||
.catch((e) => {
|
||||
logger.error(`Failed to restart audio device track`, e);
|
||||
});
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return (): void => {
|
||||
for (const s of subscriptions) s?.unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [room, devices, connectionState, controlledAudioDevices]);
|
||||
|
||||
return {
|
||||
connState: connectionState,
|
||||
livekitRoom: room,
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -62,7 +61,7 @@ Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App vm={new AppViewModel(globalScope)} />
|
||||
<App vm={new AppViewModel()} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
188
src/otel/OTelCall.ts
Normal file
188
src/otel/OTelCall.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import { type MatrixCall } from "matrix-js-sdk";
|
||||
import { CallEvent } from "matrix-js-sdk/lib/webrtc/call";
|
||||
import {
|
||||
type TransceiverStats,
|
||||
type CallFeedStats,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { type OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
|
||||
import { OTelCallTransceiverMediaStreamSpan } from "./OTelCallTransceiverMediaStreamSpan";
|
||||
import { OTelCallFeedMediaStreamSpan } from "./OTelCallFeedMediaStreamSpan";
|
||||
|
||||
type StreamId = string;
|
||||
type MID = string;
|
||||
|
||||
/**
|
||||
* Tracks an individual call within a group call, either to a full-mesh peer or a focus
|
||||
*/
|
||||
export class OTelCall {
|
||||
private readonly trackFeedSpan = new Map<
|
||||
StreamId,
|
||||
OTelCallAbstractMediaStreamSpan
|
||||
>();
|
||||
private readonly trackTransceiverSpan = new Map<
|
||||
MID,
|
||||
OTelCallAbstractMediaStreamSpan
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
public userId: string,
|
||||
public deviceId: string,
|
||||
public call: MatrixCall,
|
||||
public span: Span,
|
||||
) {
|
||||
if (call.peerConn) {
|
||||
this.addCallPeerConnListeners();
|
||||
} else {
|
||||
this.call.once(
|
||||
CallEvent.PeerConnectionCreated,
|
||||
this.addCallPeerConnListeners,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.call.peerConn?.removeEventListener(
|
||||
"connectionstatechange",
|
||||
this.onCallConnectionStateChanged,
|
||||
);
|
||||
this.call.peerConn?.removeEventListener(
|
||||
"signalingstatechange",
|
||||
this.onCallSignalingStateChanged,
|
||||
);
|
||||
this.call.peerConn?.removeEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onIceConnectionStateChanged,
|
||||
);
|
||||
this.call.peerConn?.removeEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onIceGatheringStateChanged,
|
||||
);
|
||||
this.call.peerConn?.removeEventListener(
|
||||
"icecandidateerror",
|
||||
this.onIceCandidateError,
|
||||
);
|
||||
}
|
||||
|
||||
private addCallPeerConnListeners = (): void => {
|
||||
this.call.peerConn?.addEventListener(
|
||||
"connectionstatechange",
|
||||
this.onCallConnectionStateChanged,
|
||||
);
|
||||
this.call.peerConn?.addEventListener(
|
||||
"signalingstatechange",
|
||||
this.onCallSignalingStateChanged,
|
||||
);
|
||||
this.call.peerConn?.addEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onIceConnectionStateChanged,
|
||||
);
|
||||
this.call.peerConn?.addEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onIceGatheringStateChanged,
|
||||
);
|
||||
this.call.peerConn?.addEventListener(
|
||||
"icecandidateerror",
|
||||
this.onIceCandidateError,
|
||||
);
|
||||
};
|
||||
|
||||
public onCallConnectionStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.callConnectionStateChange", {
|
||||
callConnectionState: this.call.peerConn?.connectionState,
|
||||
});
|
||||
};
|
||||
|
||||
public onCallSignalingStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.callSignalingStateChange", {
|
||||
callSignalingState: this.call.peerConn?.signalingState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceConnectionStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.iceConnectionStateChange", {
|
||||
iceConnectionState: this.call.peerConn?.iceConnectionState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceGatheringStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.iceGatheringStateChange", {
|
||||
iceGatheringState: this.call.peerConn?.iceGatheringState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceCandidateError = (ev: Event): void => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(ev, flatObject, "error.", 0);
|
||||
|
||||
this.span.addEvent("matrix.call.iceCandidateError", flatObject);
|
||||
};
|
||||
|
||||
public onCallFeedStats(callFeeds: CallFeedStats[]): void {
|
||||
let prvFeeds: StreamId[] = [...this.trackFeedSpan.keys()];
|
||||
|
||||
callFeeds.forEach((feed) => {
|
||||
if (!this.trackFeedSpan.has(feed.stream)) {
|
||||
this.trackFeedSpan.set(
|
||||
feed.stream,
|
||||
new OTelCallFeedMediaStreamSpan(
|
||||
ElementCallOpenTelemetry.instance,
|
||||
this.span,
|
||||
feed,
|
||||
),
|
||||
);
|
||||
}
|
||||
this.trackFeedSpan.get(feed.stream)?.update(feed);
|
||||
prvFeeds = prvFeeds.filter((prvStreamId) => prvStreamId !== feed.stream);
|
||||
});
|
||||
|
||||
prvFeeds.forEach((prvStreamId) => {
|
||||
this.trackFeedSpan.get(prvStreamId)?.end();
|
||||
this.trackFeedSpan.delete(prvStreamId);
|
||||
});
|
||||
}
|
||||
|
||||
public onTransceiverStats(transceiverStats: TransceiverStats[]): void {
|
||||
let prvTransSpan: MID[] = [...this.trackTransceiverSpan.keys()];
|
||||
|
||||
transceiverStats.forEach((transStats) => {
|
||||
if (!this.trackTransceiverSpan.has(transStats.mid)) {
|
||||
this.trackTransceiverSpan.set(
|
||||
transStats.mid,
|
||||
new OTelCallTransceiverMediaStreamSpan(
|
||||
ElementCallOpenTelemetry.instance,
|
||||
this.span,
|
||||
transStats,
|
||||
),
|
||||
);
|
||||
}
|
||||
this.trackTransceiverSpan.get(transStats.mid)?.update(transStats);
|
||||
prvTransSpan = prvTransSpan.filter(
|
||||
(prvStreamId) => prvStreamId !== transStats.mid,
|
||||
);
|
||||
});
|
||||
|
||||
prvTransSpan.forEach((prvMID) => {
|
||||
this.trackTransceiverSpan.get(prvMID)?.end();
|
||||
this.trackTransceiverSpan.delete(prvMID);
|
||||
});
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
this.trackFeedSpan.forEach((feedSpan) => feedSpan.end());
|
||||
this.trackTransceiverSpan.forEach((transceiverSpan) =>
|
||||
transceiverSpan.end(),
|
||||
);
|
||||
this.span.end();
|
||||
}
|
||||
}
|
||||
69
src/otel/OTelCallAbstractMediaStreamSpan.ts
Normal file
69
src/otel/OTelCallAbstractMediaStreamSpan.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import opentelemetry, { type Span } from "@opentelemetry/api";
|
||||
import { type TrackStats } from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
import { type ElementCallOpenTelemetry } from "./otel";
|
||||
import { OTelCallMediaStreamTrackSpan } from "./OTelCallMediaStreamTrackSpan";
|
||||
|
||||
type TrackId = string;
|
||||
|
||||
export abstract class OTelCallAbstractMediaStreamSpan {
|
||||
protected readonly trackSpans = new Map<
|
||||
TrackId,
|
||||
OTelCallMediaStreamTrackSpan
|
||||
>();
|
||||
public readonly span;
|
||||
|
||||
public constructor(
|
||||
protected readonly oTel: ElementCallOpenTelemetry,
|
||||
protected readonly callSpan: Span,
|
||||
protected readonly type: string,
|
||||
) {
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
callSpan,
|
||||
);
|
||||
const options = {
|
||||
links: [
|
||||
{
|
||||
context: callSpan.spanContext(),
|
||||
},
|
||||
],
|
||||
};
|
||||
this.span = oTel.tracer.startSpan(this.type, options, ctx);
|
||||
}
|
||||
|
||||
protected upsertTrackSpans(tracks: TrackStats[]): void {
|
||||
let prvTracks: TrackId[] = [...this.trackSpans.keys()];
|
||||
tracks.forEach((t) => {
|
||||
if (!this.trackSpans.has(t.id)) {
|
||||
this.trackSpans.set(
|
||||
t.id,
|
||||
new OTelCallMediaStreamTrackSpan(this.oTel, this.span, t),
|
||||
);
|
||||
}
|
||||
this.trackSpans.get(t.id)?.update(t);
|
||||
prvTracks = prvTracks.filter((prvTrackId) => prvTrackId !== t.id);
|
||||
});
|
||||
|
||||
prvTracks.forEach((prvTrackId) => {
|
||||
this.trackSpans.get(prvTrackId)?.end();
|
||||
this.trackSpans.delete(prvTrackId);
|
||||
});
|
||||
}
|
||||
|
||||
public abstract update(data: object): void;
|
||||
|
||||
public end(): void {
|
||||
this.trackSpans.forEach((tSpan) => {
|
||||
tSpan.end();
|
||||
});
|
||||
this.span.end();
|
||||
}
|
||||
}
|
||||
64
src/otel/OTelCallFeedMediaStreamSpan.ts
Normal file
64
src/otel/OTelCallFeedMediaStreamSpan.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import {
|
||||
type CallFeedStats,
|
||||
type TrackStats,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
import { type ElementCallOpenTelemetry } from "./otel";
|
||||
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
|
||||
|
||||
export class OTelCallFeedMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
|
||||
private readonly prev: { isAudioMuted: boolean; isVideoMuted: boolean };
|
||||
|
||||
public constructor(
|
||||
protected readonly oTel: ElementCallOpenTelemetry,
|
||||
protected readonly callSpan: Span,
|
||||
callFeed: CallFeedStats,
|
||||
) {
|
||||
const postFix =
|
||||
callFeed.type === "local" && callFeed.prefix === "from-call-feed"
|
||||
? "(clone)"
|
||||
: "";
|
||||
super(oTel, callSpan, `matrix.call.feed.${callFeed.type}${postFix}`);
|
||||
this.span.setAttribute("feed.streamId", callFeed.stream);
|
||||
this.span.setAttribute("feed.type", callFeed.type);
|
||||
this.span.setAttribute("feed.readFrom", callFeed.prefix);
|
||||
this.span.setAttribute("feed.purpose", callFeed.purpose);
|
||||
this.prev = {
|
||||
isAudioMuted: callFeed.isAudioMuted,
|
||||
isVideoMuted: callFeed.isVideoMuted,
|
||||
};
|
||||
this.span.addEvent("matrix.call.feed.initState", this.prev);
|
||||
}
|
||||
|
||||
public update(callFeed: CallFeedStats): void {
|
||||
if (this.prev.isAudioMuted !== callFeed.isAudioMuted) {
|
||||
this.span.addEvent("matrix.call.feed.audioMuted", {
|
||||
isAudioMuted: callFeed.isAudioMuted,
|
||||
});
|
||||
this.prev.isAudioMuted = callFeed.isAudioMuted;
|
||||
}
|
||||
if (this.prev.isVideoMuted !== callFeed.isVideoMuted) {
|
||||
this.span.addEvent("matrix.call.feed.isVideoMuted", {
|
||||
isVideoMuted: callFeed.isVideoMuted,
|
||||
});
|
||||
this.prev.isVideoMuted = callFeed.isVideoMuted;
|
||||
}
|
||||
|
||||
const trackStats: TrackStats[] = [];
|
||||
if (callFeed.video) {
|
||||
trackStats.push(callFeed.video);
|
||||
}
|
||||
if (callFeed.audio) {
|
||||
trackStats.push(callFeed.audio);
|
||||
}
|
||||
this.upsertTrackSpans(trackStats);
|
||||
}
|
||||
}
|
||||
69
src/otel/OTelCallMediaStreamTrackSpan.ts
Normal file
69
src/otel/OTelCallMediaStreamTrackSpan.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type TrackStats } from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
import opentelemetry, { type Span } from "@opentelemetry/api";
|
||||
|
||||
import { type ElementCallOpenTelemetry } from "./otel";
|
||||
|
||||
export class OTelCallMediaStreamTrackSpan {
|
||||
private readonly span: Span;
|
||||
private prev: TrackStats;
|
||||
|
||||
public constructor(
|
||||
protected readonly oTel: ElementCallOpenTelemetry,
|
||||
protected readonly streamSpan: Span,
|
||||
data: TrackStats,
|
||||
) {
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
streamSpan,
|
||||
);
|
||||
const options = {
|
||||
links: [
|
||||
{
|
||||
context: streamSpan.spanContext(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const type = `matrix.call.track.${data.label}.${data.kind}`;
|
||||
this.span = oTel.tracer.startSpan(type, options, ctx);
|
||||
this.span.setAttribute("track.trackId", data.id);
|
||||
this.span.setAttribute("track.kind", data.kind);
|
||||
this.span.setAttribute("track.constrainDeviceId", data.constrainDeviceId);
|
||||
this.span.setAttribute("track.settingDeviceId", data.settingDeviceId);
|
||||
this.span.setAttribute("track.label", data.label);
|
||||
|
||||
this.span.addEvent("matrix.call.track.initState", {
|
||||
readyState: data.readyState,
|
||||
muted: data.muted,
|
||||
enabled: data.enabled,
|
||||
});
|
||||
this.prev = data;
|
||||
}
|
||||
|
||||
public update(data: TrackStats): void {
|
||||
if (this.prev.muted !== data.muted) {
|
||||
this.span.addEvent("matrix.call.track.muted", { muted: data.muted });
|
||||
}
|
||||
if (this.prev.enabled !== data.enabled) {
|
||||
this.span.addEvent("matrix.call.track.enabled", {
|
||||
enabled: data.enabled,
|
||||
});
|
||||
}
|
||||
if (this.prev.readyState !== data.readyState) {
|
||||
this.span.addEvent("matrix.call.track.readyState", {
|
||||
readyState: data.readyState,
|
||||
});
|
||||
}
|
||||
this.prev = data;
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
this.span.end();
|
||||
}
|
||||
}
|
||||
61
src/otel/OTelCallTransceiverMediaStreamSpan.ts
Normal file
61
src/otel/OTelCallTransceiverMediaStreamSpan.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import {
|
||||
type TrackStats,
|
||||
type TransceiverStats,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
import { type ElementCallOpenTelemetry } from "./otel";
|
||||
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
|
||||
|
||||
export class OTelCallTransceiverMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
|
||||
private readonly prev: {
|
||||
direction: string;
|
||||
currentDirection: string;
|
||||
};
|
||||
|
||||
public constructor(
|
||||
protected readonly oTel: ElementCallOpenTelemetry,
|
||||
protected readonly callSpan: Span,
|
||||
stats: TransceiverStats,
|
||||
) {
|
||||
super(oTel, callSpan, `matrix.call.transceiver.${stats.mid}`);
|
||||
this.span.setAttribute("transceiver.mid", stats.mid);
|
||||
|
||||
this.prev = {
|
||||
direction: stats.direction,
|
||||
currentDirection: stats.currentDirection,
|
||||
};
|
||||
this.span.addEvent("matrix.call.transceiver.initState", this.prev);
|
||||
}
|
||||
|
||||
public update(stats: TransceiverStats): void {
|
||||
if (this.prev.currentDirection !== stats.currentDirection) {
|
||||
this.span.addEvent("matrix.call.transceiver.currentDirection", {
|
||||
currentDirection: stats.currentDirection,
|
||||
});
|
||||
this.prev.currentDirection = stats.currentDirection;
|
||||
}
|
||||
if (this.prev.direction !== stats.direction) {
|
||||
this.span.addEvent("matrix.call.transceiver.direction", {
|
||||
direction: stats.direction,
|
||||
});
|
||||
this.prev.direction = stats.direction;
|
||||
}
|
||||
|
||||
const trackStats: TrackStats[] = [];
|
||||
if (stats.sender) {
|
||||
trackStats.push(stats.sender);
|
||||
}
|
||||
if (stats.receiver) {
|
||||
trackStats.push(stats.receiver);
|
||||
}
|
||||
this.upsertTrackSpans(trackStats);
|
||||
}
|
||||
}
|
||||
477
src/otel/OTelGroupCallMembership.ts
Normal file
477
src/otel/OTelGroupCallMembership.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import opentelemetry, {
|
||||
type Span,
|
||||
type Attributes,
|
||||
type Context,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
type GroupCall,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallError,
|
||||
type CallState,
|
||||
type MatrixCall,
|
||||
type VoipEvent,
|
||||
} from "matrix-js-sdk/lib/webrtc/call";
|
||||
import {
|
||||
type CallsByUserAndDevice,
|
||||
type GroupCallError,
|
||||
GroupCallEvent,
|
||||
type GroupCallStatsReport,
|
||||
} from "matrix-js-sdk/lib/webrtc/groupCall";
|
||||
import {
|
||||
type ConnectionStatsReport,
|
||||
type ByteSentStatsReport,
|
||||
type SummaryStatsReport,
|
||||
type CallFeedReport,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
import { OTelCall } from "./OTelCall";
|
||||
|
||||
/**
|
||||
* Represent the span of time which we intend to be joined to a group call
|
||||
*/
|
||||
export class OTelGroupCallMembership {
|
||||
private callMembershipSpan?: Span;
|
||||
private groupCallContext?: Context;
|
||||
private myUserId = "unknown";
|
||||
private myDeviceId: string;
|
||||
private myMember?: RoomMember;
|
||||
private callsByCallId = new Map<string, OTelCall>();
|
||||
private statsReportSpan: {
|
||||
span: Span | undefined;
|
||||
stats: OTelStatsReportEvent[];
|
||||
};
|
||||
private readonly speakingSpans = new Map<RoomMember, Map<string, Span>>();
|
||||
|
||||
public constructor(
|
||||
private groupCall: GroupCall,
|
||||
client: MatrixClient,
|
||||
) {
|
||||
const clientId = client.getUserId();
|
||||
if (clientId) {
|
||||
this.myUserId = clientId;
|
||||
const myMember = groupCall.room.getMember(clientId);
|
||||
if (myMember) {
|
||||
this.myMember = myMember;
|
||||
}
|
||||
}
|
||||
this.myDeviceId = client.getDeviceId() || "unknown";
|
||||
this.statsReportSpan = { span: undefined, stats: [] };
|
||||
this.groupCall.on(GroupCallEvent.CallsChanged, this.onCallsChanged);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.groupCall.removeListener(
|
||||
GroupCallEvent.CallsChanged,
|
||||
this.onCallsChanged,
|
||||
);
|
||||
}
|
||||
|
||||
public onJoinCall(): void {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
if (this.callMembershipSpan !== undefined) {
|
||||
logger.warn("Call membership span is already started");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the main span that tracks the time we intend to be in the call
|
||||
this.callMembershipSpan =
|
||||
ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
"matrix.groupCallMembership",
|
||||
);
|
||||
this.callMembershipSpan.setAttribute(
|
||||
"matrix.confId",
|
||||
this.groupCall.groupCallId,
|
||||
);
|
||||
this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId);
|
||||
this.callMembershipSpan.setAttribute("matrix.deviceId", this.myDeviceId);
|
||||
this.callMembershipSpan.setAttribute(
|
||||
"matrix.displayName",
|
||||
this.myMember ? this.myMember.name : "unknown-name",
|
||||
);
|
||||
|
||||
this.groupCallContext = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan,
|
||||
);
|
||||
|
||||
this.callMembershipSpan?.addEvent("matrix.joinCall");
|
||||
}
|
||||
|
||||
public onLeaveCall(): void {
|
||||
if (this.callMembershipSpan === undefined) {
|
||||
logger.warn("Call membership span is already ended");
|
||||
return;
|
||||
}
|
||||
|
||||
this.callMembershipSpan.addEvent("matrix.leaveCall");
|
||||
// and end the span to indicate we've left
|
||||
this.callMembershipSpan.end();
|
||||
this.callMembershipSpan = undefined;
|
||||
this.groupCallContext = undefined;
|
||||
}
|
||||
|
||||
public onUpdateRoomState(event: MatrixEvent): void {
|
||||
if (
|
||||
!event ||
|
||||
(!event.getType().startsWith("m.call") &&
|
||||
!event.getType().startsWith("org.matrix.msc3401.call"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.callMembershipSpan?.addEvent(
|
||||
`matrix.roomStateEvent_${event.getType()}`,
|
||||
ObjectFlattener.flattenVoipEvent(event.getContent()),
|
||||
);
|
||||
}
|
||||
|
||||
public onCallsChanged(calls: CallsByUserAndDevice): void {
|
||||
for (const [userId, userCalls] of calls.entries()) {
|
||||
for (const [deviceId, call] of userCalls.entries()) {
|
||||
if (!this.callsByCallId.has(call.callId)) {
|
||||
if (ElementCallOpenTelemetry.instance) {
|
||||
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
`matrix.call`,
|
||||
undefined,
|
||||
this.groupCallContext,
|
||||
);
|
||||
// XXX: anonymity
|
||||
span.setAttribute("matrix.call.target.userId", userId);
|
||||
span.setAttribute("matrix.call.target.deviceId", deviceId);
|
||||
const displayName =
|
||||
this.groupCall.room.getMember(userId)?.name ?? "unknown";
|
||||
span.setAttribute("matrix.call.target.displayName", displayName);
|
||||
this.callsByCallId.set(
|
||||
call.callId,
|
||||
new OTelCall(userId, deviceId, call, span),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const callTrackingInfo of this.callsByCallId.values()) {
|
||||
const userCalls = calls.get(callTrackingInfo.userId);
|
||||
if (
|
||||
!userCalls ||
|
||||
!userCalls.has(callTrackingInfo.deviceId) ||
|
||||
userCalls.get(callTrackingInfo.deviceId)?.callId !==
|
||||
callTrackingInfo.call.callId
|
||||
) {
|
||||
callTrackingInfo.end();
|
||||
this.callsByCallId.delete(callTrackingInfo.call.callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onCallStateChange(call: MatrixCall, newState: CallState): void {
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got call state change for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
callTrackingInfo.span.addEvent("matrix.call.stateChange", {
|
||||
state: newState,
|
||||
});
|
||||
}
|
||||
|
||||
public onSendEvent(call: MatrixCall, event: VoipEvent): void {
|
||||
const eventType = event.eventType as string;
|
||||
if (
|
||||
!eventType.startsWith("m.call") &&
|
||||
!eventType.startsWith("org.matrix.call")
|
||||
)
|
||||
return;
|
||||
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got call send event for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "toDevice") {
|
||||
callTrackingInfo.span.addEvent(
|
||||
`matrix.sendToDeviceEvent_${event.eventType}`,
|
||||
ObjectFlattener.flattenVoipEvent(event),
|
||||
);
|
||||
} else if (event.type === "sendEvent") {
|
||||
callTrackingInfo.span.addEvent(
|
||||
`matrix.sendToRoomEvent_${event.eventType}`,
|
||||
ObjectFlattener.flattenVoipEvent(event),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onReceivedVoipEvent(event: MatrixEvent): void {
|
||||
// These come straight from CallEventHandler so don't have
|
||||
// a call already associated (in principle we could receive
|
||||
// events for calls we don't know about).
|
||||
const callId = event.getContent().call_id;
|
||||
if (!callId) {
|
||||
this.callMembershipSpan?.addEvent("matrix.receive_voip_event_no_callid", {
|
||||
"sender.userId": event.getSender(),
|
||||
});
|
||||
logger.error("Received call event with no call ID!");
|
||||
return;
|
||||
}
|
||||
|
||||
const call = this.callsByCallId.get(callId);
|
||||
if (!call) {
|
||||
this.callMembershipSpan?.addEvent(
|
||||
"matrix.receive_voip_event_unknown_callid",
|
||||
{
|
||||
"sender.userId": event.getSender(),
|
||||
},
|
||||
);
|
||||
logger.error("Received call event for unknown call ID " + callId);
|
||||
return;
|
||||
}
|
||||
|
||||
call.span.addEvent("matrix.receive_voip_event", {
|
||||
"sender.userId": event.getSender(),
|
||||
...ObjectFlattener.flattenVoipEvent(event.getContent()),
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleMicrophoneMuted(newValue: boolean): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.toggleMicMuted", {
|
||||
"matrix.microphone.muted": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSetMicrophoneMuted(setMuted: boolean): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.setMicMuted", {
|
||||
"matrix.microphone.muted": setMuted,
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleLocalVideoMuted(newValue: boolean): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.toggleVidMuted", {
|
||||
"matrix.video.muted": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSetLocalVideoMuted(setMuted: boolean): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.setVidMuted", {
|
||||
"matrix.video.muted": setMuted,
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleScreensharing(newValue: boolean): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.setVidMuted", {
|
||||
"matrix.screensharing.enabled": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSpeaking(
|
||||
member: RoomMember,
|
||||
deviceId: string,
|
||||
speaking: boolean,
|
||||
): void {
|
||||
if (speaking) {
|
||||
// Ensure that there's an audio activity span for this speaker
|
||||
let deviceMap = this.speakingSpans.get(member);
|
||||
if (deviceMap === undefined) {
|
||||
deviceMap = new Map();
|
||||
this.speakingSpans.set(member, deviceMap);
|
||||
}
|
||||
|
||||
if (!deviceMap.has(deviceId)) {
|
||||
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
"matrix.audioActivity",
|
||||
undefined,
|
||||
this.groupCallContext,
|
||||
);
|
||||
span.setAttribute("matrix.userId", member.userId);
|
||||
span.setAttribute("matrix.displayName", member.rawDisplayName);
|
||||
|
||||
deviceMap.set(deviceId, span);
|
||||
}
|
||||
} else {
|
||||
// End the audio activity span for this speaker, if any
|
||||
const deviceMap = this.speakingSpans.get(member);
|
||||
deviceMap?.get(deviceId)?.end();
|
||||
deviceMap?.delete(deviceId);
|
||||
|
||||
if (deviceMap?.size === 0) this.speakingSpans.delete(member);
|
||||
}
|
||||
}
|
||||
|
||||
public onCallError(error: CallError, call: MatrixCall): void {
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got error for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
callTrackingInfo.span.recordException(error);
|
||||
}
|
||||
|
||||
public onGroupCallError(error: GroupCallError): void {
|
||||
this.callMembershipSpan?.recordException(error);
|
||||
}
|
||||
|
||||
public onUndecryptableToDevice(event: MatrixEvent): void {
|
||||
this.callMembershipSpan?.addEvent("matrix.toDevice.undecryptable", {
|
||||
"sender.userId": event.getSender(),
|
||||
});
|
||||
}
|
||||
|
||||
public onCallFeedStatsReport(
|
||||
report: GroupCallStatsReport<CallFeedReport>,
|
||||
): void {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
let call: OTelCall | undefined;
|
||||
const callId = report.report?.callId;
|
||||
|
||||
if (callId) {
|
||||
call = this.callsByCallId.get(callId);
|
||||
}
|
||||
|
||||
if (!call) {
|
||||
this.callMembershipSpan?.addEvent(
|
||||
OTelStatsReportType.CallFeedReport + "_unknown_callId",
|
||||
{
|
||||
"call.callId": callId,
|
||||
"call.opponentMemberId": report.report?.opponentMemberId
|
||||
? report.report?.opponentMemberId
|
||||
: "unknown",
|
||||
},
|
||||
);
|
||||
logger.error(
|
||||
`Received ${OTelStatsReportType.CallFeedReport} with unknown call ID: ${callId}`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
call.onCallFeedStats(report.report.callFeeds);
|
||||
call.onTransceiverStats(report.report.transceiver);
|
||||
}
|
||||
}
|
||||
|
||||
public onConnectionStatsReport(
|
||||
statsReport: GroupCallStatsReport<ConnectionStatsReport>,
|
||||
): void {
|
||||
this.buildCallStatsSpan(
|
||||
OTelStatsReportType.ConnectionReport,
|
||||
statsReport.report,
|
||||
);
|
||||
}
|
||||
|
||||
public onByteSentStatsReport(
|
||||
statsReport: GroupCallStatsReport<ByteSentStatsReport>,
|
||||
): void {
|
||||
this.buildCallStatsSpan(
|
||||
OTelStatsReportType.ByteSentReport,
|
||||
statsReport.report,
|
||||
);
|
||||
}
|
||||
|
||||
public buildCallStatsSpan(
|
||||
type: OTelStatsReportType,
|
||||
report: ByteSentStatsReport | ConnectionStatsReport,
|
||||
): void {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
let call: OTelCall | undefined;
|
||||
const callId = report?.callId;
|
||||
|
||||
if (callId) {
|
||||
call = this.callsByCallId.get(callId);
|
||||
}
|
||||
|
||||
if (!call) {
|
||||
this.callMembershipSpan?.addEvent(type + "_unknown_callid", {
|
||||
"call.callId": callId,
|
||||
"call.opponentMemberId": report.opponentMemberId
|
||||
? report.opponentMemberId
|
||||
: "unknown",
|
||||
});
|
||||
logger.error(`Received ${type} with unknown call ID: ${callId}`);
|
||||
return;
|
||||
}
|
||||
const data = ObjectFlattener.flattenReportObject(type, report);
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
call.span,
|
||||
);
|
||||
|
||||
const options = {
|
||||
links: [
|
||||
{
|
||||
context: call.span.spanContext(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
type,
|
||||
options,
|
||||
ctx,
|
||||
);
|
||||
|
||||
span.setAttribute("matrix.callId", callId ?? "unknown");
|
||||
span.setAttribute(
|
||||
"matrix.opponentMemberId",
|
||||
report.opponentMemberId ? report.opponentMemberId : "unknown",
|
||||
);
|
||||
span.addEvent("matrix.call.connection_stats_event", data);
|
||||
span.end();
|
||||
}
|
||||
|
||||
public onSummaryStatsReport(
|
||||
statsReport: GroupCallStatsReport<SummaryStatsReport>,
|
||||
): void {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
|
||||
const type = OTelStatsReportType.SummaryReport;
|
||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan,
|
||||
);
|
||||
const span = ElementCallOpenTelemetry.instance?.tracer.startSpan(
|
||||
"matrix.groupCallMembership.summaryReport",
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
if (span === undefined) {
|
||||
return;
|
||||
}
|
||||
span.setAttribute("matrix.confId", this.groupCall.groupCallId);
|
||||
span.setAttribute("matrix.userId", this.myUserId);
|
||||
span.setAttribute(
|
||||
"matrix.displayName",
|
||||
this.myMember ? this.myMember.name : "unknown-name",
|
||||
);
|
||||
span.addEvent(type, data);
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OTelStatsReportEvent {
|
||||
type: OTelStatsReportType;
|
||||
data: Attributes;
|
||||
}
|
||||
|
||||
enum OTelStatsReportType {
|
||||
ConnectionReport = "matrix.call.stats.connection",
|
||||
ByteSentReport = "matrix.call.stats.byteSent",
|
||||
SummaryReport = "matrix.stats.summary",
|
||||
CallFeedReport = "matrix.stats.call_feed",
|
||||
}
|
||||
266
src/otel/ObjectFlattener.test.ts
Normal file
266
src/otel/ObjectFlattener.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type GroupCallStatsReport } from "matrix-js-sdk/lib/webrtc/groupCall";
|
||||
import {
|
||||
type AudioConcealment,
|
||||
type ByteSentStatsReport,
|
||||
type ConnectionStatsReport,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ObjectFlattener } from "../../src/otel/ObjectFlattener";
|
||||
|
||||
describe("ObjectFlattener", () => {
|
||||
const noConcealment: AudioConcealment = {
|
||||
concealedAudio: 0,
|
||||
totalAudioDuration: 0,
|
||||
};
|
||||
|
||||
const statsReport: GroupCallStatsReport<ConnectionStatsReport> = {
|
||||
report: {
|
||||
callId: "callId",
|
||||
opponentMemberId: "opponentMemberId",
|
||||
bandwidth: { upload: 426, download: 0 },
|
||||
bitrate: {
|
||||
upload: 426,
|
||||
download: 0,
|
||||
audio: {
|
||||
upload: 124,
|
||||
download: 0,
|
||||
},
|
||||
video: {
|
||||
upload: 302,
|
||||
download: 0,
|
||||
},
|
||||
},
|
||||
packetLoss: {
|
||||
total: 0,
|
||||
download: 0,
|
||||
upload: 0,
|
||||
},
|
||||
framerate: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", 0],
|
||||
["LOCAL_VIDEO_TRACK_ID", 30],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", 0],
|
||||
["REMOTE_VIDEO_TRACK_ID", 60],
|
||||
]),
|
||||
},
|
||||
resolution: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }],
|
||||
["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }],
|
||||
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
|
||||
]),
|
||||
},
|
||||
jitter: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", 2],
|
||||
["REMOTE_VIDEO_TRACK_ID", 50],
|
||||
]),
|
||||
codec: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", "opus"],
|
||||
["LOCAL_VIDEO_TRACK_ID", "v8"],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", "opus"],
|
||||
["REMOTE_VIDEO_TRACK_ID", "v9"],
|
||||
]),
|
||||
},
|
||||
transport: [
|
||||
{
|
||||
ip: "ff11::5fa:abcd:999c:c5c5:50000",
|
||||
type: "udp",
|
||||
localIp: "2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
isFocus: true,
|
||||
localCandidateType: "host",
|
||||
remoteCandidateType: "host",
|
||||
networkType: "ethernet",
|
||||
rtt: NaN,
|
||||
},
|
||||
{
|
||||
ip: "10.10.10.2:22222",
|
||||
type: "tcp",
|
||||
localIp: "10.10.10.100:33333",
|
||||
isFocus: true,
|
||||
localCandidateType: "srfx",
|
||||
remoteCandidateType: "srfx",
|
||||
networkType: "ethernet",
|
||||
rtt: 0,
|
||||
},
|
||||
],
|
||||
audioConcealment: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", noConcealment],
|
||||
["REMOTE_VIDEO_TRACK_ID", noConcealment],
|
||||
]),
|
||||
totalAudioConcealment: noConcealment,
|
||||
},
|
||||
};
|
||||
|
||||
describe("on flattenObjectRecursive", () => {
|
||||
it("should flatter an Map object", () => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report.resolution,
|
||||
flatObject,
|
||||
"matrix.call.stats.connection.resolution.",
|
||||
0,
|
||||
);
|
||||
expect(flatObject).toEqual({
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.height":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.width":
|
||||
-1,
|
||||
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
|
||||
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.height":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.width":
|
||||
-1,
|
||||
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
|
||||
});
|
||||
});
|
||||
it("should flatter an Array object", () => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report.transport,
|
||||
flatObject,
|
||||
"matrix.call.stats.connection.transport.",
|
||||
0,
|
||||
);
|
||||
expect(flatObject).toEqual({
|
||||
"matrix.call.stats.connection.transport.0.ip":
|
||||
"ff11::5fa:abcd:999c:c5c5:50000",
|
||||
"matrix.call.stats.connection.transport.0.type": "udp",
|
||||
"matrix.call.stats.connection.transport.0.localIp":
|
||||
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
"matrix.call.stats.connection.transport.0.isFocus": true,
|
||||
"matrix.call.stats.connection.transport.0.localCandidateType": "host",
|
||||
"matrix.call.stats.connection.transport.0.remoteCandidateType": "host",
|
||||
"matrix.call.stats.connection.transport.0.networkType": "ethernet",
|
||||
"matrix.call.stats.connection.transport.0.rtt": "NaN",
|
||||
"matrix.call.stats.connection.transport.1.ip": "10.10.10.2:22222",
|
||||
"matrix.call.stats.connection.transport.1.type": "tcp",
|
||||
"matrix.call.stats.connection.transport.1.localIp":
|
||||
"10.10.10.100:33333",
|
||||
"matrix.call.stats.connection.transport.1.isFocus": true,
|
||||
"matrix.call.stats.connection.transport.1.localCandidateType": "srfx",
|
||||
"matrix.call.stats.connection.transport.1.remoteCandidateType": "srfx",
|
||||
"matrix.call.stats.connection.transport.1.networkType": "ethernet",
|
||||
"matrix.call.stats.connection.transport.1.rtt": 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on flattenReportObject Connection Stats", () => {
|
||||
it("should flatten a Report to otel Attributes Object", () => {
|
||||
expect(
|
||||
ObjectFlattener.flattenReportObject(
|
||||
"matrix.call.stats.connection",
|
||||
statsReport.report,
|
||||
),
|
||||
).toEqual({
|
||||
"matrix.call.stats.connection.callId": "callId",
|
||||
"matrix.call.stats.connection.opponentMemberId": "opponentMemberId",
|
||||
"matrix.call.stats.connection.bandwidth.download": 0,
|
||||
"matrix.call.stats.connection.bandwidth.upload": 426,
|
||||
"matrix.call.stats.connection.bitrate.audio.download": 0,
|
||||
"matrix.call.stats.connection.bitrate.audio.upload": 124,
|
||||
"matrix.call.stats.connection.bitrate.download": 0,
|
||||
"matrix.call.stats.connection.bitrate.upload": 426,
|
||||
"matrix.call.stats.connection.bitrate.video.download": 0,
|
||||
"matrix.call.stats.connection.bitrate.video.upload": 302,
|
||||
"matrix.call.stats.connection.codec.local.LOCAL_AUDIO_TRACK_ID": "opus",
|
||||
"matrix.call.stats.connection.codec.local.LOCAL_VIDEO_TRACK_ID": "v8",
|
||||
"matrix.call.stats.connection.codec.remote.REMOTE_AUDIO_TRACK_ID":
|
||||
"opus",
|
||||
"matrix.call.stats.connection.codec.remote.REMOTE_VIDEO_TRACK_ID": "v9",
|
||||
"matrix.call.stats.connection.framerate.local.LOCAL_AUDIO_TRACK_ID": 0,
|
||||
"matrix.call.stats.connection.framerate.local.LOCAL_VIDEO_TRACK_ID": 30,
|
||||
"matrix.call.stats.connection.framerate.remote.REMOTE_AUDIO_TRACK_ID": 0,
|
||||
"matrix.call.stats.connection.framerate.remote.REMOTE_VIDEO_TRACK_ID": 60,
|
||||
"matrix.call.stats.connection.jitter.REMOTE_AUDIO_TRACK_ID": 2,
|
||||
"matrix.call.stats.connection.jitter.REMOTE_VIDEO_TRACK_ID": 50,
|
||||
"matrix.call.stats.connection.packetLoss.download": 0,
|
||||
"matrix.call.stats.connection.packetLoss.total": 0,
|
||||
"matrix.call.stats.connection.packetLoss.upload": 0,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.height":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.width":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
|
||||
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.height":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.width":
|
||||
-1,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
|
||||
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
|
||||
"matrix.call.stats.connection.transport.0.ip":
|
||||
"ff11::5fa:abcd:999c:c5c5:50000",
|
||||
"matrix.call.stats.connection.transport.0.type": "udp",
|
||||
"matrix.call.stats.connection.transport.0.localIp":
|
||||
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
"matrix.call.stats.connection.transport.0.isFocus": true,
|
||||
"matrix.call.stats.connection.transport.0.localCandidateType": "host",
|
||||
"matrix.call.stats.connection.transport.0.remoteCandidateType": "host",
|
||||
"matrix.call.stats.connection.transport.0.networkType": "ethernet",
|
||||
"matrix.call.stats.connection.transport.0.rtt": "NaN",
|
||||
"matrix.call.stats.connection.transport.1.ip": "10.10.10.2:22222",
|
||||
"matrix.call.stats.connection.transport.1.type": "tcp",
|
||||
"matrix.call.stats.connection.transport.1.localIp":
|
||||
"10.10.10.100:33333",
|
||||
"matrix.call.stats.connection.transport.1.isFocus": true,
|
||||
"matrix.call.stats.connection.transport.1.localCandidateType": "srfx",
|
||||
"matrix.call.stats.connection.transport.1.remoteCandidateType": "srfx",
|
||||
"matrix.call.stats.connection.transport.1.networkType": "ethernet",
|
||||
"matrix.call.stats.connection.transport.1.rtt": 0,
|
||||
"matrix.call.stats.connection.audioConcealment.REMOTE_AUDIO_TRACK_ID.concealedAudio": 0,
|
||||
"matrix.call.stats.connection.audioConcealment.REMOTE_AUDIO_TRACK_ID.totalAudioDuration": 0,
|
||||
"matrix.call.stats.connection.audioConcealment.REMOTE_VIDEO_TRACK_ID.concealedAudio": 0,
|
||||
"matrix.call.stats.connection.audioConcealment.REMOTE_VIDEO_TRACK_ID.totalAudioDuration": 0,
|
||||
"matrix.call.stats.connection.totalAudioConcealment.concealedAudio": 0,
|
||||
"matrix.call.stats.connection.totalAudioConcealment.totalAudioDuration": 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on flattenByteSendStatsReportObject", () => {
|
||||
const byteSentStatsReport = new Map<
|
||||
string,
|
||||
number
|
||||
>() as ByteSentStatsReport;
|
||||
byteSentStatsReport.callId = "callId";
|
||||
byteSentStatsReport.opponentMemberId = "opponentMemberId";
|
||||
byteSentStatsReport.set("4aa92608-04c6-428e-8312-93e17602a959", 132093);
|
||||
byteSentStatsReport.set("a08e4237-ee30-4015-a932-b676aec894b1", 913448);
|
||||
|
||||
it("should flatten a Report to otel Attributes Object", () => {
|
||||
expect(
|
||||
ObjectFlattener.flattenReportObject(
|
||||
"matrix.call.stats.bytesSend",
|
||||
byteSentStatsReport,
|
||||
),
|
||||
).toEqual({
|
||||
"matrix.call.stats.bytesSend.4aa92608-04c6-428e-8312-93e17602a959": 132093,
|
||||
"matrix.call.stats.bytesSend.a08e4237-ee30-4015-a932-b676aec894b1": 913448,
|
||||
});
|
||||
expect(byteSentStatsReport.callId).toEqual("callId");
|
||||
expect(byteSentStatsReport.opponentMemberId).toEqual("opponentMemberId");
|
||||
});
|
||||
});
|
||||
});
|
||||
100
src/otel/ObjectFlattener.ts
Normal file
100
src/otel/ObjectFlattener.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import { type Attributes } from "@opentelemetry/api";
|
||||
import { type VoipEvent } from "matrix-js-sdk/lib/webrtc/call";
|
||||
import { type GroupCallStatsReport } from "matrix-js-sdk/lib/webrtc/groupCall";
|
||||
import {
|
||||
type ByteSentStatsReport,
|
||||
type ConnectionStatsReport,
|
||||
type SummaryStatsReport,
|
||||
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
|
||||
|
||||
export class ObjectFlattener {
|
||||
public static flattenReportObject(
|
||||
prefix: string,
|
||||
report: ConnectionStatsReport | ByteSentStatsReport,
|
||||
): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(report, flatObject, `${prefix}.`, 0);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
public static flattenByteSentStatsReportObject(
|
||||
statsReport: GroupCallStatsReport<ByteSentStatsReport>,
|
||||
): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report,
|
||||
flatObject,
|
||||
"matrix.stats.bytesSent.",
|
||||
0,
|
||||
);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
public static flattenSummaryStatsReportObject(
|
||||
statsReport: GroupCallStatsReport<SummaryStatsReport>,
|
||||
): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report,
|
||||
flatObject,
|
||||
"matrix.stats.summary.",
|
||||
0,
|
||||
);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
/* Flattens out an object into a single layer with components
|
||||
* of the key separated by dots
|
||||
*/
|
||||
public static flattenVoipEvent(event: VoipEvent): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
event as unknown as Record<string, unknown>, // XXX Types
|
||||
flatObject,
|
||||
"matrix.event.",
|
||||
0,
|
||||
);
|
||||
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
public static flattenObjectRecursive(
|
||||
obj: object,
|
||||
flatObject: Attributes,
|
||||
prefix: string,
|
||||
depth: number,
|
||||
): void {
|
||||
if (depth > 10)
|
||||
throw new Error(
|
||||
"Depth limit exceeded: aborting VoipEvent recursion. Prefix is " +
|
||||
prefix,
|
||||
);
|
||||
let entries;
|
||||
if (obj instanceof Map) {
|
||||
entries = obj.entries();
|
||||
} else {
|
||||
entries = Object.entries(obj);
|
||||
}
|
||||
for (const [k, v] of entries) {
|
||||
if (["string", "number", "boolean"].includes(typeof v) || v === null) {
|
||||
let value;
|
||||
value = v === null ? "null" : v;
|
||||
value = typeof v === "number" && Number.isNaN(v) ? "NaN" : value;
|
||||
flatObject[prefix + k] = value;
|
||||
} else if (typeof v === "object") {
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
v,
|
||||
flatObject,
|
||||
prefix + k + ".",
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user