Merge branch 'livekit' into fkwp/change_video_codec

This commit is contained in:
Timo K
2025-12-01 17:58:23 +01:00
185 changed files with 13880 additions and 7198 deletions

View File

@@ -43,6 +43,29 @@ module.exports = {
// To encourage good usage of RxJS: // To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error", "rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }], "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: [ overrides: [
{ {

View File

@@ -10,7 +10,7 @@ jobs:
pull-requests: read pull-requests: read
steps: steps:
- name: Add notice - name: Add notice
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked') if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with: with:
script: | script: |

View File

@@ -23,7 +23,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Check it out - name: Check it out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download artifact - name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -34,7 +34,7 @@ jobs:
path: dist path: dist
- name: Log in to container registry - name: Log in to container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -42,7 +42,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}} tags: ${{ inputs.docker_tags}}
@@ -50,10 +50,10 @@ jobs:
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

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

View File

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

View File

@@ -44,6 +44,8 @@ jobs:
run: | run: |
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "TAG=latest" >> "$GITHUB_OUTPUT" echo "TAG=latest" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
echo "TAG=rc" >> "$GITHUB_OUTPUT"
else else
echo "TAG=other" >> "$GITHUB_OUTPUT" echo "TAG=other" >> "$GITHUB_OUTPUT"
fi fi
@@ -83,7 +85,7 @@ jobs:
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload - name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
files: | files: |
${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}.tar.gz
@@ -98,10 +100,10 @@ jobs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }} ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions: permissions:
contents: read contents: read
id-token: write # required for the provenance flag on npm publish id-token: write # Allow npm to authenticate as a trusted publisher
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download built element-call artifact - name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -124,8 +126,6 @@ jobs:
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" 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:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
- id: artifact_version - id: artifact_version
name: Output artifact version name: Output artifact version
@@ -142,7 +142,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download built element-call artifact - name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -163,6 +163,8 @@ jobs:
run: | run: |
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" 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 else
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV" echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
fi fi
@@ -195,7 +197,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with: with:
path: element-call path: element-call
@@ -208,7 +210,7 @@ jobs:
path: element-call/embedded/ios/Sources/dist path: element-call/embedded/ios/Sources/dist
- name: Checkout element-call-swift - name: Checkout element-call-swift
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with: with:
repository: element-hq/element-call-swift repository: element-hq/element-call-swift
path: element-call-swift path: element-call-swift
@@ -260,7 +262,7 @@ jobs:
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
- name: Add release notes - name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
append_body: true append_body: true
body: | body: |

View File

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

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- name: Yarn cache - name: Yarn cache
@@ -22,7 +22,7 @@ jobs:
- name: Vitest - name: Vitest
run: "yarn run test:coverage" run: "yarn run test:coverage"
- name: Upload to codecov - name: Upload to codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with: with:
@@ -30,10 +30,10 @@ jobs:
fail_ci_if_error: true fail_ci_if_error: true
playwright: playwright:
name: Run end-to-end tests name: Run end-to-end tests
timeout-minutes: 30 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable

View File

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

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ dist-ssr
.idea/ .idea/
public/config.json public/config.json
backend/synapse_tmp/* backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage /coverage
config.json config.json
@@ -28,4 +29,4 @@ yarn-error.log
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/

View File

@@ -1 +1 @@
22 24

View File

@@ -206,22 +206,22 @@ See also:
### Backend ### Backend
A docker compose file `dev-backend-docker-compose.yml` is provided to start the A docker compose file `dev-backend-docker-compose.yml` is provided to start the
whole stack of components which is required for a local development environment: whole stack of components which is required for a local development environment
including federation:
- Minimum Synapse Setup (servername: `synapse.m.localhost`) - Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy) - MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU Setup using dev defaults for config - Minimum LiveKit SFU setup using dev defaults for config
- Redis db for completeness
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS) - Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost` - Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted - Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
certificates certificates
- Minimum TLS reverse proxy for - Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` - Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` - MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host ` - Local Element Call development `call.m.localhost` via `yarn dev --host `
- Element Web `app.m.localhost` - Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thu, 03 May 2035 10:32:02 GMT - Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
These use a test 'secret' published in this repository, so this must be used These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._** only for local development and **_never be exposed to the public Internet._**

View File

@@ -0,0 +1,64 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_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
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
# This needs to match at least the heart-beat frequency plus a bit of headroom
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30

View File

@@ -19,8 +19,18 @@ database:
media_store_path: /data/media_store media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key" signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers: trusted_key_servers:
- server_name: "matrix.org" - server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
@@ -28,12 +38,21 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to # MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room. # correctly track the state of the room.
msc4222_enabled: true msc4222_enabled: true
# sticky events for matrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events. # duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging). # Ratelimiting settings for client actions (registration, login, messaging).
# #
# Each ratelimiting configuration is made of two parameters: # Each ratelimiting configuration is made of two parameters:
@@ -45,10 +64,3 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s # Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5 per_second: 0.5
burst_count: 30 burst_count: 30
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

View File

@@ -0,0 +1,20 @@
port: 17880
bind_addresses:
- "0.0.0.0"
rtc:
tcp_port: 17881
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
turn:
enabled: false
domain: localhost
cert_file: ""
key_file: ""
tls_port: 5349
udp_port: 443
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -6,11 +6,6 @@ rtc:
port_range_start: 50100 port_range_start: 50100
port_range_end: 50200 port_range_end: 50200
use_external_ip: false use_external_ip: false
#redis:
# address: redis:6379
# username: ""
# password: ""
# db: 0
turn: turn:
enabled: false enabled: false
domain: localhost domain: localhost

View File

@@ -1,4 +1,5 @@
# Synapse reverse proxy including .well-known/matrix/client # Synapse reverse proxy including .well-known/matrix/client
# domain synapse.m.localhost
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@@ -26,14 +27,53 @@ server {
# This is also required for development environment. # This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token # Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS # verification, which requires TLS
location / { location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver:8008"; proxy_pass "http://homeserver:8008";
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.othersite.m.localhost
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen 8448 ssl;
listen [::]:443 ssl;
listen [::]:8448 ssl;
server_name synapse.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
} }
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
@@ -41,6 +81,7 @@ server {
} }
# MatrixRTC reverse proxy # MatrixRTC reverse proxy
# domain matrix-rtc.m.localhost
# - MatrixRTC Authorization Service # - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection # - LiveKit SFU websocket signaling connection
upstream jwt-auth-services { upstream jwt-auth-services {
@@ -49,16 +90,14 @@ upstream jwt-auth-services {
} }
server { server {
listen 80;
listen [::]:80;
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl; listen [::]:443 ssl;
listen 8448 ssl;
listen [::]:8448 ssl;
server_name matrix-rtc.m.localhost; server_name matrix-rtc.m.localhost;
ssl_certificate /root/ssl/cert.pem; ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem; ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ { location ^~ /livekit/jwt/ {
@@ -94,6 +133,54 @@ server {
} }
# MatrixRTC reverse proxy
# domain matrix-rtc.othersite.m.localhost
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name matrix-rtc.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {
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;
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
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_send_timeout 120;
proxy_read_timeout 120;
proxy_buffering off;
proxy_set_header Accept-Encoding gzip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# LiveKit SFU websocket connection running at port 17880
proxy_pass http://livekit-sfu-1:17880/;
}
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host # Convenience reverse proxy for the call.m.localhost domain to yarn dev --host
server { server {
listen 80; listen 80;
@@ -159,3 +246,36 @@ server {
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
} }
# Convenience reverse proxy app.othersite.m.localhost for element web
server {
listen 80;
listen [::]:80;
server_name app.othersite.m.localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
location ^~ / {
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://element-web-1:18081;
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUGdiFHhH4KL2pqBjMQHQ+PVIkSV8wDQYJKoZIhvcNAQEL MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMDMy BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MDJaFw0zNTA1MDMxMDMyMDJaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA2y0hjmNn1vRsVSdy Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH9jdT QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
tG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeFw9fw SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
eRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZNWnie NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
Ui7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAveL9K DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
FGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BLPiQU X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
KGKrAgMBAAGjUDBOMB0GA1UdDgQWBBQJqBjMu61c1p24txw/y+kv3D+V6DAfBgNV Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBQJqBjMu61c1p24txw/y+kv3D+V6DAMBgNVHRMEBTADAQH/MA0GCSqG HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQB8m2YfFGLugNt5vAAOvNxVqDA8c72yCVYr3CBCpmTIEY5Z SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
d3qVGhG9//ux6+J8ntkSwd9nV5GJyYXHukCG1VavnAWolWdNF/WAllf0jhLuz7kD XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
/cJnuI1By4tBsBmSz851i6HJ4t5k99Be+6GQVzi0e7zzfxTHZE4xP2J6Ox8QbPsP 1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
n0m76nIp/WbWaJqzvIIjJhmUUPPv+4wN+eOArgjiGLzptM2qTtGZtd0c9nS5gvep KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
+mEbSUN9zkhAroZf80wf+hEvy+fJ94VbZ9QjTzTg7odZLrsXGIe8DaG63EYRQ25b EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
W5iYBAreln5fGSt7qHsGfqwZibTEk/Lx3dydO1Kg 7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDA2y0hjmNn1vRs MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
VSdy8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
9jdTtG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeF 1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
w9fweRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZN I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
WnieUi7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAv wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
eL9KFGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BL LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
PiQUKGKrAgMBAAECggEAAPX2kxi5AQ7ul82SzT1KgpSXyDHLdYaUyAoYnaX9RO+B YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
8ylmpyeqygs4+KQS4EMJm9jpo85Oy37bIKdG3kljU6wQcKlL5Y+ZUOo1nzpV6fid dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
hGVs6ts8VXw8KshKQ9AyccZ8L/pirUfgOffgTwfjY7/90zceAL/s98GuZWc62nkX DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
55joQv/OikqYfAGP/U6Bp2Zyf23DwJB09Z3B6NnZj/ZyAbDrDEHuA15LhCOcCczp ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
IU/mFEywBPHT9Tg4w4Beq78PeAETvku2UalYRLhP3RLlXr2oEbwUtINRVt2QjZ85 zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
Esps4uCqL/mgQluIebtudD9HL/YMlNPXue1mDXFxJQKBgQDgZZY4yJBcf488T1V6 rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
HNm06b/LvVGj253pKgw14hpY1xQu3Ymgzv1GEqzhSYdzxhpmj0tMUNHxAp+YdGQu XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
SZ0wcPKhw0aYVkIjDRYDC3Wn5GJhyIEYHGYMo/n4l49UzHRBPOTDzp49DkHTKBgh E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
XgIIazYT3CkjTIMRrkUv+qfIPQKBgQDcBGu/mqbjxs4sN3zqPS4aB21o6t6W0sXs +VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
ZP9w6RlTPQi5U2oRbftjZtYc0bbEgkMUImB1HwYPQT5pJ+MyC414xDvSc2exBr5d K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
To6yyPIy78Tf5PHM12fpKV92nSvoz/pSjYcGxxDtKfPqu+t8mOJfjCV1lLLA+xuB RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
DDaE4p8dBwKBgQCdAne6A5v/HMH8UQZeCxHJpESvKiiVnnU/UEx651nID7XvlNNX ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
0X0mKqsMd4ZvW43ddSYan/JF0LAa3FW8jYWO/3jF9vzOWoysOdvNBZetgf/Uq5ao JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
aDZ/YbzmVCXWD7jIbPMkjs3pqrAkL0mzDzQc7+dGviWKrV6IYIfIqnn7gQKBgDCz m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
vdIk/qpO+JZrFfiX4Fucp0hhLTJ/p5ZDaRPqVVPKn+K+Jy2ChfIj8mNgvK9VEloj kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
nexvGJ1J2PHYBX+vdPp1nbRhHWPfVUY8PHQw7QP/dToGaMvqJrNDGEGeWvjnCMc7 fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
UtdaO1H0Rm0AegkTopB56lTTvJnhO95eALd7nrMDAoGAEPdzJtWoKafp49svhSj0 XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
hiXQv2SPBwVUN4LZ4SOWiXUcmYYm80aNpYKLkBxYjrfqFWhE7NUHLGp8YorQWKY2 0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
acD9AReHk/xku0ABy6jeYmSCmCxASxst5liKD+l12sk0gB0rk5MBxB4Uu1MIbQZ2 sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
aCASX3AVD2/XyC2MKkzc8Eg= QlmeMCxeWyPYqoMfwKmdtDP/Iw==
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIDZzCCAk+gAwIBAgIUXizLjwkdqepX0bh0K3abeJxj68IwDQYJKoZIhvcNAQEL MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMzU5 BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MTFaFw0zNTA1MDMxMzU5MTFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrzGSScSgaQuZdELGFYiLiYRwr MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
LKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI39WmH95aX3d+A qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
U7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ouuN6AOdzWs8hp EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
RYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFft1ZXxIZOCjDs 0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
rEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71IfieLCEzMTP1VXa fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
tP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZrQUGnL2RtAgMB PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgaIwgZ8wHwYDVR0jBBgwFoAUCagYzLutXNaduLccP8vpL9w/legwCQYDVR0T AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwMAYDVR0RBCkw BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
J4IJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdDAdBgNVHQ4E QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
FgQUfdh1p52ZgWyZcBgBXGwKi4EnUE0wDQYJKoZIhvcNAQELBQADggEBAKrHEuB6 cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
33j8+EwSHw3zrvt/DRXK2BDHI1Ir9JcztSunaKAjZXVvf/dvZp0Xs1dEdJIdnv6G MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
iZYhBbOqDqpQZbf2h/h0kuu5yZSBUdnQXnYNxlhp2UaC/UEgw5iZT/p1rm7RjVie K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
y4Dp2WytV5iZOLmLj6xDvd3DXazgJPWIRX8p8qJZbKTkwCjTr7nDIj8jjG1sVFf7 LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
1RJBO5/6WSnImrpDmlLUrvjiKvbxcdseDJyBOhTwdRdSk4S2M+s5tR5j2I1gXLOq d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
J5ioN76+SCrTY0K0WKRy9oOXWO1/X3+VYcekp+0F3SGkd5w17jylCv1XIGHAdEsQ 92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
v2z2/aMI/7sAD2Q= AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrzGSScSgaQuZd MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
ELGFYiLiYRwrLKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI3 Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
9WmH95aX3d+AU7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ou JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
uN6AOdzWs8hpRYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFf 7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
t1ZXxIZOCjDsrEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71Ifi JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
eLCEzMTP1VXatP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZr G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
QUGnL2RtAgMBAAECggEAJaFQii8U/KOYt9vXNoMnZvSkaeSQLLhn2V6Kciu1CtWE neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
aMTWLsFE6nk+G5xXkYcTmM3T0GghtH3u5CjyI6EcsEkeEorCZJt0wbmayDmqiekR uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
LfMzOdHuTHX5+edPgMGYYG1BFyRKyYFsjH1b5zRFZhXdGQnrl5760GsVlz9D1KZQ YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
iHcT+q1S2tmZeoUukQnADENKXUMCyTGM5FCddgNtsWnGDsTDayh7hUdvDkB+mW4G X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
lSp+BZuc3PCwpbD6qkXvfugWs6CUAAtXoV3ceWgxQ+TEnNlwxaG1AyugfgNUBolk zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
8xgeZt4r5QId03jsHDf7hpBAofcaCd5EMIIQYFvWoQKBgQDlbAvAzEFPTZZn2nRV OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
Xagw4xjqVc1LLEKLCWq0N5rEkwn0h90Dz5N7/3NuonP/sIDsDHCbyiOYBI1Ck6Xi kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
0WuB+OyKDh+xeF2mekN9G9ywPahdK5lT/TVsxXFyZlwtVv1x/6KBO4yv5URizxqU xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
gyAPDDxfD/KcNjkOBaodWEwQGQKBgQC/s2gPDBtQkjLwkHXchBomLww5eLlVrac1 zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
WK4UX6uSdOgrjJ375OOgMTxe8NVZdOuAKytGXRWDwgH3nVWvuZhe7dGlX3JMuSer xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
e9VwDpBESrvqcR4ruL6wm8wej6BXyjH0wD3FHb0S5HfuBDxTn+4bDwrbRzOUMNgy BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
lSppuflxdQKBgQDiZcIfazFT8evn5nMAvuC4BZNTxIJHmZC9JfjPiUPIkpWzYtOe izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
7BvNtKOT3Op9uw8uYYRKqKqBXJSNy6ha8XCXHS9HeXKbLn20SFkLQBCDNwVLlDfF T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
40zyXtF6JDr4XyzSb4NM5pgKCER5AYloXxGm59s3sEQpFXUuOjbKqJS/GQKBgAoI NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
c7vF4HAZFr1sch62cz/oWnVvkhOf4Q5zs7ixQSOLJtOQqnwSgK9TpFs7s47ZBbJR YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
kBRAru2Ua9Hv1Bo8VnMxczV6h1roneDlvEf/GyHX33nnrbKQGrrXjJlU3wl5NaAf mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
p5v3cHvapUQ5yIZ/6lBUOzc6xMJOxCHxmKSr7Rg5AoGAbEE4lt6Xh2dnBPJ81eNI srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
IDrw/3ITY53qAY4Bx88CByIFuu8CEUdUZprh98jSl6ic1tMinZfUhRMwABLrUD51 nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
DGst8iGLPD9u83iMcUHI/L+p7AbxrKLvWXZrF5UZm440c9mSWqfhPaTBosPtNDsG +2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
LfETwH1flKXMTXd2xA9RTE4= qYgXLjun9dht6kx9vGJ69wI=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@@ -34,5 +34,6 @@ subjectAltName = @alt_names
DNS.1 = localhost DNS.1 = localhost
DNS.2 = m.localhost DNS.2 = m.localhost
DNS.3 = *.m.localhost DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
EOF EOF
) )

View File

@@ -0,0 +1,53 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://synapse.othersite.m.localhost",
"server_name": "synapse.othersite.m.localhost"
}
},
"disable_custom_urls": false,
"disable_guests": false,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"force_verification": false,
"brand": "Element",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"default_widget_container_height": 280,
"default_country_code": "GB",
"show_labs_settings": false,
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
"room_directory": {
"servers": ["matrix.org"]
},
"enable_presence_by_hs_url": {
"https://matrix.org": false,
"https://matrix-client.matrix.org": false
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
},
"element_call": {
"participant_limit": 8,
"brand": "Element Call"
},
"map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx"
}

View File

@@ -0,0 +1,81 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_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
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000

View File

@@ -19,8 +19,18 @@ database:
media_store_path: /data/media_store media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key" signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers: trusted_key_servers:
- server_name: "matrix.org" - server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
@@ -34,6 +44,13 @@ experimental_features:
# duration (null), which disallows sending delayed events. # duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging). # Ratelimiting settings for client actions (registration, login, messaging).
# #
# Each ratelimiting configuration is made of two parameters: # Each ratelimiting configuration is made of two parameters:
@@ -44,6 +61,10 @@ rc_message:
per_second: 10000 per_second: 10000
burst_count: 10000 burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login: rc_login:
address: address:
per_second: 10000 per_second: 10000
@@ -58,10 +79,3 @@ rc_login:
rc_registration: rc_registration:
per_second: 10000 per_second: 10000
burst_count: 10000 burst_count: 10000
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

View File

@@ -1,5 +0,0 @@
bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300

View File

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

View File

@@ -18,7 +18,7 @@
"api_host": "https://posthog-element-call.element.io" "api_host": "https://posthog-element-call.element.io"
}, },
"rageshake": { "rageshake": {
"submit_url": "https://element.io/bugreports/submit" "submit_url": "https://rageshakes.element.io/api/submit"
}, },
"sentry": { "sentry": {
"environment": "netlify-pr-preview", "environment": "netlify-pr-preview",

View File

@@ -24,8 +24,31 @@ services:
networks: networks:
- ecbackend - ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server-1
environment:
- LIVEKIT_JWT_PORT=16080
- LIVEKIT_URL=wss://matrix-rtc.othersite.m.localhost/livekit/sfu
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=secret
# If the configured homeserver runs on localhost, it'll probably be using
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
deploy:
restart_policy:
condition: on-failure
ports:
# HOST_PORT:CONTAINER_PORT
- 16080:16080
networks:
- ecbackend
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml command: --dev --config /etc/livekit.yaml
restart: unless-stopped restart: unless-stopped
@@ -43,20 +66,30 @@ services:
networks: networks:
- ecbackend - ecbackend
redis: livekit-1:
image: redis:6-alpine image: livekit/livekit-server:latest
command: redis-server /etc/redis.conf pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
# The SFU seems to work far more reliably when we let it share the host
# network rather than opening specific ports (but why?? we're not missing
# any…)
ports: ports:
# HOST_PORT:CONTAINER_PORT # HOST_PORT:CONTAINER_PORT
- 6379:6379 - 17880:17880/tcp
- 17881:17881/tcp
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes: volumes:
- ./backend/redis.conf:/etc/redis.conf:Z - ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
networks: networks:
- ecbackend - ecbackend
synapse: synapse:
hostname: homeserver hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment: environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does # Needed for rootless podman-compose such that the uid/gid mapping does
@@ -71,6 +104,24 @@ services:
networks: networks:
- ecbackend - ecbackend
synapse-1:
hostname: homeserver-1
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
# fit local user uid. If the container runs as root (uid 0) it is fine as
# it actually maps to your non-root user on the host (e.g. 1000).
# Otherwise uid mapping will not match your non-root user.
- UID=0
- GID=0
volumes:
- ./backend/synapse_tmp_othersite:/data:Z
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
networks:
- ecbackend
element-web: element-web:
image: ghcr.io/element-hq/element-web:develop image: ghcr.io/element-hq/element-web:develop
pull_policy: always pull_policy: always
@@ -83,10 +134,24 @@ services:
networks: networks:
- ecbackend - ecbackend
element-web-1:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
volumes:
- ./backend/ew.test.othersite.config.json:/app/config.json:Z
environment:
ELEMENT_WEB_PORT: 18081
ports:
# HOST_PORT:CONTAINER_PORT
- "18081:18081"
networks:
- ecbackend
nginx: nginx:
# see backend/dev_tls_setup for how to generate the tls certs # see backend/dev_tls_setup for how to generate the tls certs
hostname: synapse.m.localhost hostname: synapse.m.localhost
image: nginx:latest image: nginx:latest
pull_policy: always
volumes: volumes:
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z - ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z - ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z
@@ -104,4 +169,7 @@ services:
networks: networks:
ecbackend: ecbackend:
aliases: aliases:
- synapse.m.localhost
- synapse.othersite.m.localhost
- matrix-rtc.m.localhost - matrix-rtc.m.localhost
- matrix-rtc.othersite.m.localhost

View File

@@ -126,6 +126,32 @@ server {
} }
``` ```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
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}
}
}
# Default route for livekit
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}
}
}
```
#### MatrixRTC backend announcement #### MatrixRTC backend announcement
> [!IMPORTANT] > [!IMPORTANT]
@@ -214,7 +240,7 @@ server {
There are currently two different config files. `.env` holds variables that are There are currently two different config files. `.env` holds variables that are
used at build time, while `public/config.json` holds variables that are used at used at build time, while `public/config.json` holds variables that are used at
runtime. Documentation and default values for `public/config.json` can be found runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](src/config/ConfigOptions.ts). in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
> [!CAUTION] > [!CAUTION]
> Please note configuring MatrixRTC backend via `config.json` of > Please note configuring MatrixRTC backend via `config.json` of
@@ -257,6 +283,7 @@ self-hosters and developers working with Element Call.
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/) - [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/) - [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/) - [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 ## 🛠️ Tools

View File

@@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `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 users default homeserver. | | `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 users 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. | | `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 ### Widget-only parameters
@@ -94,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. | | `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. | | `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://element.io/bugreports/submit`. 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. |
| `sentryDsn` | Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | No | 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. | | `sentryEnvironment` | Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/) | No | In full package the value from config is used. |

View File

@@ -2,7 +2,7 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions] [versions]
android_gradle_plugin = "8.11.1" android_gradle_plugin = "8.13.0"
[libraries] [libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }

View File

@@ -64,6 +64,14 @@
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}", "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", "debug_tile_layout_label": "Debug tile layout",
"device_id": "Device ID: {{id}}", "device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant", "duplicate_tiles_label": "Number of additional tile copies per participant",
@@ -72,12 +80,24 @@
"livekit_server_info": "LiveKit Server Info", "livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}", "livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}", "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)", "mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"show_connection_stats": "Show connection statistics", "show_connection_stats": "Show connection statistics",
"show_non_member_tiles": "Show tiles for non-member media", "url_params": "URL parameters"
"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.", "disconnected_banner": "Connectivity to the server has been lost.",
"error": { "error": {
@@ -92,7 +112,7 @@
"generic_description": "Submitting debug logs will help us track down the problem.", "generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity", "insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.", "insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"open_elsewhere": "Opened in another tab", "open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",

View File

@@ -44,7 +44,7 @@
"@formatjs/intl-segmenter": "^11.7.3", "@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0", "@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0", "@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.38.0", "@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5", "@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18", "@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0", "@opentelemetry/api": "^1.4.0",
@@ -54,7 +54,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3", "@radix-ui/react-visually-hidden": "^1.0.3",
@@ -71,7 +71,7 @@
"@types/grecaptcha": "^3.0.9", "@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.0.0", "@types/node": "^24.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -99,6 +99,7 @@
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-unicorn": "^56.0.0", "eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0", "global-jsdom": "^26.0.0",
"i18next": "^24.0.0", "i18next": "^24.0.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
@@ -108,7 +109,7 @@
"livekit-client": "^2.13.0", "livekit-client": "^2.13.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21",
"matrix-widget-api": "^1.13.0", "matrix-widget-api": "^1.13.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",
@@ -129,7 +130,7 @@
"typescript-eslint-language-service": "^5.0.5", "typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0", "unique-names-generator": "^4.6.0",
"vaul": "^1.0.0", "vaul": "^1.0.0",
"vite": "^6.0.0", "vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0", "vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0", "vite-plugin-svgr": "^4.0.0",

View File

@@ -2,3 +2,6 @@ services:
synapse: synapse:
volumes: volumes:
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z - ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
synapse-1:
volumes:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z

View File

@@ -40,6 +40,11 @@ test("Start a new call then leave and show the feedback screen", async ({
// The tooltip with the name should be visible // The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe"); await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// Resize the window to resemble a small mobile phone
await page.setViewportSize({ width: 350, height: 660 });
// We should still be able to send reactions at this screen size
await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible();
// leave the call // leave the call
await page.getByTestId("incall_leave").click(); await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText( await expect(page.getByRole("heading")).toContainText(

View File

@@ -159,8 +159,12 @@ export const widgetTest = test.extend<MyFixtures>({
} = await registerUser(browser, userB); } = await registerUser(browser, userB);
// Invite the second user // Invite the second user
await ewPage1.getByRole("button", { name: "Add room" }).click(); await ewPage1
await ewPage1.getByText("New room").click(); .getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click(); await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible(); await expect(ewPage1.getByText("You created this room.")).toBeVisible();
@@ -184,9 +188,9 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the invite // Accept the invite
await expect( await expect(
ewPage2.getByRole("treeitem", { name: "Welcome Room" }), ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible(); ).toBeVisible();
await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click(); await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click(); await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect( await expect(
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),

View File

@@ -0,0 +1,60 @@
/*
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 { expect, test } from "@playwright/test";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode',
);
test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("Test user");
// If we do not call fastForward here, we end up with Date.now() returning an actual timestamp
// but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0
// and we will never get into probablyDisconnected state?
await page.clock.fastForward(10);
await page.getByTestId("home_go").click();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// The media tile for the local user should become visible
await new Promise((resolve) => setTimeout(resolve, 1500));
await expect(page.getByTestId("name_tag")).toContainText("Test user");
// Now disconnect from the internet
await page.route("https://synapse.m.localhost/**/*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.clock.fastForward(20000);
await expect(
page.getByRole("dialog", { name: "Reconnecting…" }),
).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus();
await expect(
page.getByRole("button", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
});

View File

@@ -49,7 +49,10 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
// Check the join indicator on the room list // Check the join indicator on the room list
await expect( await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }), brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("button", { name: "End call" }),
).toBeVisible(); ).toBeVisible();
// Join from the other side // Join from the other side
@@ -59,26 +62,28 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
).toBeVisible(); ).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click(); await whistler.page.getByRole("button", { name: "Join" }).click();
await expect( // Currently disabled due to recent Element Web is bypassing Lobby
whistler.page // await expect(
.locator('iframe[title="Element Call"]') // whistler.page
.contentFrame() // .locator('iframe[title="Element Call"]')
.getByTestId("lobby_joinCall"), // .contentFrame()
).toBeVisible(); // .getByTestId("lobby_joinCall"),
// ).toBeVisible();
//
// await whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall")
// .click();
await whistler.page // Currrenty disabled due to recent Element Web not indicating the number of participants
.locator('iframe[title="Element Call"]') // await expect(
.contentFrame() // whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
.getByTestId("lobby_joinCall") // ).toBeVisible();
.click();
await expect( // await expect(
whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), // brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible(); // ).toBeVisible();
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
// Whistler leaves // Whistler leaves
await whistler.page.waitForTimeout(1000); await whistler.page.waitForTimeout(1000);

View File

@@ -23,14 +23,6 @@ export function useMediaDevices(): MediaDevices {
return 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. * A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return * It will check the `useAsEarpiece` of the `audioOutput` device and return

View File

@@ -35,6 +35,8 @@ Please see LICENSE in the repository root for full details.
.bg.animate[data-state="closed"] { .bg.animate[data-state="closed"] {
animation: fade-out 130ms; animation: fade-out 130ms;
opacity: 0;
pointer-events: none;
} }
.overlay { .overlay {

View File

@@ -19,10 +19,26 @@ import mediaViewStyles from "../src/tile/MediaView.module.css";
interface Props { interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
focusUrl?: 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 // This is only used in developer mode for debugging purposes, so we don't need full localization
export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => { export const RTCConnectionStats: FC<Props> = ({
audio,
video,
focusUrl,
...rest
}) => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [modalContents, setModalContents] = useState< const [modalContents, setModalContents] = useState<
"video" | "audio" | "none" "video" | "audio" | "none"
@@ -55,6 +71,13 @@ export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => {
</pre> </pre>
</div> </div>
</Modal> </Modal>
{focusUrl && (
<div>
<Text as="span" size="xs" title="focusURL">
&nbsp;{extractDomain(focusUrl)}
</Text>
</div>
)}
{audio && ( {audio && (
<div> <div>
<Button <Button

View File

@@ -46,11 +46,11 @@ interface Props {
*/ */
Icon?: ComponentType<SVGAttributes<SVGElement>>; Icon?: ComponentType<SVGAttributes<SVGElement>>;
/** /**
* Whether the toast should be portaled into the root of the document (rather * Whether the toast should be modal, making it fill the screen (by portalling
* than rendered in-place within the component tree). * it into the root of the document) and trap focus until dismissed.
* @default true * @default true
*/ */
portal?: boolean; modal?: boolean;
} }
/** /**
@@ -62,7 +62,7 @@ export const Toast: FC<Props> = ({
autoDismiss, autoDismiss,
children, children,
Icon, Icon,
portal = true, modal = true,
}) => { }) => {
const onOpenChange = useCallback( const onOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
@@ -103,8 +103,8 @@ export const Toast: FC<Props> = ({
); );
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange}> <DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{portal ? <DialogPortal>{content}</DialogPortal> : content} {modal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot> </DialogRoot>
); );
}; };

View File

@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it, onTestFinished, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import * as PlatformMod from "../src/Platform";
import { import {
getRoomIdentifierFromUrl, getRoomIdentifierFromUrl,
getUrlParams, computeUrlParams,
HeaderStyle, HeaderStyle,
getUrlParams,
} from "../src/UrlParams"; } from "../src/UrlParams";
const ROOM_NAME = "roomNameHere"; const ROOM_NAME = "roomNameHere";
@@ -103,16 +106,16 @@ describe("UrlParams", () => {
describe("preload", () => { describe("preload", () => {
it("defaults to false", () => { it("defaults to false", () => {
expect(getUrlParams().preload).toBe(false); expect(computeUrlParams().preload).toBe(false);
}); });
it("ignored in SPA mode", () => { it("ignored in SPA mode", () => {
expect(getUrlParams("?preload=true").preload).toBe(false); expect(computeUrlParams("?preload=true").preload).toBe(false);
}); });
it("respected in widget mode", () => { it("respected in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).preload, ).preload,
).toBe(true); ).toBe(true);
@@ -121,19 +124,20 @@ describe("UrlParams", () => {
describe("returnToLobby", () => { describe("returnToLobby", () => {
it("is false in SPA mode", () => { it("is false in SPA mode", () => {
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false); expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
}); });
it("defaults to false in widget mode", () => { it("defaults to false in widget mode", () => {
expect( expect(
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo") computeUrlParams(
.returnToLobby, "?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(false); ).toBe(false);
}); });
it("respected in widget mode", () => { it("respected in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby, ).returnToLobby,
).toBe(true); ).toBe(true);
@@ -142,12 +146,12 @@ describe("UrlParams", () => {
describe("userId", () => { describe("userId", () => {
it("is ignored in SPA mode", () => { it("is ignored in SPA mode", () => {
expect(getUrlParams("?userId=asd").userId).toBe(null); expect(computeUrlParams("?userId=asd").userId).toBe(null);
}); });
it("is parsed in widget mode", () => { it("is parsed in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).userId, ).userId,
).toBe("asd"); ).toBe("asd");
@@ -156,12 +160,12 @@ describe("UrlParams", () => {
describe("deviceId", () => { describe("deviceId", () => {
it("is ignored in SPA mode", () => { it("is ignored in SPA mode", () => {
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null); expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
}); });
it("is parsed in widget mode", () => { it("is parsed in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).deviceId, ).deviceId,
).toBe("asd"); ).toBe("asd");
@@ -170,12 +174,12 @@ describe("UrlParams", () => {
describe("baseUrl", () => { describe("baseUrl", () => {
it("is ignored in SPA mode", () => { it("is ignored in SPA mode", () => {
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null); expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
}); });
it("is parsed in widget mode", () => { it("is parsed in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).baseUrl, ).baseUrl,
).toBe("asd"); ).toBe("asd");
@@ -185,28 +189,28 @@ describe("UrlParams", () => {
describe("viaServers", () => { describe("viaServers", () => {
it("is ignored in widget mode", () => { it("is ignored in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).viaServers, ).viaServers,
).toBe(null); ).toBe(null);
}); });
it("is parsed in SPA mode", () => { it("is parsed in SPA mode", () => {
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd"); expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
}); });
}); });
describe("homeserver", () => { describe("homeserver", () => {
it("is ignored in widget mode", () => { it("is ignored in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).homeserver, ).homeserver,
).toBe(null); ).toBe(null);
}); });
it("is parsed in SPA mode", () => { it("is parsed in SPA mode", () => {
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd"); expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
}); });
}); });
@@ -228,7 +232,7 @@ describe("UrlParams", () => {
const startNewCallDefaults = (platform: string): object => ({ const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true, confineToRoom: true,
appPrompt: false, appPrompt: false,
preload: true, preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true, showControls: true,
hideScreensharing: false, hideScreensharing: false,
@@ -237,12 +241,12 @@ describe("UrlParams", () => {
controlledAudioDevices: platform === "desktop" ? false : true, controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true, skipLobby: true,
returnToLobby: false, returnToLobby: false,
sendNotificationType: "notification", sendNotificationType: platform === "desktop" ? "notification" : "ring",
}); });
const joinExistingCallDefaults = (platform: string): object => ({ const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true, confineToRoom: true,
appPrompt: false, appPrompt: false,
preload: true, preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true, showControls: true,
hideScreensharing: false, hideScreensharing: false,
@@ -252,24 +256,55 @@ describe("UrlParams", () => {
skipLobby: false, skipLobby: false,
returnToLobby: false, returnToLobby: false,
sendNotificationType: "notification", sendNotificationType: "notification",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
}); });
it("use no-intent-defaults with unknown intent", () => { it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults); expect(computeUrlParams()).toMatchObject(noIntentDefaults);
}); });
it("ignores intent if it is not a valid value", () => { it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults); expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
}); });
it("accepts start_call", () => { it("accepts start_call", () => {
expect( expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"), computeUrlParams(
).toMatchObject(startNewCallDefaults("desktop")); "?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
});
it("accepts start_call_dm mobile", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(startNewCallDefaults("android"));
});
it("accepts start_call_dm mobile and prioritizes overwritten params", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org&sendNotificationType=notification",
),
).toMatchObject({
...startNewCallDefaults("android"),
sendNotificationType: "notification",
});
}); });
it("accepts join_existing", () => { it("accepts join_existing", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org", "?intent=join_existing&widgetId=1234&parentUrl=parent.org",
), ),
).toMatchObject(joinExistingCallDefaults("desktop")); ).toMatchObject(joinExistingCallDefaults("desktop"));
@@ -278,31 +313,55 @@ describe("UrlParams", () => {
describe("skipLobby", () => { describe("skipLobby", () => {
it("defaults to false", () => { it("defaults to false", () => {
expect(getUrlParams().skipLobby).toBe(false); expect(computeUrlParams().skipLobby).toBe(false);
}); });
it("defaults to false if intent is start_call in SPA mode", () => { it("defaults to false if intent is start_call in SPA mode", () => {
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false); expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
}); });
it("defaults to true if intent is start_call in widget mode", () => { it("defaults to false if intent is start_call in widget mode", () => {
expect( expect(
getUrlParams( computeUrlParams(
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo", "?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).skipLobby, ).skipLobby,
).toBe(true); ).toBe(false);
}); });
it("default to false if intent is join_existing", () => { it("default to false if intent is join_existing", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false); expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
}); });
}); });
describe("header", () => { describe("header", () => {
it("uses header if provided", () => { it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe( expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar", "app_bar",
); );
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none"); expect(computeUrlParams("?header=none&hideHeader=false").header).toBe(
"none",
);
});
});
describe("getUrlParams", () => {
it("uses cached values", () => {
const spy = vi.spyOn(logger, "info");
// call get once
const params = getUrlParams("?header=app_bar&hideHeader=true", "");
// call get twice
expect(getUrlParams("?header=app_bar&hideHeader=true", "")).toBe(params);
// expect compute to only be called once
// it will only log when it is computing the values
expect(spy).toHaveBeenCalledExactlyOnceWith(
"UrlParams: final set of url params\n",
"intent:",
"unknown",
"\nproperties:",
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
}); });
}); });
}); });

View File

@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react"; import { useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc"; import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es"; import { pickBy } from "lodash-es";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
@@ -26,7 +29,9 @@ export enum UserIntent {
StartNewCall = "start_call", StartNewCall = "start_call",
JoinExistingCall = "join_existing", JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm", StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm", JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown", Unknown = "unknown",
} }
@@ -216,6 +221,27 @@ export interface UrlConfiguration {
* This is one part to make the call matrixRTC session behave like a telephone call. * This is one part to make the call matrixRTC session behave like a telephone call.
*/ */
autoLeaveWhenOthersLeft: boolean; autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: 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 // If you need to add a new flag to this interface, prefer a name that describes
@@ -223,7 +249,10 @@ export interface UrlConfiguration {
// the situations that call for this behavior ('isEmbedded'). This makes it // the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's // clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers. // 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 // 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 // takes a UrlParams and returns a query string. That would enable us to
@@ -299,8 +328,14 @@ class ParamParser {
} }
} }
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/** /**
* Gets the app parameters for the current URL. * Gets the url params and loads them from a cache if already computed.
* @param search The URL search string * @param search The URL search string
* @param hash The URL hash * @param hash The URL hash
* @returns The app parameters encoded in the URL * @returns The app parameters encoded in the URL
@@ -309,6 +344,26 @@ export const getUrlParams = (
search = window.location.search, search = window.location.search,
hash = window.location.hash, hash = window.location.hash,
): UrlParams => { ): UrlParams => {
if (
urlParamCache.search === search &&
urlParamCache.hash === hash &&
urlParamCache.params
) {
return urlParamCache.params;
}
const params = computeUrlParams(search, hash);
urlParamCache = { search, hash, params };
return params;
};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
*/
export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const parser = new ParamParser(search, hash); const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? ""); const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -332,11 +387,10 @@ export const getUrlParams = (
? UserIntent.Unknown ? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset. // Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration; let intentPreset: UrlConfiguration = {
const inAppDefault = {
confineToRoom: true, confineToRoom: true,
appPrompt: false, appPrompt: false,
preload: true, preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true, showControls: true,
hideScreensharing: false, hideScreensharing: false,
@@ -345,35 +399,38 @@ export const getUrlParams = (
controlledAudioDevices: platform === "desktop" ? false : true, controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true, skipLobby: true,
returnToLobby: false, returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType, sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false, autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
}; };
switch (intent) { switch (intent) {
case UserIntent.StartNewCall: case UserIntent.StartNewCall:
intentPreset = { intentPreset.skipLobby = false;
...inAppDefault, intentPreset.callIntent = "video";
skipLobby: true,
};
break; break;
case UserIntent.JoinExistingCall: case UserIntent.JoinExistingCall:
intentPreset = { // On desktop this will be overridden based on which button was used to join the call
...inAppDefault, intentPreset.skipLobby = false;
skipLobby: false, intentPreset.callIntent = "video";
};
break; break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM: case UserIntent.StartNewCallDM:
intentPreset = { intentPreset.skipLobby = true;
...inAppDefault, intentPreset.sendNotificationType = "ring";
skipLobby: true, intentPreset.autoLeaveWhenOthersLeft = true;
autoLeaveWhenOthersLeft: true, intentPreset.waitForCallPickup = true;
}; intentPreset.callIntent = intentPreset.callIntent ?? "video";
break; break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM: case UserIntent.JoinExistingCallDM:
intentPreset = { // On desktop this will be overridden based on which button was used to join the call
...inAppDefault, intentPreset.skipLobby = true;
skipLobby: true, intentPreset.autoLeaveWhenOthersLeft = true;
autoLeaveWhenOthersLeft: true, intentPreset.callIntent = intentPreset.callIntent ?? "video";
};
break; break;
// Non widget usecase defaults // Non widget usecase defaults
default: default:
@@ -391,9 +448,33 @@ export const getUrlParams = (
returnToLobby: false, returnToLobby: false,
sendNotificationType: undefined, sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false, autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
}; };
} }
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 = { const properties: UrlProperties = {
widgetId, widgetId,
parentUrl, parentUrl,
@@ -442,13 +523,29 @@ export const getUrlParams = (
"ring", "ring",
"notification", "notification",
]), ]),
waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
}; };
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return { return {
...properties, ...properties,
...intentPreset, ...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined), ...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
}; };
}; };

View File

@@ -6,21 +6,22 @@ Please see LICENSE in the repository root for full details.
*/ */
import { act, render } from "@testing-library/react"; import { act, render } from "@testing-library/react";
import { expect, test } from "vitest"; import { expect, test, vi } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { ReactionToggleButton } from "./ReactionToggleButton"; import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions"; import { ElementCallReactionEventType } from "../reactions";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures"; import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test"; import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`; vi.mock("livekit-client/e2ee-worker?worker");
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
function TestComponent({ function TestComponent({
rtcSession, rtcSession,
@@ -33,7 +34,7 @@ function TestComponent({
<TooltipProvider> <TooltipProvider>
<ReactionsSenderProvider <ReactionsSenderProvider
vm={vm} vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession} rtcSession={rtcSession.asMockedSession()}
> >
<ReactionToggleButton vm={vm} identifier={localIdent} /> <ReactionToggleButton vm={vm} identifier={localIdent} />
</ReactionsSenderProvider> </ReactionsSenderProvider>

View File

@@ -33,7 +33,7 @@ import {
ReactionsRowSize, ReactionsRowSize,
} from "../reactions"; } from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="«rb5»" aria-labelledby="«rbb»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"

View File

@@ -122,7 +122,7 @@ export interface ConfigOptions {
delayed_leave_event_delay_ms?: number; delayed_leave_event_delay_ms?: number;
/** /**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed. * The time (in milliseconds) after which 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. * 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 * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs

View File

@@ -14,7 +14,7 @@ import {
import { distinctUntilChanged } from "rxjs"; import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { type GridLayout as GridLayoutModel } from "../state/CallViewModel"; import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";

View File

@@ -9,7 +9,7 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel"; import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode, useCallback } from "react"; import { type ReactNode, useCallback } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css"; import styles from "./SpotlightExpandedLayout.module.css";

View File

@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel"; import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightLandscapeLayout.module.css"; import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";

View File

@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightPortraitLayout.module.css"; import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";

View File

@@ -113,19 +113,49 @@ 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 => { const roomHasCallMembershipEvents = (room: Room): boolean => {
switch (room.getMyMembership()) { // Check our room membership first, to rule out any rooms
case KnownMembership.Join: // we can't have a call in.
return !!room const myMembership = room.getMyMembership();
.getLiveTimeline() if (myMembership === KnownMembership.Knock) {
.getState(EventTimeline.FORWARDS) // Assume that a room you've knocked on is able to hold calls
?.events?.get(EventType.GroupCallMemberPrefix); return true;
case KnownMembership.Knock: } else if (myMembership !== KnownMembership.Join) {
// Assume that a room you've knocked on is able to hold calls // Otherwise, non-joined rooms should never show up.
return true; return false;
default:
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) {
return true;
}
}
// 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[] { export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {

View File

@@ -6,20 +6,28 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { render, type RenderResult } from "@testing-library/react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { import {
getTrackReferenceId, getTrackReferenceId,
type TrackReference, type TrackReference,
} from "@livekit/components-core"; } from "@livekit/components-core";
import { type RemoteAudioTrack } from "livekit-client"; import {
type Participant,
type RemoteAudioTrack,
type Room,
Track,
} from "livekit-client";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react"; import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test"; import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext"; import * as MediaDevicesContext from "../MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import { mockMediaDevices, mockTrack } from "../utils/test"; import {
mockMediaDevices,
mockRemoteParticipant,
mockTrack,
} from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext); export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
@@ -48,42 +56,203 @@ vi.mock("@livekit/components-react", async (importOriginal) => {
}; };
}); });
const tracks = [mockTrack("test:123")]; let tracks: TrackReference[] = [];
vi.mocked(useTracks).mockReturnValue(tracks);
it("should render for member", () => { /**
const { container, queryAllByTestId } = render( * 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({})}> <MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer <LivekitRoomAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]} validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom}
url={""}
/> />
</MediaDevicesProvider>, </MediaDevicesProvider>,
); );
}
it("should render for member", () => {
const { container, queryAllByTestId } = renderTestComponent(
[{ userId: "@alice", deviceId: "DEV0" }],
["@alice:DEV0"],
);
expect(container).toBeTruthy(); expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1); expect(queryAllByTestId("audio")).toHaveLength(1);
}); });
it("should not render without member", () => { it("should not render without member", () => {
const memberships = [ const { container, queryAllByTestId } = renderTestComponent(
{ sender: "othermember", deviceId: "123" }, [{ userId: "@bob", deviceId: "DEV0" }],
] as CallMembership[]; ["@alice:DEV0"],
const { container, queryAllByTestId } = render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer members={memberships} />
</MediaDevicesProvider>,
); );
expect(container).toBeTruthy(); expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0); 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.", () => { it("should not setup audioContext gain and pan if there is no need to.", () => {
render( renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1); expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
@@ -100,13 +269,8 @@ it("should setup audioContext gain and pan", () => {
pan: 1, pan: 1,
volume: 0.1, volume: 0.1,
}); });
render(
<MediaDevicesProvider value={mockMediaDevices({})}> renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalled(); expect(audioTrack.setAudioContext).toHaveBeenCalled();

View File

@@ -6,15 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { getTrackReferenceId } from "@livekit/components-core"; import { getTrackReferenceId } from "@livekit/components-core";
import { type Room as LivekitRoom } from "livekit-client";
import { type RemoteAudioTrack, Track } from "livekit-client"; import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { import {
useTracks, useTracks,
AudioTrack, AudioTrack,
type AudioTrackProps, type AudioTrackProps,
} from "@livekit/components-react"; } from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
import { useEarpieceAudioConfig } from "../MediaDevicesContext"; import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
@@ -22,11 +23,16 @@ import * as controls from "../controls";
export interface MatrixAudioRendererProps { export interface MatrixAudioRendererProps {
/** /**
* The list of participants to render audio for. * The service URL of the LiveKit room.
* 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.
*/ */
members: CallMembership[]; url: string;
livekitRoom: LivekitRoom;
/**
* The list of participant identities 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).
*/
validIdentities: ParticipantId[];
/** /**
* If set to `true`, mutes all audio tracks rendered by the component. * If set to `true`, mutes all audio tracks rendered by the component.
* @remarks * @remarks
@@ -35,9 +41,9 @@ export interface MatrixAudioRendererProps {
muted?: boolean; muted?: boolean;
} }
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
/** /**
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app. * Takes care of handling remote participants audio tracks and makes sure that microphones and screen share are audible.
* 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. * 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. * This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
@@ -49,35 +55,12 @@ export interface MatrixAudioRendererProps {
* ``` * ```
* @public * @public
*/ */
export function MatrixAudioRenderer({ export function LivekitRoomAudioRenderer({
members, url,
livekitRoom,
validIdentities,
muted, muted,
}: MatrixAudioRendererProps): ReactNode { }: 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( const tracks = useTracks(
[ [
Track.Source.Microphone, Track.Source.Microphone,
@@ -87,25 +70,25 @@ export function MatrixAudioRenderer({
{ {
updateOnlyOn: [], updateOnlyOn: [],
onlySubscribed: true, onlySubscribed: true,
room: livekitRoom,
}, },
).filter((ref) => { )
const isValid = validIdentities?.has(ref.participant.identity); // Only keep audio tracks
if (!isValid && !ref.participant.isLocal) .filter((ref) => ref.publication.kind === Track.Kind.Audio)
logInvalid(ref.participant.identity, validIdentities); // Only keep tracks from participants that are in the validIdentities list
return ( .filter((ref) => {
!ref.participant.isLocal && const isValid = validIdentities.includes(ref.participant.identity);
ref.publication.kind === Track.Kind.Audio && if (!isValid) {
isValid // 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`,
useEffect(() => { `current members: ${validIdentities.join()}`,
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) { `track will not get rendered`,
logger.debug( );
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`, return false;
); }
loggedInvalidIdentities.current.clear(); return true;
} });
}, [tracks, validIdentities]);
// This component is also (in addition to the "only play audio for connected members" logic above) // This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones. // responsible for mimicking earpiece audio on iPhones.

View File

@@ -19,14 +19,22 @@ import {
useMemo, useMemo,
} from "react"; } from "react";
import { type LocalVideoTrack } from "livekit-client"; import { type LocalVideoTrack } from "livekit-client";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
import { import {
backgroundBlur as backgroundBlurSettings, backgroundBlur as backgroundBlurSettings,
useSetting, useSetting,
} from "../settings/settings"; } from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer"; import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
type ProcessorState = { //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 = {
supported: boolean | undefined; supported: boolean | undefined;
processor: undefined | ProcessorWrapper<BackgroundOptions>; processor: undefined | ProcessorWrapper<BackgroundOptions>;
}; };
@@ -42,6 +50,43 @@ export function useTrackProcessor(): ProcessorState {
return state; 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 = ( export const useTrackProcessorSync = (
videoTrack: LocalVideoTrack | null, videoTrack: LocalVideoTrack | null,
): void => { ): void => {

View File

@@ -7,12 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk"; import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; 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 { useActiveLivekitFocus } from "../room/useActiveFocus";
import { useErrorBoundary } from "../useErrorBoundary";
import { FailToGetOpenIdToken } from "../utils/errors"; import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
@@ -21,51 +16,26 @@ export interface SFUConfig {
jwt: string; jwt: string;
} }
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 // The bits we need from MatrixClient
export type OpenIDClientParts = Pick< export type OpenIDClientParts = Pick<
MatrixClient, MatrixClient,
"getOpenIdToken" | "getDeviceId" "getOpenIdToken" | "getDeviceId"
>; >;
/**
export function useOpenIDSFU( * Gets a bearer token from the homeserver and then use it to authenticate
client: OpenIDClientParts, * to the matrix RTC backend in order to get acces to the SFU.
rtcSession: MatrixRTCSession, * It has built-in retry for calls to the homeserver with a backoff policy.
): SFUConfig | undefined { * @param client
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined); * @param serviceUrl
* @param matrixRoomId
const activeFocus = useActiveLivekitFocus(rtcSession); * @returns Object containing the token information
const { showErrorBoundary } = useErrorBoundary(); * @throws FailToGetOpenIdToken
*/
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( export async function getSFUConfigWithOpenID(
client: OpenIDClientParts, client: OpenIDClientParts,
activeFocus: LivekitFocus, serviceUrl: string,
): Promise<SFUConfig | undefined> { matrixRoomId: string,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken; let openIdToken: IOpenIDToken;
try { try {
openIdToken = await doNetworkOperationWithRetry(async () => openIdToken = await doNetworkOperationWithRetry(async () =>
@@ -78,26 +48,16 @@ export async function getSFUConfigWithOpenID(
} }
logger.debug("Got openID token", openIdToken); logger.debug("Got openID token", openIdToken);
try { logger.info(`Trying to get JWT for focus ${serviceUrl}...`);
logger.info( const sfuConfig = await getLiveKitJWT(
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`, client,
); serviceUrl,
const sfuConfig = await getLiveKitJWT( matrixRoomId,
client, openIdToken,
activeFocus.livekit_service_url, );
activeFocus.livekit_alias, logger.info(`Got JWT from call's active focus URL.`);
openIdToken,
);
logger.info(`Got JWT from call's active focus URL.`);
return sfuConfig; 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;
}
} }
async function getLiveKitJWT( async function getLiveKitJWT(

View File

@@ -1,184 +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 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 { defer, 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 = defer<void>();
// let pendingDisconnection = defer<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 = defer<void>();
// let pendingDisconnection = defer<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();
});
});

View File

@@ -1,362 +0,0 @@
/*
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;
}

View File

@@ -1,403 +0,0 @@
/*
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,
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,
);
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,
};
}

View File

@@ -24,6 +24,7 @@ import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake"; import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer"; import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel"; import { AppViewModel } from "./state/AppViewModel";
import { globalScope } from "./state/ObservableScope";
window.setLKLogLevel = setLKLogLevel; window.setLKLogLevel = setLKLogLevel;
@@ -61,7 +62,7 @@ Initializer.initBeforeReact()
.then(() => { .then(() => {
root.render( root.render(
<StrictMode> <StrictMode>
<App vm={new AppViewModel()} /> <App vm={new AppViewModel(globalScope)} />
</StrictMode>, </StrictMode>,
); );
}) })

View File

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

View File

@@ -22,11 +22,13 @@ export function RaisedHandIndicator({
miniature, miniature,
showTimer, showTimer,
onClick, onClick,
tabIndex,
}: { }: {
raisedHandTime?: Date; raisedHandTime?: Date;
miniature?: boolean; miniature?: boolean;
showTimer?: boolean; showTimer?: boolean;
onClick?: () => void; onClick?: () => void;
tabIndex?: number;
}): ReactNode { }): ReactNode {
const { t } = useTranslation(); const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState(""); const [raisedHandDuration, setRaisedHandDuration] = useState("");
@@ -94,6 +96,7 @@ export function RaisedHandIndicator({
background: "none", background: "none",
}} }}
onClick={clickCallback} onClick={clickCallback}
tabIndex={tabIndex}
> >
{content} {content}
</button> </button>

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { renderHook } from "@testing-library/react"; import { renderHook } from "@testing-library/react";
import { afterEach, test, vitest } from "vitest"; import { afterEach, test, vitest } from "vitest";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { import {
RoomEvent as MatrixRoomEvent, RoomEvent as MatrixRoomEvent,
MatrixEvent, MatrixEvent,
@@ -24,7 +23,7 @@ import {
localRtcMember, localRtcMember,
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { getBasicRTCSession } from "../utils/test-viewmodel"; import { getBasicRTCSession } from "../utils/test-viewmodel";
import { withTestScheduler } from "../utils/test"; import { testScope, withTestScheduler } from "../utils/test";
import { ElementCallReactionEventType, ReactionSet } from "."; import { ElementCallReactionEventType, ReactionSet } from ".";
afterEach(() => { afterEach(() => {
@@ -38,7 +37,8 @@ test("handles a hand raised reaction", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { raisedHands$ } = new ReactionsReader( const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("ab", { schedule("ab", {
a: () => {}, a: () => {},
@@ -48,7 +48,7 @@ test("handles a hand raised reaction", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.Reaction, type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(), origin_server_ts: localTimestamp.getTime(),
content: { content: {
@@ -68,7 +68,7 @@ test("handles a hand raised reaction", () => {
expectObservable(raisedHands$).toBe("ab", { expectObservable(raisedHands$).toBe("ab", {
a: {}, a: {},
b: { b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { [`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId, reactionEventId,
membershipEventId: localRtcMember.eventId, membershipEventId: localRtcMember.eventId,
time: localTimestamp, time: localTimestamp,
@@ -86,7 +86,8 @@ test("handles a redaction", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { raisedHands$ } = new ReactionsReader( const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("abc", { schedule("abc", {
a: () => {}, a: () => {},
@@ -96,7 +97,7 @@ test("handles a redaction", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.Reaction, type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(), origin_server_ts: localTimestamp.getTime(),
content: { content: {
@@ -118,7 +119,7 @@ test("handles a redaction", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.RoomRedaction, type: EventType.RoomRedaction,
redacts: reactionEventId, redacts: reactionEventId,
}), }),
@@ -130,7 +131,7 @@ test("handles a redaction", () => {
expectObservable(raisedHands$).toBe("abc", { expectObservable(raisedHands$).toBe("abc", {
a: {}, a: {},
b: { b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { [`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId, reactionEventId,
membershipEventId: localRtcMember.eventId, membershipEventId: localRtcMember.eventId,
time: localTimestamp, time: localTimestamp,
@@ -149,7 +150,8 @@ test("handles waiting for event decryption", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { raisedHands$ } = new ReactionsReader( const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("abc", { schedule("abc", {
a: () => {}, a: () => {},
@@ -157,7 +159,7 @@ test("handles waiting for event decryption", () => {
const encryptedEvent = new MatrixEvent({ const encryptedEvent = new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.Reaction, type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(), origin_server_ts: localTimestamp.getTime(),
content: { content: {
@@ -184,7 +186,7 @@ test("handles waiting for event decryption", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.Reaction, type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(), origin_server_ts: localTimestamp.getTime(),
content: { content: {
@@ -200,7 +202,7 @@ test("handles waiting for event decryption", () => {
expectObservable(raisedHands$).toBe("a-c", { expectObservable(raisedHands$).toBe("a-c", {
a: {}, a: {},
c: { c: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { [`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId, reactionEventId,
membershipEventId: localRtcMember.eventId, membershipEventId: localRtcMember.eventId,
time: localTimestamp, time: localTimestamp,
@@ -218,7 +220,8 @@ test("hands rejecting events without a proper membership", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { raisedHands$ } = new ReactionsReader( const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("ab", { schedule("ab", {
a: () => {}, a: () => {},
@@ -228,7 +231,7 @@ test("hands rejecting events without a proper membership", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: EventType.Reaction, type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(), origin_server_ts: localTimestamp.getTime(),
content: { content: {
@@ -263,7 +266,8 @@ test("handles a reaction", () => {
withTestScheduler(({ schedule, time, expectObservable }) => { withTestScheduler(({ schedule, time, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { reactions$ } = new ReactionsReader( const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule(`abc`, { schedule(`abc`, {
a: () => {}, a: () => {},
@@ -273,7 +277,7 @@ test("handles a reaction", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: reaction.emoji, emoji: reaction.emoji,
@@ -298,7 +302,7 @@ test("handles a reaction", () => {
{ {
a: {}, a: {},
b: { b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { [`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionOption: reaction, reactionOption: reaction,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS), expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
}, },
@@ -321,7 +325,8 @@ test("ignores bad reaction events", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { reactions$ } = new ReactionsReader( const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("ab", { schedule("ab", {
a: () => {}, a: () => {},
@@ -332,7 +337,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: {}, content: {},
}), }),
@@ -347,7 +352,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: reaction.emoji, emoji: reaction.emoji,
@@ -368,7 +373,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: aliceRtcMember.sender, sender: aliceRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: reaction.emoji, emoji: reaction.emoji,
@@ -389,7 +394,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
name: reaction.name, name: reaction.name,
@@ -409,7 +414,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: " ", emoji: " ",
@@ -445,7 +450,8 @@ test("that reactions cannot be spammed", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => { renderHook(() => {
const { reactions$ } = new ReactionsReader( const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession, testScope(),
rtcSession.asMockedSession(),
); );
schedule("abcd", { schedule("abcd", {
a: () => {}, a: () => {},
@@ -455,7 +461,7 @@ test("that reactions cannot be spammed", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: reactionA.emoji, emoji: reactionA.emoji,
@@ -477,7 +483,7 @@ test("that reactions cannot be spammed", () => {
new MatrixEvent({ new MatrixEvent({
room_id: rtcSession.room.roomId, room_id: rtcSession.room.roomId,
event_id: reactionEventId, event_id: reactionEventId,
sender: localRtcMember.sender, sender: localRtcMember.userId,
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
content: { content: {
emoji: reactionB.emoji, emoji: reactionB.emoji,
@@ -502,7 +508,7 @@ test("that reactions cannot be spammed", () => {
{ {
a: {}, a: {},
b: { b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { [`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionOption: reactionA, reactionOption: reactionA,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS), expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
}, },

View File

@@ -18,7 +18,7 @@ import {
EventType, EventType,
RoomEvent as MatrixRoomEvent, RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { BehaviorSubject, delay, type Subscription } from "rxjs"; import { BehaviorSubject, delay } from "rxjs";
import { import {
ElementCallReactionEventType, ElementCallReactionEventType,
@@ -28,6 +28,7 @@ import {
type RaisedHandInfo, type RaisedHandInfo,
type ReactionInfo, type ReactionInfo,
} from "."; } from ".";
import { type ObservableScope } from "../state/ObservableScope";
export const REACTION_ACTIVE_TIME_MS = 3000; export const REACTION_ACTIVE_TIME_MS = 3000;
@@ -54,12 +55,13 @@ export class ReactionsReader {
*/ */
public readonly reactions$ = this.reactionsSubject$.asObservable(); public readonly reactions$ = this.reactionsSubject$.asObservable();
private readonly reactionsSub: Subscription; public constructor(
private readonly scope: ObservableScope,
public constructor(private readonly rtcSession: MatrixRTCSession) { private readonly rtcSession: MatrixRTCSession,
) {
// Hide reactions after a given time. // Hide reactions after a given time.
this.reactionsSub = this.reactionsSubject$ this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS)) .pipe(delay(REACTION_ACTIVE_TIME_MS), this.scope.bind())
.subscribe((reactions) => { .subscribe((reactions) => {
const date = new Date(); const date = new Date();
const nextEntries = Object.fromEntries( const nextEntries = Object.fromEntries(
@@ -71,15 +73,38 @@ export class ReactionsReader {
this.reactionsSubject$.next(nextEntries); this.reactionsSubject$.next(nextEntries);
}); });
// TODO: Convert this class to the functional reactive style and get rid of
// all this manual setup and teardown for event listeners
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent); this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent);
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.Timeline,
this.handleReactionEvent,
),
);
this.rtcSession.room.on( this.rtcSession.room.on(
MatrixRoomEvent.Redaction, MatrixRoomEvent.Redaction,
this.handleReactionEvent, this.handleReactionEvent,
); );
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
),
);
this.rtcSession.room.client.on( this.rtcSession.room.client.on(
MatrixEventEvent.Decrypted, MatrixEventEvent.Decrypted,
this.handleReactionEvent, this.handleReactionEvent,
); );
this.scope.onEnd(() =>
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
),
);
// We listen for a local echo to get the real event ID, as timeline events // We listen for a local echo to get the real event ID, as timeline events
// may still be sending. // may still be sending.
@@ -87,11 +112,23 @@ export class ReactionsReader {
MatrixRoomEvent.LocalEchoUpdated, MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent, this.handleReactionEvent,
); );
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
),
);
rtcSession.on( this.rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged, this.onMembershipsChanged,
); );
this.scope.onEnd(() =>
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
),
);
// Run this once to ensure we have fetched the state from the call. // Run this once to ensure we have fetched the state from the call.
this.onMembershipsChanged([]); this.onMembershipsChanged([]);
@@ -130,7 +167,7 @@ export class ReactionsReader {
private onMembershipsChanged = (oldMemberships: CallMembership[]): void => { private onMembershipsChanged = (oldMemberships: CallMembership[]): void => {
// Remove any raised hands for users no longer joined to the call. // Remove any raised hands for users no longer joined to the call.
for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter( for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter(
(rhId) => oldMemberships.find((u) => u.sender == rhId), (rhId) => oldMemberships.find((u) => u.userId == rhId),
)) { )) {
this.removeRaisedHand(identifier); this.removeRaisedHand(identifier);
} }
@@ -138,10 +175,10 @@ export class ReactionsReader {
// For each member in the call, check to see if a reaction has // For each member in the call, check to see if a reaction has
// been raised and adjust. // been raised and adjust.
for (const m of this.rtcSession.memberships) { for (const m of this.rtcSession.memberships) {
if (!m.sender || !m.eventId) { if (!m.userId || !m.eventId) {
continue; continue;
} }
const identifier = `${m.sender}:${m.deviceId}`; const identifier = `${m.userId}:${m.deviceId}`;
if ( if (
this.raisedHandsSubject$.value[identifier] && this.raisedHandsSubject$.value[identifier] &&
this.raisedHandsSubject$.value[identifier].membershipEventId !== this.raisedHandsSubject$.value[identifier].membershipEventId !==
@@ -151,13 +188,13 @@ export class ReactionsReader {
// was raised, reset. // was raised, reset.
this.removeRaisedHand(identifier); this.removeRaisedHand(identifier);
} }
const reaction = this.getLastReactionEvent(m.eventId, m.sender); const reaction = this.getLastReactionEvent(m.eventId, m.userId);
if (reaction) { if (reaction) {
const eventId = reaction?.getId(); const eventId = reaction?.getId();
if (!eventId) { if (!eventId) {
continue; continue;
} }
this.addRaisedHand(`${m.sender}:${m.deviceId}`, { this.addRaisedHand(`${m.userId}:${m.deviceId}`, {
membershipEventId: m.eventId, membershipEventId: m.eventId,
reactionEventId: eventId, reactionEventId: eventId,
time: new Date(reaction.localTimestamp), time: new Date(reaction.localTimestamp),
@@ -219,7 +256,7 @@ export class ReactionsReader {
const membershipEventId = content?.["m.relates_to"]?.event_id; const membershipEventId = content?.["m.relates_to"]?.event_id;
const membershipEvent = this.rtcSession.memberships.find( const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender, (e) => e.eventId === membershipEventId && e.userId === sender,
); );
// Check to see if this reaction was made to a membership event (and the // Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership) // sender of the reaction matches the membership)
@@ -229,7 +266,8 @@ export class ReactionsReader {
); );
return; return;
} }
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`; // TODO refactor to use memer id `membershipEvent.membershipID` (needs to happen in combination with other memberId refactors)
const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`;
if (!content.emoji) { if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`); logger.warn(`Reaction had no emoji from ${reactionEventId}`);
@@ -278,7 +316,7 @@ export class ReactionsReader {
// Check to see if this reaction was made to a membership event (and the // Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership) // sender of the reaction matches the membership)
const membershipEvent = this.rtcSession.memberships.find( const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender, (e) => e.eventId === membershipEventId && e.userId === sender,
); );
if (!membershipEvent) { if (!membershipEvent) {
logger.warn( logger.warn(
@@ -289,7 +327,7 @@ export class ReactionsReader {
if (content?.["m.relates_to"].key === "🖐️") { if (content?.["m.relates_to"].key === "🖐️") {
this.addRaisedHand( this.addRaisedHand(
`${membershipEvent.sender}:${membershipEvent.deviceId}`, `${membershipEvent.userId}:${membershipEvent.deviceId}`,
{ {
reactionEventId, reactionEventId,
membershipEventId, membershipEventId,
@@ -309,31 +347,4 @@ export class ReactionsReader {
this.removeRaisedHand(targetUser); this.removeRaisedHand(targetUser);
} }
}; };
/**
* Stop listening for events.
*/
public destroy(): void {
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
this.rtcSession.room.off(
MatrixRoomEvent.Timeline,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
);
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
);
this.reactionsSub.unsubscribe();
}
} }

View File

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

View File

@@ -27,6 +27,8 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url"; import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
import waveSoundOgg from "../sound/reactions/wave.ogg?url"; import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url"; import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction"; export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -191,6 +193,15 @@ export const ReactionSet: ReactionOption[] = [
mp3: waveSoundMp3, mp3: waveSoundMp3,
}, },
}, },
{
emoji: "🥁",
name: "drum",
alias: ["joke"],
sound: {
ogg: baduntssSoundOgg,
mp3: baduntssSoundMp3,
},
},
]; ];
export interface RaisedHandInfo { export interface RaisedHandInfo {

View File

@@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext"; import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "."; import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
interface ReactionsSenderContextType { interface ReactionsSenderContextType {
@@ -65,7 +65,7 @@ export const ReactionsSenderProvider = ({
const myMembershipEvent = useMemo( const myMembershipEvent = useMemo(
() => () =>
memberships.find( memberships.find(
(m) => m.sender === myUserId && m.deviceId === myDeviceId, (m) => m.userId === myUserId && m.deviceId === myDeviceId,
)?.eventId, )?.eventId,
[memberships, myUserId, myDeviceId], [memberships, myUserId, myDeviceId],
); );

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { import {
afterAll,
beforeEach, beforeEach,
expect, expect,
type MockedFunction, type MockedFunction,
@@ -16,33 +15,41 @@ import {
afterEach, afterEach,
} from "vitest"; } from "vitest";
import { act } from "react"; import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type RoomMember } from "matrix-js-sdk";
import { mockRtcMembership } from "../utils/test";
import { import {
CallEventAudioRenderer, type LivekitTransport,
MAX_PARTICIPANT_COUNT_FOR_SOUND, type CallMembership,
} from "./CallEventAudioRenderer"; } from "matrix-js-sdk/lib/matrixrtc";
import {
exampleTransport,
mockMatrixRoomMember,
mockRtcMembership,
} from "../utils/test";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { import {
alice, alice,
aliceRtcMember, aliceRtcMember,
bob,
bobRtcMember, bobRtcMember,
local, local,
localRtcMember, localRtcMember,
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";
vitest.mock("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
...(await importOriginal()),
makeTransport: (): [LivekitTransport] => [exampleTransport],
}));
afterEach(() => { afterEach(() => {
vitest.resetAllMocks(); vitest.clearAllMocks();
});
afterAll(() => {
vitest.restoreAllMocks();
}); });
let playSound: MockedFunction< let playSound: MockedFunction<
@@ -56,6 +63,8 @@ beforeEach(() => {
playSound = vitest.fn(); playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
}); });
}); });
@@ -70,6 +79,7 @@ test("plays one sound when entering a call", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([ const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
local, local,
alice, alice,
bob,
]); ]);
render(<CallEventAudioRenderer vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
@@ -84,6 +94,7 @@ test("plays a sound when a user joins", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([ const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
local, local,
alice, alice,
bob,
]); ]);
render(<CallEventAudioRenderer vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
@@ -107,16 +118,31 @@ test("plays a sound when a user leaves", () => {
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });
test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).not.toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => { test("plays no sound when the participant list is more than the maximum size", () => {
const mockMembers: RoomMember[] = [local];
const mockRtcMemberships: CallMembership[] = [localRtcMember]; const mockRtcMemberships: CallMembership[] = [localRtcMember];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) { for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
mockRtcMemberships.push( const membership = mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`);
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`), mockMembers.push(mockMatrixRoomMember(membership));
); mockRtcMemberships.push(membership);
} }
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment( const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice], mockMembers,
mockRtcMemberships, mockRtcMemberships,
); );
@@ -136,12 +162,14 @@ test("plays one sound when a hand is raised", () => {
const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([ const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([
local, local,
alice, alice,
bob,
]); ]);
render(<CallEventAudioRenderer vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
act(() => { act(() => {
handRaisedSubject$.next({ handRaisedSubject$.next({
[bobRtcMember.callId]: { // TODO: What is this string supposed to be?
[`${bobRtcMember.userId}:${bobRtcMember.deviceId}`]: {
time: new Date(), time: new Date(),
membershipEventId: "", membershipEventId: "",
reactionEventId: "", reactionEventId: "",
@@ -158,6 +186,7 @@ test("should not play a sound when a hand raise is retracted", () => {
]); ]);
render(<CallEventAudioRenderer vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
playSound.mockClear();
act(() => { act(() => {
handRaisedSubject$.next({ handRaisedSubject$.next({
["foo"]: { ["foo"]: {
@@ -172,7 +201,7 @@ test("should not play a sound when a hand raise is retracted", () => {
}, },
}); });
}); });
expect(playSound).toHaveBeenCalledTimes(2); expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
act(() => { act(() => {
handRaisedSubject$.next({ handRaisedSubject$.next({
["foo"]: { ["foo"]: {
@@ -182,5 +211,5 @@ test("should not play a sound when a hand raise is retracted", () => {
}, },
}); });
}); });
expect(playSound).toHaveBeenCalledTimes(2); expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
}); });

View File

@@ -6,9 +6,8 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3"; import joinCallSoundMp3 from "../sound/join_call.mp3";
import joinCallSoundOgg from "../sound/join_call.ogg"; import joinCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3"; import leftCallSoundMp3 from "../sound/left_call.mp3";
@@ -17,15 +16,14 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3"; import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg"; import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3"; import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
export const callEventAudioSounds = prefetchSounds({ export const callEventAudioSounds = prefetchSounds({
join: { join: {
mp3: joinCallSoundMp3, mp3: joinCallSoundMp3,
@@ -43,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3, mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg, ogg: screenShareStartedOgg,
}, },
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
}); });
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({ export function CallEventAudioRenderer({
vm, vm,
muted, muted,
@@ -60,37 +68,18 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx); const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => { useEffect(() => {
const joinSub = vm.participantChanges$ const joinSub = vm.joinSoundEffect$.subscribe(
.pipe( () => void audioEngineRef.current?.playSound("join"),
filter( );
({ joined, ids }) => const leftSub = vm.leaveSoundEffect$.subscribe(
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, () => void audioEngineRef.current?.playSound("left"),
), );
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), const handRaisedSub = vm.newHandRaised$.subscribe(
) () => void audioEngineRef.current?.playSound("raiseHand"),
.subscribe(() => { );
void audioEngineRef.current?.playSound("join"); const screenshareSub = vm.newScreenShare$.subscribe(
}); () => void audioEngineRef.current?.playSound("screenshareStarted"),
);
const leftSub = vm.participantChanges$
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
void audioEngineRef.current?.playSound("raiseHand");
});
const screenshareSub = vm.newScreenShare$.subscribe(() => {
void audioEngineRef.current?.playSound("screenshareStarted");
});
return (): void => { return (): void => {
joinSub.unsubscribe(); joinSub.unsubscribe();

View File

@@ -34,7 +34,6 @@
.overlay[data-show="false"] { .overlay[data-show="false"] {
animation: fade-out 130ms forwards; animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none; pointer-events: none;
} }

View File

@@ -20,7 +20,7 @@ interface Props {
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => { export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={styles.overlay} data-show={show}> <div className={styles.overlay} data-show={show} aria-hidden={!show}>
<BigIcon className={styles.icon}> <BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden /> <VoiceCallIcon aria-hidden />
</BigIcon> </BigIcon>

View File

@@ -26,7 +26,7 @@ import {
E2EENotSupportedError, E2EENotSupportedError,
type ElementCallError, type ElementCallError,
InsufficientCapacityError, InsufficientCapacityError,
MatrixRTCFocusMissingError, MatrixRTCTransportMissingError,
UnknownCallError, UnknownCallError,
} from "../utils/errors.ts"; } from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts"; import { mockConfig } from "../utils/test.ts";
@@ -34,7 +34,7 @@ import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([ test.each([
{ {
error: new MatrixRTCFocusMissingError("example.com"), error: new MatrixRTCTransportMissingError("example.com"),
expectedTitle: "Call is not supported", expectedTitle: "Call is not supported",
}, },
{ {
@@ -85,7 +85,7 @@ test.each([
); );
test("should render the error page with link back to home", async () => { test("should render the error page with link back to home", async () => {
const error = new MatrixRTCFocusMissingError("example.com"); const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => { const TestComponent = (): ReactNode => {
throw error; throw error;
}; };
@@ -106,7 +106,7 @@ test("should render the error page with link back to home", async () => {
await screen.findByText("Call is not supported"); await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument(); expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect( expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i), screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
).toBeInTheDocument(); ).toBeInTheDocument();
await screen.findByRole("button", { name: "Return to home screen" }); await screen.findByRole("button", { name: "Return to home screen" });
@@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
const WrapComponent = (): ReactNode => { const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true); const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback( const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => { async (action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action); reconnectCallbackSpy(action);
setFailState(false); setFailState(false);
return Promise.resolve();
}, },
[setFailState], [setFailState],
); );
@@ -212,7 +213,7 @@ describe("Rageshake button", () => {
}); });
test("should have a close button in widget mode", async () => { test("should have a close button in widget mode", async () => {
const error = new MatrixRTCFocusMissingError("example.com"); const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => { const TestComponent = (): ReactNode => {
throw error; throw error;
}; };

View File

@@ -22,6 +22,7 @@ import {
WebBrowserIcon, WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { import {
ConnectionLostError, ConnectionLostError,
@@ -36,7 +37,9 @@ import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void; export type RecoveryActionHandler = (
action: CallErrorRecoveryAction,
) => Promise<void>;
interface ErrorPageProps { interface ErrorPageProps {
error: ElementCallError; error: ElementCallError;
@@ -51,7 +54,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
widget, widget,
}: ErrorPageProps): ReactElement => { }: ErrorPageProps): ReactElement => {
const { t } = useTranslation(); const { t } = useTranslation();
logger.error("Error boundary caught:", error);
let icon: ComponentType<SVGAttributes<SVGElement>>; let icon: ComponentType<SVGAttributes<SVGElement>>;
switch (error.category) { switch (error.category) {
case ErrorCategory.CONFIGURATION_ISSUE: case ErrorCategory.CONFIGURATION_ISSUE:
@@ -71,7 +74,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
if (error instanceof ConnectionLostError) { if (error instanceof ConnectionLostError) {
actions.push({ actions.push({
label: t("call_ended_view.reconnect_button"), label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"), onClick: () => void recoveryActionHandler("reconnect"),
}); });
} }
@@ -131,9 +134,9 @@ export const GroupCallErrorBoundary = ({
widget={widget ?? null} widget={widget ?? null}
error={callError} error={callError}
resetError={resetError} resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => { recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
await recoveryActionHandler(action);
resetError(); resetError();
recoveryActionHandler(action);
}} }}
/> />
); );

View File

@@ -5,6 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
// TODO-MULTI-SFU: Restore or discard these tests. The role of GroupCallView has
// changed (it no longer manages the connection to the same extent), so they may
// need extra work to adapt.
import { import {
beforeEach, beforeEach,
expect, expect,
@@ -12,17 +16,21 @@ import {
onTestFinished, onTestFinished,
test, test,
vi, vi,
vitest,
} from "vitest"; } from "vitest";
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react"; import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { type ITransport } from "matrix-widget-api";
import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
@@ -36,13 +44,14 @@ import {
MockRTCSession, MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { type WidgetHelpers } from "../widget"; import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter"; import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors"; import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
import { type MuteStates } from "../state/MuteStates.ts";
vi.mock("../soundUtils"); vi.mock("../soundUtils");
vi.mock("../useAudioContext"); vi.mock("../useAudioContext");
@@ -69,12 +78,13 @@ const leaveRTCSession = vi.hoisted(() =>
), ),
); );
vi.mock("../rtcSessionHelpers", async (importOriginal) => { // vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here? // // TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // // eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>(); // const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
return { ...orig, enterRTCSession, leaveRTCSession }; // // TODO: leaveRTCSession no longer exists! Tests need adapting.
}); // return { ...orig, enterRTCSession, leaveRTCSession };
// });
let playSound: MockedFunction< let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
@@ -94,13 +104,19 @@ beforeEach(() => {
playSound = vi.fn(); playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vi.fn(),
soundDuration: {},
}); });
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation( (ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
({ onLeave }) => { ({ onLeft: onLeave }) => {
return ( return (
<div> <div>
<button onClick={() => onLeave()}>Leave</button> <button onClick={() => onLeave("user")}>Leave</button>
<button onClick={() => onLeave("allOthersLeft")}>
SimulateOtherLeft
</button>
<button onClick={() => onLeave("error")}>SimulateErrorLeft</button>
</div> </div>
); );
}, },
@@ -111,12 +127,12 @@ function createGroupCallView(
widget: WidgetHelpers | null, widget: WidgetHelpers | null,
joined = true, joined = true,
): { ): {
rtcSession: MockRTCSession; rtcSession: MatrixRTCSession;
getByText: ReturnType<typeof render>["getByText"]; getByText: ReturnType<typeof render>["getByText"];
} { } {
const client = { const client = {
getUser: () => null, getUser: () => null,
getUserId: () => localRtcMember.sender, getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null), getRoom: (rId) => (rId === roomId ? room : null),
} as Partial<MatrixClient> as MatrixClient; } as Partial<MatrixClient> as MatrixClient;
@@ -144,7 +160,8 @@ function createGroupCallView(
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },
video: { enabled: false }, video: { enabled: false },
} as MuteStates; // TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
} as unknown as MuteStates;
const { getByText } = render( const { getByText } = render(
<BrowserRouter> <BrowserRouter>
<TooltipProvider> <TooltipProvider>
@@ -157,10 +174,12 @@ function createGroupCallView(
preload={false} preload={false}
skipLobby={false} skipLobby={false}
header={HeaderStyle.Standard} header={HeaderStyle.Standard}
rtcSession={rtcSession as unknown as MatrixRTCSession} rtcSession={rtcSession.asMockedSession()}
isJoined={joined}
muteStates={muteState} muteStates={muteState}
widget={widget} widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
/> />
</ProcessorProvider> </ProcessorProvider>
</MediaDevicesContext> </MediaDevicesContext>
@@ -169,11 +188,11 @@ function createGroupCallView(
); );
return { return {
getByText, getByText,
rtcSession, rtcSession: rtcSession.asMockedSession(),
}; };
} }
test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => { test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null); const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave"); const leaveButton = getByText("Leave");
@@ -190,7 +209,7 @@ test("GroupCallView plays a leave sound asynchronously in SPA mode", async () =>
await waitFor(() => expect(leaveRTCSession).toHaveResolved()); await waitFor(() => expect(leaveRTCSession).toHaveResolved());
}); });
test("GroupCallView plays a leave sound synchronously in widget mode", async () => { test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const widget = { const widget = {
api: { api: {
@@ -206,6 +225,8 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
); );
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
}); });
const { getByText, rtcSession } = createGroupCallView( const { getByText, rtcSession } = createGroupCallView(
@@ -227,7 +248,113 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
expect(leaveRTCSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
}); });
test("GroupCallView leaves the session when an error occurs", async () => { test.skip("Should close widget when all other left and have time to play a sound", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const resolvePlaySound = Promise.withResolvers<void>();
playSound = vi.fn().mockReturnValue(resolvePlaySound);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
expect(widgetSendMock).not.toHaveBeenCalled();
resolvePlaySound.resolve();
await flushPromises();
expect(playSound).toHaveBeenCalledWith("left");
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should close widget when all other left", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateErrorLeft");
await user.click(leaveButton);
await flushPromises();
// When onLeft is called, we first set always on screen to false
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
await flushPromises();
// But then we do not close the widget automatically
expect(widgetStopMock).not.toHaveBeenCalledOnce();
expect(widgetSendMock).not.toHaveBeenCalledOnce();
});
test.skip("GroupCallView leaves the session when an error occurs", async () => {
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => { (ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
if (error !== null) throw error; if (error !== null) throw error;
@@ -248,9 +375,10 @@ test("GroupCallView leaves the session when an error occurs", async () => {
); );
}); });
test("GroupCallView shows errors that occur during joining", async () => { test.skip("GroupCallView shows errors that occur during joining", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError("")); // This should not mock this error that deep. it should only mock the CallViewModel.
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
onTestFinished(() => { onTestFinished(() => {
enterRTCSession.mockReset(); enterRTCSession.mockReset();
}); });
@@ -258,3 +386,19 @@ test("GroupCallView shows errors that occur during joining", async () => {
await user.click(screen.getByRole("button", { name: "Join call" })); await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported"); screen.getByText("Call is not supported");
}); });
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
);
// XXX: Wrapping the following click in act() shouldn't be necessary (the
// async state update should be processed automatically by the waitFor call),
// and yet here we are.
await act(async () =>
user.click(screen.getByRole("button", { name: "Reconnect" })),
);
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});

View File

@@ -38,10 +38,9 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media"; import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { import {
saveKeyForRoom, saveKeyForRoom,
useRoomEncryptionSystem, useRoomEncryptionSystem,
@@ -50,10 +49,18 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName"; import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule"; import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { HeaderStyle, useUrlParams } from "../UrlParams"; import {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer"; import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { import {
@@ -63,16 +70,17 @@ import {
UnknownCallError, UnknownCallError,
} from "../utils/errors.ts"; } from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
useNewMembershipManager as useNewMembershipManagerSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx"; import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts"; import { useBehavior } from "../useBehavior.ts";
/**
* If there already are this many participants in the call, we automatically mute
* the user.
*/
export const MUTE_PARTICIPANT_COUNT = 8;
declare global { declare global {
interface Window { interface Window {
rtcSession?: MatrixRTCSession; rtcSession?: MatrixRTCSession;
@@ -83,11 +91,12 @@ interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
confineToRoom: boolean; confineToRoom: boolean;
preload: boolean; preload: UrlParams["preload"];
skipLobby: boolean; skipLobby: UrlParams["skipLobby"];
header: HeaderStyle; header: HeaderStyle;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
isJoined: boolean; joined: boolean;
setJoined: (value: boolean) => void;
muteStates: MuteStates; muteStates: MuteStates;
widget: WidgetHelpers | null; widget: WidgetHelpers | null;
} }
@@ -100,7 +109,8 @@ export const GroupCallView: FC<Props> = ({
skipLobby, skipLobby,
header, header,
rtcSession, rtcSession,
isJoined, joined,
setJoined,
muteStates, muteStates,
widget, widget,
}) => { }) => {
@@ -121,7 +131,7 @@ export const GroupCallView: FC<Props> = ({
// This should use `useEffectEvent` (only available in experimental versions) // This should use `useEffectEvent` (only available in experimental versions)
useEffect(() => { useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT) if (memberships.length >= MUTE_PARTICIPANT_COUNT)
muteStates.audio.setEnabled?.(false); muteStates.audio.setEnabled$.value?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -149,6 +159,7 @@ export const GroupCallView: FC<Props> = ({
}; };
}, [rtcSession]); }, [rtcSession]);
// TODO move this into the callViewModel LocalMembership.ts
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,
@@ -172,10 +183,6 @@ export const GroupCallView: FC<Props> = ({
password: passwordFromUrl, password: passwordFromUrl,
} = useUrlParams(); } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId); const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
useExperimentalToDeviceTransportSetting,
);
// Save the password once we start the groupCallView // Save the password once we start the groupCallView
useEffect(() => { useEffect(() => {
@@ -200,7 +207,7 @@ export const GroupCallView: FC<Props> = ({
// Count each member only once, regardless of how many devices they use // Count each member only once, regardless of how many devices they use
const participantCount = useMemo( const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.sender!)).size, () => new Set<string>(memberships.map((m) => m.userId!)).size,
[memberships], [memberships],
); );
@@ -210,12 +217,9 @@ export const GroupCallView: FC<Props> = ({
const enterRTCSessionOrError = useCallback( const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => { async (rtcSession: MatrixRTCSession): Promise<void> => {
try { try {
await enterRTCSession( setJoined(true);
rtcSession, // TODO-MULTI-SFU what to do with error handling now that we don't use this function?
perParticipantE2EE, // @BillCarsonFr
useNewMembershipManager,
useExperimentalToDeviceTransport,
);
} catch (e) { } catch (e) {
if (e instanceof ElementCallError) { if (e instanceof ElementCallError) {
setExternalError(e); setExternalError(e);
@@ -227,12 +231,9 @@ export const GroupCallView: FC<Props> = ({
setExternalError(error); setExternalError(error);
} }
} }
return Promise.resolve();
}, },
[ [setJoined],
perParticipantE2EE,
useExperimentalToDeviceTransport,
useNewMembershipManager,
],
); );
useEffect(() => { useEffect(() => {
@@ -251,7 +252,7 @@ export const GroupCallView: FC<Props> = ({
if (!deviceId) { if (!deviceId) {
logger.warn("Unknown audio input: " + audioInput); logger.warn("Unknown audio input: " + audioInput);
// override the default mute state // override the default mute state
latestMuteStates.current!.audio.setEnabled?.(false); latestMuteStates.current!.audio.setEnabled$.value?.(false);
} else { } else {
logger.debug( logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`, `Found audio input ID ${deviceId} for name ${audioInput}`,
@@ -265,7 +266,7 @@ export const GroupCallView: FC<Props> = ({
if (!deviceId) { if (!deviceId) {
logger.warn("Unknown video input: " + videoInput); logger.warn("Unknown video input: " + videoInput);
// override the default mute state // override the default mute state
latestMuteStates.current!.video.setEnabled?.(false); latestMuteStates.current!.video.setEnabled$.value?.(false);
} else { } else {
logger.debug( logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`, `Found video input ID ${deviceId} for name ${videoInput}`,
@@ -276,34 +277,24 @@ export const GroupCallView: FC<Props> = ({
}; };
if (skipLobby) { if (skipLobby) {
if (widget) { if (widget && preload) {
if (preload) { // In preload mode without lobby we wait for a join action before entering
// In preload mode without lobby we wait for a join action before entering const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => { (async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession); await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
setJoined(true);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => { })().catch((e) => {
logger.error("Error joining RTC session", e); logger.error("Error joining RTC session on preload", e);
}); });
} };
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else { } else {
void enterRTCSessionOrError(rtcSession); // No lobby and no preload: we enter the rtc session right away
setJoined(true);
} }
} }
}, [ }, [
@@ -314,58 +305,87 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE, perParticipantE2EE,
mediaDevices, mediaDevices,
latestMuteStates, latestMuteStates,
enterRTCSessionOrError, setJoined,
useNewMembershipManager,
]); ]);
// TODO refactor this + "joined" to just one callState
const [left, setLeft] = useState(false); const [left, setLeft] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const onLeave = useCallback( const onLeft = useCallback(
(cause: "user" | "error" = "user"): void => { (
const audioPromise = leaveSoundContext.current?.playSound("left"); reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, ): void => {
// therefore we want the event to be sent instantly without getting queued/batched. let playSound: CallEventSounds = "left";
const sendInstantly = !!widget; if (reason === "timeout" || reason === "decline") playSound = reason;
setJoined(false);
setLeft(true); setLeft(true);
// we need to wait until the callEnded event is tracked on posthog. const audioPromise = leaveSoundContext.current?.playSound(playSound);
// Otherwise the iFrame gets killed before the callEnded event got tracked. // We need to wait until the callEnded event is tracked on PostHog,
// otherwise the iframe may get killed first.
const posthogRequest = new Promise((resolve) => { const posthogRequest = new Promise((resolve) => {
// To increase the likelihood of the PostHog event being sent out in
// widget mode before the iframe is killed, we ask it to skip the
// usual queuing/batching of requests.
const sendInstantly = widget !== null;
PosthogAnalytics.instance.eventCallEnded.track( PosthogAnalytics.instance.eventCallEnded.track(
room.roomId, room.roomId,
rtcSession.memberships.length, rtcSession.memberships.length,
sendInstantly, sendInstantly,
rtcSession, rtcSession,
); );
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up the
// closing of the widget that long anyway, so giving it 10 ms will do.
window.setTimeout(resolve, 10); window.setTimeout(resolve, 10);
}); });
leaveRTCSession( void Promise.all([audioPromise, posthogRequest])
rtcSession, .catch((e) =>
cause, logger.error(
// Wait for the sound in widget mode (it's not long) "Failed to play leave audio and/or send PostHog leave event",
Promise.all([audioPromise, posthogRequest]), e,
) ),
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. )
.then(async () => { .then(async () => {
if ( if (
!isPasswordlessUser && !isPasswordlessUser &&
!confineToRoom && !confineToRoom &&
!PosthogAnalytics.instance.isEnabled() !PosthogAnalytics.instance.isEnabled()
) { )
await navigate("/"); void navigate("/");
if (widget) {
// After this point the iframe could die at any moment!
try {
await widget.api.setAlwaysOnScreen(false);
} catch (e) {
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
);
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
if (reason != "error" && !getUrlParams().returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
}
} }
})
.catch((e) => {
logger.error("Error leaving RTC session", e);
}); });
}, },
[ [
setJoined,
leaveSoundContext, leaveSoundContext,
widget, widget,
rtcSession,
room.roomId, room.roomId,
rtcSession,
isPasswordlessUser, isPasswordlessUser,
confineToRoom, confineToRoom,
navigate, navigate,
@@ -373,25 +393,12 @@ export const GroupCallView: FC<Props> = ({
); );
useEffect(() => { useEffect(() => {
if (widget && isJoined) { if (widget && joined)
// set widget to sticky once joined. // set widget to sticky once joined.
widget.api.setAlwaysOnScreen(true).catch((e) => { widget.api.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e); logger.error("Error calling setAlwaysOnScreen(true)", e);
}); });
}, [widget, joined, rtcSession]);
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
widget.api.transport.reply(ev.detail, {});
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession, "user").catch((e) => {
logger.error("Failed to leave RTC session", e);
});
};
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [widget, isJoined, rtcSession]);
const joinRule = useJoinRule(room); const joinRule = useJoinRule(room);
@@ -426,7 +433,7 @@ export const GroupCallView: FC<Props> = ({
client={client} client={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={() => void enterRTCSessionOrError(rtcSession)} onEnter={() => setJoined(true)}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={header === HeaderStyle.None} hideHeader={header === HeaderStyle.None}
participantCount={participantCount} participantCount={participantCount}
@@ -444,7 +451,7 @@ export const GroupCallView: FC<Props> = ({
throw externalError; throw externalError;
}; };
body = <ErrorComponent />; body = <ErrorComponent />;
} else if (isJoined) { } else if (joined) {
body = ( body = (
<> <>
{shareModal} {shareModal}
@@ -453,8 +460,7 @@ export const GroupCallView: FC<Props> = ({
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession} rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room} matrixRoom={room}
participantCount={participantCount} onLeft={onLeft}
onLeave={onLeave}
header={header} header={header}
muteStates={muteStates} muteStates={muteStates}
e2eeSystem={e2eeSystem} e2eeSystem={e2eeSystem}
@@ -495,6 +501,7 @@ export const GroupCallView: FC<Props> = ({
// Left in widget mode: // Left in widget mode:
body = returnToLobby ? lobbyView : null; body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) { } else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null; body = null;
} else { } else {
body = lobbyView; body = lobbyView;
@@ -503,17 +510,18 @@ export const GroupCallView: FC<Props> = ({
return ( return (
<GroupCallErrorBoundary <GroupCallErrorBoundary
widget={widget} widget={widget}
recoveryActionHandler={(action) => { recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") { if (action == "reconnect") {
setLeft(false); setLeft(false);
enterRTCSessionOrError(rtcSession).catch((e) => { await enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e); logger.error("Error re-entering RTC session", e);
}); });
} }
}} }}
onError={ onError={
(/**error*/) => { (/**error*/) => {
if (rtcSession.isJoined()) onLeave("error"); if (rtcSession.isJoined()) onLeft("error");
} }
} }
> >

View File

@@ -108,13 +108,6 @@ Please see LICENSE in the repository root for full details.
} }
@media (max-width: 370px) { @media (max-width: 370px) {
.raiseHand {
display: none;
}
}
@media (max-width: 340px) {
.invite,
.shareScreen { .shareScreen {
display: none; display: none;
} }
@@ -126,6 +119,13 @@ Please see LICENSE in the repository root for full details.
} }
} }
@media (max-width: 320px) {
.invite,
.raiseHand {
display: none;
}
}
@media (max-height: 400px) { @media (max-height: 400px) {
.footer { .footer {
padding-block: var(--cpd-space-4x); padding-block: var(--cpd-space-4x);

View File

@@ -13,18 +13,15 @@ import {
type MockedFunction, type MockedFunction,
vi, vi,
} from "vitest"; } from "vitest";
import { act, render, type RenderResult } from "@testing-library/react"; import { render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { ConnectionState, type LocalParticipant } from "livekit-client"; import { type LocalParticipant } from "livekit-client";
import { of } from "rxjs"; import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type MuteStates } from "./MuteStates";
import { InCallView } from "./InCallView"; import { InCallView } from "./InCallView";
import { import {
mockLivekitRoom, mockLivekitRoom,
@@ -32,6 +29,7 @@ import {
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockMediaDevices, mockMediaDevices,
mockMuteStates,
mockRemoteParticipant, mockRemoteParticipant,
mockRtcMembership, mockRtcMembership,
type MockRTCSession, type MockRTCSession,
@@ -39,19 +37,12 @@ import {
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local } from "../utils/test-fixtures"; import { alice, local } from "../utils/test-fixtures";
import {
developerMode as developerModeSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
} from "../settings/settings";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
// });
vi.hoisted( vi.hoisted(
() => () =>
(global.ImageData = class MockImageData { (global.ImageData = class MockImageData {
@@ -64,6 +55,7 @@ vi.mock("../useAudioContext");
vi.mock("../tile/GridTile"); vi.mock("../tile/GridTile");
vi.mock("../tile/SpotlightTile"); vi.mock("../tile/SpotlightTile");
vi.mock("@livekit/components-react"); vi.mock("@livekit/components-react");
vi.mock("livekit-client/e2ee-worker?worker");
vi.mock("../e2ee/sharedKeyManagement"); vi.mock("../e2ee/sharedKeyManagement");
vi.mock("../livekit/MatrixAudioRenderer"); vi.mock("../livekit/MatrixAudioRenderer");
vi.mock("react-use-measure", () => ({ vi.mock("react-use-measure", () => ({
@@ -88,7 +80,7 @@ beforeEach(() => {
// MatrixAudioRenderer is tested separately. // MatrixAudioRenderer is tested separately.
( (
MatrixAudioRenderer as MockedFunction<typeof MatrixAudioRenderer> LivekitRoomAudioRenderer as MockedFunction<typeof LivekitRoomAudioRenderer>
).mockImplementation((_props) => { ).mockImplementation((_props) => {
return <div>mocked: MatrixAudioRenderer</div>; return <div>mocked: MatrixAudioRenderer</div>;
}); });
@@ -111,9 +103,10 @@ function createInCallView(): RenderResult & {
} { } {
const client = { const client = {
getUser: () => null, getUser: () => null,
getUserId: () => localRtcMember.sender, getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null), getRoom: (rId) => (rId === roomId ? room : null),
getDomain: () => "example.com",
} as Partial<MatrixClient> as MatrixClient; } as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({ const room = mockMatrixRoom({
relations: { relations: {
@@ -124,7 +117,8 @@ function createInCallView(): RenderResult & {
} as unknown as RelationsContainer, } as unknown as RelationsContainer,
client, client,
roomId, roomId,
getMember: (userId) => roomMembers.get(userId) ?? null, // getMember: (userId) => roomMembers.get(userId) ?? null,
getMembers: () => Array.from(roomMembers.values()),
getMxcAvatarUrl: () => null, getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true), hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null, getCanonicalAlias: () => null,
@@ -133,10 +127,7 @@ function createInCallView(): RenderResult & {
} as Partial<RoomState> as RoomState, } as Partial<RoomState> as RoomState,
}); });
const muteState = { const muteState = mockMuteStates();
audio: { enabled: false },
video: { enabled: false },
} as MuteStates;
const livekitRoom = mockLivekitRoom( const livekitRoom = mockLivekitRoom(
{ {
localParticipant, localParticipant,
@@ -153,14 +144,14 @@ function createInCallView(): RenderResult & {
<MediaDevicesContext value={mockMediaDevices({})}> <MediaDevicesContext value={mockMediaDevices({})}>
<ReactionsSenderProvider <ReactionsSenderProvider
vm={vm} vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession} rtcSession={rtcSession.asMockedSession()}
> >
<TooltipProvider> <TooltipProvider>
<RoomContext value={livekitRoom}> <RoomContext value={livekitRoom}>
<InCallView <InCallView
client={client} client={client}
header={HeaderStyle.Standard} header={HeaderStyle.Standard}
rtcSession={rtcSession as unknown as MatrixRTCSession} rtcSession={rtcSession.asMockedSession()}
muteStates={muteState} muteStates={muteState}
vm={vm} vm={vm}
matrixInfo={{ matrixInfo={{
@@ -176,12 +167,6 @@ function createInCallView(): RenderResult & {
}, },
}} }}
matrixRoom={room} matrixRoom={room}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null} onShareClick={null}
/> />
</RoomContext> </RoomContext>
@@ -203,71 +188,4 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
}); });
describe("toDevice label", () => {
it("is shown if setting activated and room encrypted", () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { getByText } = createInCallView();
expect(getByText("using to Device key transport")).toBeInTheDocument();
});
it("is not shown in unenecrypted room", () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.NONE,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is hidden once fallback was triggered", async () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { rtcSession, queryByText } = createInCallView();
expect(queryByText("using to Device key transport")).toBeInTheDocument();
expect(rtcSession).toBeDefined();
await act(() =>
rtcSession.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, {
toDevice: true,
room: true,
}),
);
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if setting is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(false);
developerModeSetting.setValue(true);
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if developer mode is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(false);
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
});
}); });

View File

@@ -5,9 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk"; import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
type FC, type FC,
@@ -25,9 +23,8 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { useObservable, useSubscription } from "observable-hooks"; import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { import {
VoiceCallSolidIcon, VoiceCallSolidIcon,
VolumeOnSolidIcon, VolumeOnSolidIcon,
@@ -55,27 +52,21 @@ import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useLivekit } from "../livekit/useLivekit.ts";
import { useWakeLock } from "../useWakeLock"; import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle"; import { LayoutToggle } from "./LayoutToggle";
import { type ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import { import {
CallViewModel, type CallViewModel,
createCallViewModel$,
type GridMode, type GridMode,
type Layout, } from "../state/CallViewModel/CallViewModel.ts";
} from "../state/CallViewModel";
import { Grid, type TileProps } from "../grid/Grid"; import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile"; import { SpotlightTile } from "../tile/SpotlightTile";
import { import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
useRoomEncryptionSystem,
type EncryptionSystem,
} from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout"; import { makeGridLayout } from "../grid/GridLayout";
import { import {
@@ -97,114 +88,87 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { import {
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
developerMode as developerModeSetting,
useSetting, useSetting,
} from "../settings/settings"; } from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader"; import { ReactionsReader } from "../reactions/ReactionsReader";
import { ConnectionLostError } from "../utils/errors.ts"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { useTypedEventEmitter } from "../useEvents.ts";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts"; import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx"; import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); import { Avatar, Size as AvatarSize } from "../Avatar";
import waitingStyles from "./WaitingForJoin.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
const maxTapDurationMs = 400; const maxTapDurationMs = 400;
export interface ActiveCallProps export interface ActiveCallProps
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> { extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
e2eeSystem: EncryptionSystem; e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
) => void;
} }
export const ActiveCall: FC<ActiveCallProps> = (props) => { export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLivekit(
props.rtcSession,
props.muteStates,
sfuConfig,
props.e2eeSystem,
);
const connStateObservable$ = useObservable(
(inputs$) => inputs$.pipe(map(([connState]) => connState)),
[connState],
);
const [vm, setVm] = useState<CallViewModel | null>(null); const [vm, setVm] = useState<CallViewModel | null>(null);
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => { useEffect(() => {
logger.info( const scope = new ObservableScope();
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`, const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = createCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,
mediaDevices,
props.muteStates,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
); );
setVm(vm);
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
return (): void => { return (): void => {
logger.info( scope.end();
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
});
}; };
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
const vm = new CallViewModel(
props.rtcSession,
props.matrixRoom,
livekitRoom,
mediaDevices,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
);
setVm(vm);
return (): void => {
vm.destroy();
reactionsReader.destroy();
};
}
}, [ }, [
props.rtcSession, props.rtcSession,
props.matrixRoom, props.matrixRoom,
livekitRoom, props.muteStates,
mediaDevices,
props.e2eeSystem, props.e2eeSystem,
connStateObservable$, props.onLeft,
autoLeaveWhenOthersLeft, urlParams,
mediaDevices,
trackProcessorState$,
]); ]);
if (livekitRoom === undefined || vm === null) return null; if (vm === null) return null;
return ( return (
<RoomContext value={livekitRoom}> <ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}> <InCallView {...props} vm={vm} />
<InCallView </ReactionsSenderProvider>
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
</ReactionsSenderProvider>
</RoomContext>
); );
}; };
@@ -214,14 +178,9 @@ export interface InCallViewProps {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom; matrixRoom: MatrixRoom;
livekitRoom: LivekitRoom;
muteStates: MuteStates; muteStates: MuteStates;
participantCount: number;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
header: HeaderStyle; header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
} }
@@ -229,14 +188,10 @@ export const InCallView: FC<InCallViewProps> = ({
client, client,
vm, vm,
matrixInfo, matrixInfo,
rtcSession,
matrixRoom, matrixRoom,
livekitRoom,
muteStates, muteStates,
participantCount,
onLeave,
header: headerStyle, header: headerStyle,
connState,
onShareClick, onShareClick,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -244,74 +199,60 @@ export const InCallView: FC<InCallViewProps> = ({
useReactionsSender(); useReactionsSender();
useWakeLock(); useWakeLock();
// TODO-MULTI-SFU This is unused now??
// const connectionState = useObservableEagerState(vm.livekitConnectionState$);
// annoyingly we don't get the disconnection reason this way, // annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event // only by listening for the emitted event
if (connState === ConnectionState.Disconnected) // This needs to be done differential. with the vm connection state we start with Disconnected.
throw new ConnectionLostError(); // TODO-MULTI-SFU decide how to handle this properly
// @BillCarsonFr
// if (connectionState === ConnectionState.Disconnected)
// throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null); const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure(); const [containerRef2, bounds] = useMeasure();
// Merge the refs so they can attach to the same element // Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2); const containerRef = useMergedRefs(containerRef1, containerRef2);
const { hideScreensharing, showControls } = useUrlParams(); const { showControls } = useUrlParams();
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
room: livekitRoom,
});
const muteAllAudio = useBehavior(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
// Call pickup state and display names are needed for waiting overlay/sounds
const callPickupState = useBehavior(vm.callPickupState$);
// This seems like it might be enough logic to use move it into the call view model? // Preload a waiting and decline sounds
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); const pickupPhaseSoundCache = useInitial(async () => {
useTypedEventEmitter( return prefetchSounds({
rtcSession, waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
RoomAndToDeviceEvents.EnabledTransportsChanged, });
(enabled) => setDidFallbackToRoomKey(enabled.room), });
);
const [developerMode] = useSetting(developerModeSetting); const pickupPhaseAudio = useAudioContext({
const [useExperimentalToDeviceTransport] = useSetting( sounds: pickupPhaseSoundCache,
useExperimentalToDeviceTransportSetting, latencyHint: "interactive",
); muted: muteAllAudio,
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId); });
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const showToDeviceEncryption = useMemo( const audioEnabled = useBehavior(muteStates.audio.enabled$);
() => const videoEnabled = useBehavior(muteStates.video.enabled$);
developerMode && const toggleAudio = useBehavior(muteStates.audio.toggle$);
useExperimentalToDeviceTransport && const toggleVideo = useBehavior(muteStates.video.toggle$);
encryptionSystem.kind === E2eeType.PER_PARTICIPANT && const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
!didFallbackToRoomKey,
[
developerMode,
useExperimentalToDeviceTransport,
encryptionSystem.kind,
didFallbackToRoomKey,
],
);
const toggleMicrophone = useCallback(
() => muteStates.audio.setEnabled?.((e) => !e),
[muteStates],
);
const toggleCamera = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case. // This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability! // TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
containerRef1, containerRef1,
toggleMicrophone, toggleAudio,
toggleCamera, toggleVideo,
(muted) => muteStates.audio.setEnabled?.(!muted), setAudioEnabled,
(reaction) => void sendReaction(reaction), (reaction) => void sendReaction(reaction),
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const audioParticipants = useBehavior(vm.audioParticipants$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);
@@ -322,7 +263,65 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave); const sharingScreen = useBehavior(vm.sharingScreen$);
const ringOverlay = useBehavior(vm.ringOverlay$);
const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary
if (fatalCallError) throw fatalCallError;
// We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
useEffect((): (() => void) => {
// The CSS animation includes the delay, so we must double the length of the sound.
window.document.body.style.setProperty(
"--call-ring-duration-s",
`${ringDuration * 2}s`,
);
window.document.body.style.setProperty(
"--call-ring-delay-s",
`${ringDuration}s`,
);
// Remove properties when we unload.
return () => {
window.document.body.style.removeProperty("--call-ring-duration-s");
window.document.body.style.removeProperty("--call-ring-delay-s");
};
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
// When waiting for pickup, loop a waiting sound
useEffect((): void | (() => void) => {
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}, [callPickupState, pickupPhaseAudio, ringDuration]);
// Waiting UI overlay
const waitingOverlay: JSX.Element | null = useMemo(() => {
return ringOverlay ? (
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
<div
className={classNames(overlayStyles.content, waitingStyles.content)}
>
<div className={waitingStyles.pulse}>
<Avatar
id={ringOverlay.idForAvatar}
name={ringOverlay.name}
src={ringOverlay.avatarMxc}
size={AvatarSize.XL}
/>
</div>
<Text size="md" className={waitingStyles.text}>
{ringOverlay.text}
</Text>
</div>
</div>
) : null;
}, [ringOverlay]);
// Ideally we could detect taps by listening for click events and checking // Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported // that the pointerType of the event is "touch", but this isn't yet supported
@@ -532,6 +531,38 @@ export const InCallView: FC<InCallViewProps> = ({
} }
} }
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
// We need to use a non-modal toast to avoid trapping focus within the toast.
// However, a non-modal toast will not render any background overlay on its
// own, so we must render one manually.
const reconnectingToast = (
<>
<div
className={classNames(overlayStyles.bg, overlayStyles.animate)}
data-state={reconnecting ? "open" : "closed"}
/>
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
modal={false}
>
{t("common.reconnecting")}
</Toast>
</>
);
const earpieceOverlay = (
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
);
// If the reconnecting toast or earpiece overlay obscures the media tiles, we
// need to remove them from the accessibility tree and block focus.
const contentObscured = reconnecting || earpieceMode;
const Tile = useMemo( const Tile = useMemo(
() => () =>
function Tile({ function Tile({
@@ -561,6 +592,7 @@ export const InCallView: FC<InCallViewProps> = ({
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue} showSpeakingIndicators={showSpeakingIndicatorsValue}
focusable={!contentObscured}
/> />
) : ( ) : (
<SpotlightTile <SpotlightTile
@@ -571,12 +603,13 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue} showIndicators={showSpotlightIndicatorsValue}
focusable={!contentObscured}
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
/> />
); );
}, },
[vm, openProfile], [vm, openProfile, contentObscured],
); );
const layouts = useMemo(() => { const layouts = useMemo(() => {
@@ -605,6 +638,8 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.height} targetWidth={gridBounds.height}
targetHeight={gridBounds.width} targetHeight={gridBounds.width}
showIndicators={false} showIndicators={false}
focusable={!contentObscured}
aria-hidden={contentObscured}
/> />
); );
} }
@@ -622,6 +657,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout} model={layout}
Layout={layers.fixed} Layout={layers.fixed}
Tile={Tile} Tile={Tile}
aria-hidden={contentObscured}
/> />
); );
const scrollingGrid = ( const scrollingGrid = (
@@ -631,6 +667,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout} model={layout}
Layout={layers.scrolling} Layout={layers.scrolling}
Tile={Tile} Tile={Tile}
aria-hidden={contentObscured}
/> />
); );
// The grid tiles go *under* the spotlight in the portrait layout, but // The grid tiles go *under* the spotlight in the portrait layout, but
@@ -652,44 +689,33 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId, matrixRoom.roomId,
); );
const toggleScreensharing = useCallback(() => {
localParticipant
.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
})
.catch(logger.error);
}, [localParticipant, isScreenShareEnabled]);
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
buttons.push( buttons.push(
<MicButton <MicButton
key="audio" key="audio"
muted={!muteStates.audio.enabled} muted={!audioEnabled}
onClick={toggleMicrophone} onClick={toggleAudio ?? undefined}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
disabled={muteStates.audio.setEnabled === null} disabled={toggleAudio === null}
data-testid="incall_mute" data-testid="incall_mute"
/>, />,
<VideoButton <VideoButton
key="video" key="video"
muted={!muteStates.video.enabled} muted={!videoEnabled}
onClick={toggleCamera} onClick={toggleVideo ?? undefined}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
disabled={muteStates.video.setEnabled === null} disabled={toggleVideo === null}
data-testid="incall_videomute" data-testid="incall_videomute"
/>, />,
); );
if (canScreenshare && !hideScreensharing) { if (vm.toggleScreenSharing !== null) {
buttons.push( buttons.push(
<ShareScreenButton <ShareScreenButton
key="share_screen" key="share_screen"
className={styles.shareScreen} className={styles.shareScreen}
enabled={isScreenShareEnabled} enabled={sharingScreen}
onClick={toggleScreensharing} onClick={vm.toggleScreenSharing}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
data-testid="incall_screenshare" data-testid="incall_screenshare"
/>, />,
@@ -719,7 +745,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton <EndCallButton
key="end_call" key="end_call"
onClick={function (): void { onClick={function (): void {
onLeave(); vm.hangup();
}} }}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave" data-testid="incall_leave"
@@ -760,9 +786,6 @@ export const InCallView: FC<InCallViewProps> = ({
</div> </div>
); );
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
return ( return (
<div <div
className={styles.inRoom} className={styles.inRoom}
@@ -774,34 +797,22 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerOut={onPointerOut} onPointerOut={onPointerOut}
> >
{header} {header}
{ {audioParticipants.map(({ livekitRoom, url, participants }) => (
// TODO: remove this once we remove the developer flag gets removed and we have shipped to <LivekitRoomAudioRenderer
// device transport as the default. key={url}
showToDeviceEncryption && ( url={url}
<Text livekitRoom={livekitRoom}
style={{ height: 0, zIndex: 1, alignSelf: "center", margin: 0 }} validIdentities={participants}
size="sm" muted={muteAllAudio}
> />
using to Device key transport ))}
</Text>
)
}
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
{renderContent()} {renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} /> <CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} /> <ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<Toast {reconnectingToast}
onDismiss={onDismissReconnectingToast} {earpieceOverlay}
open={reconnecting}
portal={false}
>
{t("common.reconnecting")}
</Toast>
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} /> <ReactionsOverlay vm={vm} />
{waitingOverlay}
{footer} {footer}
{layout.type !== "pip" && ( {layout.type !== "pip" && (
<> <>
@@ -813,7 +824,8 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={closeSettings} onDismiss={closeSettings}
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
livekitRoom={livekitRoom} // TODO expose correct data to setttings modal
livekitRooms={[]}
/> />
</> </>
)} )}

View File

@@ -31,7 +31,7 @@ import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { import {
EndCallButton, EndCallButton,
@@ -50,8 +50,8 @@ import {
useTrackProcessorSync, useTrackProcessorSync,
} from "../livekit/TrackProcessorContext"; } from "../livekit/TrackProcessorContext";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { useLatest } from "../useLatest";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -88,14 +88,10 @@ export const LobbyView: FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
usePageTitle(matrixInfo.roomName); usePageTitle(matrixInfo.roomName);
const onAudioPress = useCallback( const audioEnabled = useBehavior(muteStates.audio.enabled$);
() => muteStates.audio.setEnabled?.((e) => !e), const videoEnabled = useBehavior(muteStates.video.enabled$);
[muteStates], const toggleAudio = useBehavior(muteStates.audio.toggle$);
); const toggleVideo = useBehavior(muteStates.video.toggle$);
const onVideoPress = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -133,7 +129,7 @@ export const LobbyView: FC<Props> = ({
// re-open the devices when they change (see below). // re-open the devices when they change (see below).
const initialAudioOptions = useInitial( const initialAudioOptions = useInitial(
() => () =>
muteStates.audio.enabled && { audioEnabled && {
deviceId: getValue(devices.audioInput.selected$)?.id, deviceId: getValue(devices.audioInput.selected$)?.id,
}, },
); );
@@ -150,27 +146,21 @@ export const LobbyView: FC<Props> = ({
// We also pass in a clone because livekit mutates the object passed in, // We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render. // which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions), audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && { video: videoEnabled && {
deviceId: videoInputId, deviceId: videoInputId,
processor: initialProcessor, processor: initialProcessor,
}, },
}), }),
[ [initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
initialAudioOptions,
muteStates.video.enabled,
videoInputId,
initialProcessor,
],
); );
const latestMuteStates = useLatest(muteStates);
const onError = useCallback( const onError = useCallback(
(error: Error) => { (error: Error) => {
logger.error("Error while creating preview Tracks:", error); logger.error("Error while creating preview Tracks:", error);
latestMuteStates.current.audio.setEnabled?.(false); muteStates.audio.setEnabled$.value?.(false);
latestMuteStates.current.video.setEnabled?.(false); muteStates.video.setEnabled$.value?.(false);
}, },
[latestMuteStates], [muteStates],
); );
const tracks = usePreviewTracks(localTrackOptions, onError); const tracks = usePreviewTracks(localTrackOptions, onError);
@@ -217,7 +207,7 @@ export const LobbyView: FC<Props> = ({
<div className={styles.content}> <div className={styles.content}>
<VideoPreview <VideoPreview
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} videoEnabled={videoEnabled}
videoTrack={videoTrack} videoTrack={videoTrack}
> >
<Button <Button
@@ -225,6 +215,7 @@ export const LobbyView: FC<Props> = ({
[styles.wait]: waitingForInvite, [styles.wait]: waitingForInvite,
})} })}
size={waitingForInvite ? "sm" : "lg"} size={waitingForInvite ? "sm" : "lg"}
disabled={waitingForInvite}
onClick={() => { onClick={() => {
if (!waitingForInvite) onEnter(); if (!waitingForInvite) onEnter();
}} }}
@@ -239,14 +230,14 @@ export const LobbyView: FC<Props> = ({
{recentsButtonInFooter && recentsButton} {recentsButtonInFooter && recentsButton}
<div className={inCallStyles.buttons}> <div className={inCallStyles.buttons}>
<MicButton <MicButton
muted={!muteStates.audio.enabled} muted={!audioEnabled}
onClick={onAudioPress} onClick={toggleAudio ?? undefined}
disabled={muteStates.audio.setEnabled === null} disabled={toggleAudio === null}
/> />
<VideoButton <VideoButton
muted={!muteStates.video.enabled} muted={!videoEnabled}
onClick={onVideoPress} onClick={toggleVideo ?? undefined}
disabled={muteStates.video.setEnabled === null} disabled={toggleVideo === null}
/> />
<SettingsButton onClick={openSettings} /> <SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />} {!confineToRoom && <EndCallButton onClick={onLeaveClick} />}

View File

@@ -1,258 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
afterAll,
afterEach,
describe,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { type FC, useCallback, useState } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { of } from "rxjs";
import { useMuteStates } from "./MuteStates";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
vi.mock("@livekit/components-core");
interface TestComponentProps {
isJoined?: boolean;
}
const TestComponent: FC<TestComponentProps> = ({ isJoined = false }) => {
const muteStates = useMuteStates(isJoined);
const onToggleAudio = useCallback(
() => muteStates.audio.setEnabled?.(!muteStates.audio.enabled),
[muteStates],
);
return (
<div>
<div data-testid="audio-enabled">
{muteStates.audio.enabled.toString()}
</div>
<button onClick={onToggleAudio}>Toggle audio</button>
<div data-testid="video-enabled">
{muteStates.video.enabled.toString()}
</div>
</div>
);
};
const mockMicrophone: MediaDeviceInfo = {
deviceId: "",
kind: "audioinput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
const mockSpeaker: MediaDeviceInfo = {
deviceId: "",
kind: "audiooutput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
const mockCamera: MediaDeviceInfo = {
deviceId: "",
kind: "videoinput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
function mockMediaDevices(
{
microphone,
speaker,
camera,
}: {
microphone?: boolean;
speaker?: boolean;
camera?: boolean;
} = { microphone: true, speaker: true, camera: true },
): MediaDevices {
vi.mocked(createMediaDeviceObserver).mockImplementation((kind) => {
switch (kind) {
case "audioinput":
return of(microphone ? [mockMicrophone] : []);
case "audiooutput":
return of(speaker ? [mockSpeaker] : []);
case "videoinput":
return of(camera ? [mockCamera] : []);
case undefined:
throw new Error("Unimplemented");
}
});
const scope = new ObservableScope();
onTestFinished(() => scope.end());
return new MediaDevices(scope);
}
describe("useMuteStates", () => {
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
it("disabled when no input devices", () => {
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext
value={mockMediaDevices({
microphone: false,
camera: false,
})}
>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("enables devices by default in the lobby", () => {
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
it("disables devices by default in the call", () => {
// Disabling new devices in the call ensures that connecting a webcam
// mid-call won't cause it to suddenly be enabled without user input
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent isJoined />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("uses defaults from config", () => {
mockConfig({
media_devices: {
enable_audio: false,
enable_video: false,
},
});
render(
<MemoryRouter>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("skipLobby mutes inputs", () => {
mockConfig();
render(
<MemoryRouter
initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("remembers previous state when devices disappear and reappear", async () => {
const user = userEvent.setup();
mockConfig();
const noDevices = mockMediaDevices({ microphone: false, camera: false });
// Warm up these Observables before making further changes to the
// createMediaDevicesObserver mock
noDevices.audioInput.available$.subscribe(() => {}).unsubscribe();
noDevices.videoInput.available$.subscribe(() => {}).unsubscribe();
const someDevices = mockMediaDevices();
const ReappearanceTest: FC = () => {
const [devices, setDevices] = useState(someDevices);
const onConnectDevicesClick = useCallback(
() => setDevices(someDevices),
[],
);
const onDisconnectDevicesClick = useCallback(
() => setDevices(noDevices),
[],
);
return (
<MemoryRouter>
<MediaDevicesContext value={devices}>
<TestComponent />
<button onClick={onConnectDevicesClick}>Connect devices</button>
<button onClick={onDisconnectDevicesClick}>
Disconnect devices
</button>
</MediaDevicesContext>
</MemoryRouter>
);
};
render(<ReappearanceTest />);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
await user.click(screen.getByRole("button", { name: "Toggle audio" }));
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
await user.click(
screen.getByRole("button", { name: "Disconnect devices" }),
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
await user.click(screen.getByRole("button", { name: "Connect devices" }));
// Audio should remember that it was muted, while video should re-enable
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});

View File

@@ -1,166 +0,0 @@
/*
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 Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
} from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import {
type DeviceLabel,
type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import { useIsEarpiece, useMediaDevices } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config";
import { useUrlParams } from "../UrlParams";
/**
* If there already are this many participants in the call, we automatically mute
* the user.
*/
export const MUTE_PARTICIPANT_COUNT = 8;
interface DeviceAvailable {
enabled: boolean;
setEnabled: Dispatch<SetStateAction<boolean>>;
}
interface DeviceUnavailable {
enabled: false;
setEnabled: null;
}
const deviceUnavailable: DeviceUnavailable = {
enabled: false,
setEnabled: null,
};
type MuteState = DeviceAvailable | DeviceUnavailable;
export interface MuteStates {
audio: MuteState;
video: MuteState;
}
function useMuteState(
device: MediaDevice<DeviceLabel, SelectedDevice>,
enabledByDefault: () => boolean,
forceUnavailable: boolean = false,
): MuteState {
const available = useObservableEagerState(device.available$);
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
// Determine the default value once devices are actually connected
(prev) => prev ?? (available.size > 0 ? enabledByDefault() : undefined),
[available.size],
);
return useMemo(
() =>
available.size === 0 || forceUnavailable
? deviceUnavailable
: {
enabled: enabled ?? false,
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
},
[available.size, enabled, forceUnavailable, setEnabled],
);
}
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
const { skipLobby } = useUrlParams();
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
// This is one of the main changes that makes iOS work with bluetooth audio devices.
if (audio.enabled) {
devices.requestDeviceNames();
}
}, [audio.enabled, devices]);
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
isEarpiece, // Force video to be unavailable if using earpiece
);
useEffect(() => {
widget?.api.transport
.send(ElementWidgetActions.DeviceMute, {
audio_enabled: audio.enabled,
video_enabled: video.enabled,
})
.catch((e) =>
logger.warn("Could not send DeviceMute action to widget", e),
);
}, [audio, video]);
const onMuteStateChangeRequest = useCallback(
(ev: CustomEvent<IWidgetApiRequest>) => {
// First copy the current state into our new state.
const newState = {
audio_enabled: audio.enabled,
video_enabled: video.enabled,
};
// Update new state if there are any requested changes from the widget action
// in `ev.detail.data`.
if (
ev.detail.data.audio_enabled != null &&
typeof ev.detail.data.audio_enabled === "boolean"
) {
audio.setEnabled?.(ev.detail.data.audio_enabled);
newState.audio_enabled = ev.detail.data.audio_enabled;
}
if (
ev.detail.data.video_enabled != null &&
typeof ev.detail.data.video_enabled === "boolean"
) {
video.setEnabled?.(ev.detail.data.video_enabled);
newState.video_enabled = ev.detail.data.video_enabled;
}
// Always reply with the new (now "current") state.
// This allows to also use this action to just get the unaltered current state
// by using a fromWidget request with: `ev.detail.data = {}`
widget!.api.transport.reply(ev.detail, newState);
},
[audio, video],
);
useEffect(() => {
// We setup a event listener for the widget action ElementWidgetActions.DeviceMute.
if (widget) {
// only setup the listener in widget mode
widget.lazyActions.on(
ElementWidgetActions.DeviceMute,
onMuteStateChangeRequest,
);
return (): void => {
// return a call to `off` so that we always clean up our listener.
widget?.lazyActions.off(
ElementWidgetActions.DeviceMute,
onMuteStateChangeRequest,
);
};
}
}, [onMuteStateChangeRequest]);
return useMemo(() => ({ audio, video }), [audio, video]);
}

View File

@@ -10,8 +10,9 @@ import {
afterAll, afterAll,
afterEach, afterEach,
beforeEach, beforeEach,
describe,
expect, expect,
test, it,
vitest, vitest,
type MockedFunction, type MockedFunction,
type Mock, type Mock,
@@ -27,7 +28,7 @@ import {
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { GenericReaction, ReactionSet } from "../reactions"; import { GenericReaction, ReactionSet } from "../reactions";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { import {
alice, alice,
@@ -45,123 +46,129 @@ function TestComponent({ vm }: { vm: CallViewModel }): ReactNode {
); );
} }
vitest.mock("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
afterEach(() => {
vitest.resetAllMocks();
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
});
afterAll(() => {
vitest.restoreAllMocks();
});
let playSound: Mock< let playSound: Mock<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
>; >;
beforeEach(() => { describe("ReactionAudioRenderer", () => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({ afterEach(() => {
sound: new ArrayBuffer(0), playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
}); });
playSound = vitest.fn(); beforeEach(() => {
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue(
playSound, {
}); sound: new ArrayBuffer(0),
}); },
test("preloads all audio elements", () => {
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
expect(prefetchSounds).toHaveBeenCalledOnce();
});
test("will play an audio sound when there is a reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !!r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
); );
} playSound = vitest.fn();
act(() => { (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue(
reactionsSubject$.next({ {
[aliceRtcMember.deviceId]: { playSound,
reactionOption: chosenReaction, playSoundLooping: vitest.fn(),
expireAfter: new Date(0), soundDuration: {},
}, },
});
});
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
});
test("will play the generic audio sound when there is soundless reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
); );
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: chosenReaction,
expireAfter: new Date(0),
},
});
}); });
expect(playSound).toHaveBeenCalledWith(GenericReaction.name); afterAll(() => {
}); vitest.restoreAllMocks();
});
test("will play multiple audio sounds when there are multiple different reactions", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([ it("preloads all audio elements", () => {
local, const { vm } = getBasicCallViewModelEnvironment([local, alice]);
alice, playReactionsSoundSetting.setValue(true);
]); render(<TestComponent vm={vm} />);
playReactionsSoundSetting.setValue(true); expect(prefetchSounds).toHaveBeenCalledOnce();
render(<TestComponent vm={vm} />); });
// Find the first reaction with a sound effect it("will play an audio sound when there is a reaction", () => {
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound); const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
if (!reaction1 || !reaction2) { local,
throw Error( alice,
"No reactions have sounds configured, this test cannot succeed", ]);
); playReactionsSoundSetting.setValue(true);
} render(<TestComponent vm={vm} />);
act(() => {
reactionsSubject$.next({ // Find the first reaction with a sound effect
[aliceRtcMember.deviceId]: { const chosenReaction = ReactionSet.find((r) => !!r.sound);
reactionOption: reaction1, if (!chosenReaction) {
expireAfter: new Date(0), throw Error(
}, "No reactions have sounds configured, this test cannot succeed",
[bobRtcMember.deviceId]: { );
reactionOption: reaction2, }
expireAfter: new Date(0), act(() => {
}, reactionsSubject$.next({
[localRtcMember.deviceId]: { [aliceRtcMember.deviceId]: {
reactionOption: reaction1, reactionOption: chosenReaction,
expireAfter: new Date(0), expireAfter: new Date(0),
}, },
}); });
});
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
});
it("will play the generic audio sound when there is soundless reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: chosenReaction,
expireAfter: new Date(0),
},
});
});
expect(playSound).toHaveBeenCalledWith(GenericReaction.name);
});
it("will play multiple audio sounds when there are multiple different reactions", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
if (!reaction1 || !reaction2) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: reaction1,
expireAfter: new Date(0),
},
[bobRtcMember.deviceId]: {
reactionOption: reaction2,
expireAfter: new Date(0),
},
[localRtcMember.deviceId]: {
reactionOption: reaction1,
expireAfter: new Date(0),
},
});
});
expect(playSound).toHaveBeenCalledWith(reaction1.name);
expect(playSound).toHaveBeenCalledWith(reaction2.name);
}); });
expect(playSound).toHaveBeenCalledWith(reaction1.name);
expect(playSound).toHaveBeenCalledWith(reaction2.name);
}); });

View File

@@ -12,7 +12,7 @@ import { GenericReaction, ReactionSet } from "../reactions";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
const soundMap = Object.fromEntries([ const soundMap = Object.fromEntries([
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [ ...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { expect, test, afterEach } from "vitest"; import { expect, test, afterEach, vi } from "vitest";
import { act } from "react"; import { act } from "react";
import { showReactions } from "../settings/settings"; import { showReactions } from "../settings/settings";
@@ -20,6 +20,8 @@ import {
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
vi.mock("livekit-client/e2ee-worker?worker");
afterEach(() => { afterEach(() => {
showReactions.setValue(showReactions.defaultValue); showReactions.setValue(showReactions.defaultValue);
}); });

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import styles from "./ReactionsOverlay.module.css"; import styles from "./ReactionsOverlay.module.css";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {

View File

@@ -20,6 +20,8 @@ import {
CheckIcon, CheckIcon,
UnknownSolidIcon, UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useObservable } from "observable-hooks";
import { map } from "rxjs";
import { useClientLegacy } from "../ClientContext"; import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
@@ -35,12 +37,13 @@ import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { Link } from "../button/Link"; import { Link } from "../button/Link";
import { ErrorView } from "../ErrorView"; import { ErrorView } from "../ErrorView";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; import { useMediaDevices } from "../MediaDevicesContext";
import { MuteStates } from "../state/MuteStates";
import { ObservableScope } from "../state/ObservableScope";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } = const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
@@ -61,10 +64,19 @@ export const RoomPage: FC = () => {
const { avatarUrl, displayName: userDisplayName } = useProfile(client); const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
const isJoined = useMatrixRTCSessionJoinState( const [joined, setJoined] = useState(false);
groupCallState.kind === "loaded" ? groupCallState.rtcSession : undefined,
const devices = useMediaDevices();
const [muteStates, setMuteStates] = useState<MuteStates | null>(null);
const joined$ = useObservable(
(inputs$) => inputs$.pipe(map(([joined]) => joined)),
[joined],
); );
const muteStates = useMuteStates(isJoined); useEffect(() => {
const scope = new ObservableScope();
setMuteStates(new MuteStates(scope, devices, joined$));
return (): void => scope.end();
}, [devices, joined$]);
useEffect(() => { useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as // If we've finished loading, are not already authed and we've been given a display name as
@@ -101,22 +113,25 @@ export const RoomPage: FC = () => {
} }
}, [groupCallState.kind]); }, [groupCallState.kind]);
const groupCallView = (): JSX.Element => { const groupCallView = (): ReactNode => {
switch (groupCallState.kind) { switch (groupCallState.kind) {
case "loaded": case "loaded":
return ( return (
<GroupCallView muteStates && (
widget={widget} <GroupCallView
client={client!} widget={widget}
rtcSession={groupCallState.rtcSession} client={client!}
isJoined={isJoined} rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser} joined={joined}
confineToRoom={confineToRoom} setJoined={setJoined}
preload={preload} isPasswordlessUser={passwordlessUser}
skipLobby={skipLobby || wasInWaitForInviteState.current} confineToRoom={confineToRoom}
header={header} preload={preload}
muteStates={muteStates} skipLobby={skipLobby || wasInWaitForInviteState.current}
/> header={header}
muteStates={muteStates}
/>
)
); );
case "waitForInvite": case "waitForInvite":
case "canKnock": { case "canKnock": {
@@ -135,31 +150,35 @@ export const RoomPage: FC = () => {
</> </>
); );
return ( return (
<LobbyView muteStates && (
client={client!} <LobbyView
matrixInfo={{ client={client!}
userId: client!.getUserId() ?? "", matrixInfo={{
displayName: userDisplayName ?? "", userId: client!.getUserId() ?? "",
avatarUrl: avatarUrl ?? "", displayName: userDisplayName ?? "",
roomAlias: null, avatarUrl: avatarUrl ?? "",
roomId: groupCallState.roomSummary.room_id, roomAlias: null,
roomName: groupCallState.roomSummary.name ?? "", roomId: groupCallState.roomSummary.room_id,
roomAvatar: groupCallState.roomSummary.avatar_url ?? null, roomName: groupCallState.roomSummary.name ?? "",
e2eeSystem: { roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
kind: groupCallState.roomSummary["im.nheko.summary.encryption"] e2eeSystem: {
? E2eeType.PER_PARTICIPANT kind: groupCallState.roomSummary[
: E2eeType.NONE, "im.nheko.summary.encryption"
}, ]
}} ? E2eeType.PER_PARTICIPANT
onEnter={(): void => knock?.()} : E2eeType.NONE,
enterLabel={label} },
waitingForInvite={groupCallState.kind === "waitForInvite"} }}
confineToRoom={confineToRoom} onEnter={(): void => knock?.()}
hideHeader={header !== "standard"} enterLabel={label}
participantCount={null} waitingForInvite={groupCallState.kind === "waitForInvite"}
muteStates={muteStates} confineToRoom={confineToRoom}
onShareClick={null} hideHeader={header !== "standard"}
/> participantCount={null}
muteStates={muteStates}
onShareClick={null}
/>
)
); );
} }
case "loading": case "loading":

View File

@@ -5,20 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { expect, describe, it, vi, beforeAll } from "vitest"; import { expect, describe, it, beforeAll } from "vitest";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "./MuteStates";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
function mockMuteStates({ audio = true, video = true } = {}): MuteStates {
return {
audio: { enabled: audio, setEnabled: vi.fn() },
video: { enabled: video, setEnabled: vi.fn() },
};
}
describe("VideoPreview", () => { describe("VideoPreview", () => {
const matrixInfo: MatrixInfo = { const matrixInfo: MatrixInfo = {
userId: "@a:example.org", userId: "@a:example.org",
@@ -49,7 +41,7 @@ describe("VideoPreview", () => {
const { queryByRole } = render( const { queryByRole } = render(
<VideoPreview <VideoPreview
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={mockMuteStates({ video: false })} videoEnabled={false}
videoTrack={null} videoTrack={null}
children={<></>} children={<></>}
/>, />,
@@ -61,7 +53,7 @@ describe("VideoPreview", () => {
const { queryByRole } = render( const { queryByRole } = render(
<VideoPreview <VideoPreview
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={mockMuteStates({ video: true })} videoEnabled
videoTrack={null} videoTrack={null}
children={<></>} children={<></>}
/>, />,

View File

@@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next";
import { TileAvatar } from "../tile/TileAvatar"; import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css"; import styles from "./VideoPreview.module.css";
import { type MuteStates } from "./MuteStates";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
export type MatrixInfo = { export type MatrixInfo = {
@@ -29,14 +28,14 @@ export type MatrixInfo = {
interface Props { interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; videoEnabled: boolean;
videoTrack: LocalVideoTrack | null; videoTrack: LocalVideoTrack | null;
children: ReactNode; children: ReactNode;
} }
export const VideoPreview: FC<Props> = ({ export const VideoPreview: FC<Props> = ({
matrixInfo, matrixInfo,
muteStates, videoEnabled,
videoTrack, videoTrack,
children, children,
}) => { }) => {
@@ -56,8 +55,8 @@ export const VideoPreview: FC<Props> = ({
}, [videoTrack]); }, [videoTrack]);
const cameraIsStarting = useMemo( const cameraIsStarting = useMemo(
() => muteStates.video.enabled && !videoTrack, () => videoEnabled && !videoTrack,
[muteStates.video.enabled, videoTrack], [videoEnabled, videoTrack],
); );
return ( return (
@@ -76,7 +75,7 @@ export const VideoPreview: FC<Props> = ({
tabIndex={-1} tabIndex={-1}
disablePictureInPicture disablePictureInPicture
/> />
{(!muteStates.video.enabled || cameraIsStarting) && ( {(!videoEnabled || cameraIsStarting) && (
<> <>
<div className={styles.avatarContainer}> <div className={styles.avatarContainer}>
{cameraIsStarting && ( {cameraIsStarting && (

View File

@@ -0,0 +1,61 @@
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pulse {
position: relative;
height: 90px;
}
.pulse::before {
content: "";
position: absolute;
inset: -12px;
border-radius: 9999px;
border: 12px solid rgba(255, 255, 255, 0.6);
animation: pulse var(--call-ring-duration-s) ease-out infinite;
animation-delay: 1s;
opacity: 0;
}
.text {
color: var(--cpd-color-text-on-solid-primary);
}
@keyframes pulse {
0% {
transform: scale(0.95);
opacity: 0.7;
transform: scale(0);
opacity: 1;
}
35% {
transform: scale(1.15);
opacity: 0.15;
}
50% {
transform: scale(1.2);
opacity: 0;
}
50.01% {
transform: scale(0);
}
85% {
transform: scale(0);
}
100% {
transform: scale(0);
}
}

View File

@@ -292,7 +292,7 @@ exports[`should have a close button in widget mode 1`] = `
Call is not supported Call is not supported
</h1> </h1>
<p> <p>
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_vczzf_8" class="_button_vczzf_8"
@@ -445,7 +445,7 @@ exports[`should render the error page with link back to home 1`] = `
Call is not supported Call is not supported
</h1> </h1>
<p> <p>
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_vczzf_8 homeLink" class="_button_vczzf_8 homeLink"
@@ -598,7 +598,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
Call is not supported Call is not supported
</h1> </h1>
<p> <p>
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_vczzf_8 homeLink" class="_button_vczzf_8 homeLink"

View File

@@ -49,15 +49,40 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</span> </span>
</div> </div>
<div
class="participantsLine"
>
<svg
aria-label="Participants"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
<path
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
data-testid="roomHeader_participants_count"
>
2
</span>
</div>
</div> </div>
</div> </div>
<div <div
class="nav rightNav" class="nav rightNav"
/> />
</header> </header>
<div>
mocked: MatrixAudioRenderer
</div>
<div <div
class="scrollingGrid grid" class="scrollingGrid grid"
> >
@@ -83,6 +108,11 @@ exports[`InCallView > rendering > renders 1`] = `
<div /> <div />
</div> </div>
<div <div
class="bg animate"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay" class="overlay"
data-show="false" data-show="false"
> >
@@ -255,7 +285,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="buttons" class="buttons"
> >
<button <button
aria-disabled="false" aria-disabled="true"
aria-labelledby="«r8»" aria-labelledby="«r8»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary" data-kind="primary"
@@ -278,7 +308,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-disabled="false" aria-disabled="true"
aria-labelledby="«rd»" aria-labelledby="«rd»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary" data-kind="primary"

View File

@@ -1,155 +0,0 @@
/*
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 { vi, type Mocked, test, expect } from "vitest";
import { type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";
import { withFakeTimers } from "../utils/test";
const withMockedPosthog = (
continuation: (posthog: Mocked<PosthogAnalytics>) => void,
): void => {
const posthog = vi.mocked({
trackEvent: vi.fn(),
} as unknown as PosthogAnalytics);
const instanceSpy = vi
.spyOn(PosthogAnalytics, "instance", "get")
.mockReturnValue(posthog);
try {
continuation(posthog);
} finally {
instanceSpy.mockRestore();
}
};
const mockRoomState = (
groupCallMemberContents: Record<string, unknown>[],
): RoomState => {
const stateEvents = groupCallMemberContents.map((content) => ({
getContent: (): Record<string, unknown> => content,
}));
return { getStateEvents: () => stateEvents } as unknown as RoomState;
};
test("checkForParallelCalls does nothing if all participants are in the same call", () => {
withFakeTimers(() => {
withMockedPosthog((posthog) => {
const roomState = mockRoomState([
{
"m.calls": [
{
"m.call_id": "1",
"m.devices": [
{
device_id: "Element Call",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
{
"m.call_id": null, // invalid
"m.devices": [
{
device_id: "Element Android",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
null, // invalid
],
},
{
"m.calls": [
{
"m.call_id": "1",
"m.devices": [
{
device_id: "Element Desktop",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
],
},
]);
checkForParallelCalls(roomState);
expect(posthog.trackEvent).not.toHaveBeenCalled();
});
});
});
test("checkForParallelCalls sends diagnostics to PostHog if there is a split-brain", () => {
withFakeTimers(() => {
withMockedPosthog((posthog) => {
const roomState = mockRoomState([
{
"m.calls": [
{
"m.call_id": "1",
"m.devices": [
{
device_id: "Element Call",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
{
"m.call_id": "2",
"m.devices": [
{
device_id: "Element Android",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
],
},
{
"m.calls": [
{
"m.call_id": "1",
"m.devices": [
{
device_id: "Element Desktop",
session_id: "a",
expires_ts: Date.now() + 1000,
},
],
},
{
"m.call_id": "2",
"m.devices": [
{
device_id: "Element Call",
session_id: "a",
expires_ts: Date.now() - 1000,
},
],
},
],
},
]);
checkForParallelCalls(roomState);
expect(posthog.trackEvent).toHaveBeenCalledWith({
eventName: "ParallelCalls",
participantsPerCall: {
"1": 2,
"2": 1,
},
});
});
});
});

View File

@@ -1,55 +0,0 @@
/*
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 { EventType, type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
function isObject(x: unknown): x is Record<string, unknown> {
return typeof x === "object" && x !== null;
}
/**
* Checks the state of a room for multiple calls happening in parallel, sending
* the details to PostHog if that is indeed what's happening. (This is unwanted
* as it indicates a split-brain scenario.)
*/
export function checkForParallelCalls(state: RoomState): void {
const now = Date.now();
const participantsPerCall = new Map<string, number>();
// For each participant in each call, increment the participant count
for (const e of state.getStateEvents(EventType.GroupCallMemberPrefix)) {
const content = e.getContent<Record<string, unknown>>();
const calls: unknown[] = Array.isArray(content["m.calls"])
? content["m.calls"]
: [];
for (const call of calls) {
if (isObject(call) && typeof call["m.call_id"] === "string") {
const devices: unknown[] = Array.isArray(call["m.devices"])
? call["m.devices"]
: [];
for (const device of devices) {
if (isObject(device) && (device["expires_ts"] as number) > now) {
const participantCount =
participantsPerCall.get(call["m.call_id"]) ?? 0;
participantsPerCall.set(call["m.call_id"], participantCount + 1);
}
}
}
}
}
if (participantsPerCall.size > 1) {
PosthogAnalytics.instance.trackEvent({
eventName: "ParallelCalls",
participantsPerCall: Object.fromEntries(participantsPerCall),
});
}
}

View File

@@ -1,45 +0,0 @@
/*
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 MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useRef } from "react";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
import { useTypedEventEmitterState } from "../useEvents";
/**
* Gets the currently active (livekit) focus for a MatrixRTC session
* This logic is specific to livekit foci where the whole call must use one
* and the same focus.
*/
export function useActiveLivekitFocus(
rtcSession: MatrixRTCSession,
): LivekitFocus | undefined {
const prevActiveFocus = useRef<LivekitFocus | undefined>(undefined);
return useTypedEventEmitterState(
rtcSession,
MatrixRTCSessionEvent.MembershipsChanged,
useCallback(() => {
const f = rtcSession.getActiveFocus();
// Only handle foci with type="livekit" for now.
if (f && isLivekitFocus(f) && !deepCompare(f, prevActiveFocus.current)) {
const oldestMembership = rtcSession.getOldestMembership();
logger.info(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(f)}`,
);
prevActiveFocus.current = f;
}
return prevActiveFocus.current;
}, [rtcSession]),
);
}

View File

@@ -1,218 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { expect, onTestFinished, test, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import EventEmitter from "events";
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";
import { mockConfig } from "./utils/test";
import { ElementWidgetActions, widget } from "./widget";
import { ErrorCode } from "./utils/errors.ts";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("./UrlParams", () => ({ getUrlParams }));
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
vi.mock("./widget", () => ({
...actualWidget,
widget: {
api: {
setAlwaysOnScreen: (): void => {},
transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() },
},
lazyActions: new EventEmitter(),
},
}));
test("It joins the correct Session", async () => {
const focusFromOlderMembership = {
type: "livekit",
livekit_service_url: "http://my-oldest-member-service-url.com",
livekit_alias: "my-oldest-member-service-alias",
};
const focusConfigFromWellKnown = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
};
const focusConfigFromWellKnown2 = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url2.com",
};
const clientWellKnown = {
"org.matrix.msc4143.rtc_foci": [
focusConfigFromWellKnown,
focusConfigFromWellKnown2,
],
};
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
async (domain) => {
if (domain === "example.org") {
return Promise.resolve(clientWellKnown);
}
return Promise.resolve({});
},
);
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
getOldestMembership: vi.fn().mockReturnValue({
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
}),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
await enterRTCSession(mockedSession, false);
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
[
{
livekit_alias: "my-oldest-member-service-alias",
livekit_service_url: "http://my-oldest-member-service-url.com",
type: "livekit",
},
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url2.com",
type: "livekit",
},
{
livekit_alias: "roomId",
livekit_service_url: "http://my-default-service-url.com",
type: "livekit",
},
],
{
focus_selection: "oldest_membership",
type: "livekit",
},
{
manageMediaKeys: false,
useLegacyMemberEvents: false,
useNewMembershipManager: true,
useExperimentalToDeviceTransport: false,
},
);
});
async function testLeaveRTCSession(
cause: "user" | "error",
expectClose: boolean,
): Promise<void> {
vi.clearAllMocks();
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
await leaveRTCSession(session, cause);
expect(session.leaveRoomSession).toHaveBeenCalled();
expect(widget!.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.HangupCall,
expect.anything(),
);
if (expectClose) {
expect(widget!.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(widget!.api.transport.stop).toHaveBeenCalled();
} else {
expect(widget!.api.transport.send).not.toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(widget!.api.transport.stop).not.toHaveBeenCalled();
}
}
test("leaveRTCSession closes the widget on a normal hangup", async () => {
await testLeaveRTCSession("user", true);
});
test("leaveRTCSession doesn't close the widget on a fatal error", async () => {
await testLeaveRTCSession("error", false);
});
test("leaveRTCSession doesn't close the widget when returning to lobby", async () => {
getUrlParams.mockReturnValue({ returnToLobby: true });
onTestFinished(() => void getUrlParams.mockReset());
await testLeaveRTCSession("user", false);
});
test("It fails with configuration error if no live kit url config is set in fallback", async () => {
mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
},
},
memberships: [],
getFocusInUse: vi.fn(),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
await expect(enterRTCSession(mockedSession, false)).rejects.toThrowError(
expect.objectContaining({ code: ErrorCode.MISSING_MATRIX_RTC_FOCUS }),
);
});
test("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => {
mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [
{
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
},
],
});
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn(),
joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession;
await enterRTCSession(mockedSession, false);
});

View File

@@ -1,199 +0,0 @@
/*
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 {
isLivekitFocus,
isLivekitFocusConfig,
type LivekitFocus,
type LivekitFocusActive,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config";
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
import { MatrixRTCFocusMissingError } from "./utils/errors";
import { getUrlParams } from "./UrlParams";
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
export function makeActiveFocus(): LivekitFocusActive {
return {
type: "livekit",
focus_selection: "oldest_membership",
};
}
async function makePreferredLivekitFoci(
rtcSession: MatrixRTCSession,
livekitAlias: string,
): Promise<LivekitFocus[]> {
logger.log("Start building foci_preferred list: ", rtcSession.room.roomId);
const preferredFoci: LivekitFocus[] = [];
// Make the Focus from the running rtc session the highest priority one
// This minimizes how often we need to switch foci during a call.
const focusInUse = rtcSession.getFocusInUse();
if (focusInUse && isLivekitFocus(focusInUse)) {
logger.log("Adding livekit focus from oldest member: ", focusInUse);
preferredFoci.push(focusInUse);
}
// Warm up the first focus we owned, to ensure livekit room is created before any state event sent.
let toWarmUp: LivekitFocus | undefined;
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = rtcSession.room.client.getDomain();
if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
const validWellKnownFoci = wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
});
if (validWellKnownFoci.length > 0) {
toWarmUp = validWellKnownFoci[0];
}
preferredFoci.push(...validWellKnownFoci);
}
}
const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf) {
const focusFormConf: LivekitFocus = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
toWarmUp = toWarmUp ?? focusFormConf;
logger.log("Adding livekit focus from config: ", focusFormConf);
preferredFoci.push(focusFormConf);
}
if (toWarmUp) {
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
await getSFUConfigWithOpenID(rtcSession.room.client, toWarmUp);
}
if (preferredFoci.length === 0)
throw new MatrixRTCFocusMissingError(domain ?? "");
return Promise.resolve(preferredFoci);
// TODO: we want to do something like this:
//
// const focusOtherMembers = await focusFromOtherMembers(
// rtcSession,
// livekitAlias,
// );
// if (focusOtherMembers) preferredFoci.push(focusOtherMembers);
}
export async function enterRTCSession(
rtcSession: MatrixRTCSession,
encryptMedia: boolean,
useNewMembershipManager = true,
useExperimentalToDeviceTransport = false,
): Promise<void> {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall();
// right now we assume everything is a room-scoped call
const livekitAlias = rtcSession.room.roomId;
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
rtcSession.joinRoomSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(),
{
notificationType: getUrlParams().sendNotificationType,
useNewMembershipManager,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport,
},
);
if (widget) {
try {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
} catch (e) {
logger.error("Failed to send join action", e);
}
}
}
const widgetPostHangupProcedure = async (
widget: WidgetHelpers,
cause: "user" | "error",
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> => {
try {
await widget.api.setAlwaysOnScreen(false);
} catch (e) {
logger.error("Failed to set call widget `alwaysOnScreen` to false", e);
}
// Wait for any last bits before hanging up.
await promiseBeforeHangup;
// We send the hangup event after the memberships have been updated
// calling leaveRTCSession.
// We need to wait because this makes the client hosting this widget killing the IFrame.
try {
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
if (cause === "user" && !getUrlParams().returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
}
};
export async function leaveRTCSession(
rtcSession: MatrixRTCSession,
cause: "user" | "error",
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> {
await rtcSession.leaveRoomSession();
if (widget) {
await widgetPostHangupProcedure(widget, cause, promiseBeforeHangup);
} else {
await promiseBeforeHangup;
}
}

Some files were not shown because too many files have changed in this diff Show More