mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
6 Commits
toger5/cre
...
hs/error-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a130ac70 | ||
|
|
82392bf582 | ||
|
|
f072fd2f9c | ||
|
|
c00298f4a5 | ||
|
|
0721d48c20 | ||
|
|
b16ba52e13 |
2
.github/workflows/blocked.yaml
vendored
2
.github/workflows/blocked.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
|
||||
10
.github/workflows/build-and-publish-docker.yaml
vendored
10
.github/workflows/build-and-publish-docker.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
@@ -50,10 +50,10 @@ jobs:
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
2
.github/workflows/build-element-call.yaml
vendored
2
.github/workflows/build-element-call.yaml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
16
.github/workflows/publish-embedded-packages.yaml
vendored
16
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -44,8 +44,6 @@ jobs:
|
||||
run: |
|
||||
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
|
||||
echo "TAG=rc" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "TAG=other" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -85,7 +83,7 @@ jobs:
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -103,7 +101,7 @@ jobs:
|
||||
id-token: write # required for the provenance flag on npm publish
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -144,7 +142,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -165,8 +163,6 @@ jobs:
|
||||
run: |
|
||||
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
fi
|
||||
@@ -199,7 +195,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
path: element-call
|
||||
|
||||
@@ -212,7 +208,7 @@ jobs:
|
||||
path: element-call/embedded/ios/Sources/dist
|
||||
|
||||
- name: Checkout element-call-swift
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: element-hq/element-call-swift
|
||||
path: element-call-swift
|
||||
@@ -264,7 +260,7 @@ jobs:
|
||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
8
.github/workflows/test.yaml
vendored
8
.github/workflows/test.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
2
.github/workflows/translations-download.yaml
vendored
2
.github/workflows/translations-download.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,7 +8,6 @@ dist-ssr
|
||||
.idea/
|
||||
public/config.json
|
||||
backend/synapse_tmp/*
|
||||
backend/synapse_tmp_othersite/*
|
||||
/coverage
|
||||
config.json
|
||||
|
||||
@@ -29,4 +28,4 @@ yarn-error.log
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.cache/
|
||||
18
README.md
18
README.md
@@ -206,22 +206,22 @@ See also:
|
||||
### Backend
|
||||
|
||||
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
|
||||
including federation:
|
||||
whole stack of components which is required for a local development environment:
|
||||
|
||||
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
|
||||
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
|
||||
- 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)
|
||||
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
|
||||
- Hostnames: `m.localhost`, `*.m.localhost`
|
||||
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
|
||||
certificates
|
||||
- Minimum TLS reverse proxy for
|
||||
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
|
||||
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
|
||||
- Synapse homeserver: `synapse.m.localhost`
|
||||
- MatrixRTC backend: `matrix-rtc.m.localhost`
|
||||
- Local Element Call development `call.m.localhost` via `yarn dev --host `
|
||||
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
|
||||
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
|
||||
- Element Web `app.m.localhost`
|
||||
- Note certificates will expire on Thu, 03 May 2035 10:32:02 GMT
|
||||
|
||||
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._**
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
@@ -19,18 +19,8 @@ database:
|
||||
|
||||
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.othersite.m.localhost"
|
||||
accept_keys_insecurely: true
|
||||
- server_name: "matrix.org"
|
||||
|
||||
experimental_features:
|
||||
# MSC3266: Room summary API. Used for knocking over federation
|
||||
@@ -38,21 +28,12 @@ experimental_features:
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
# sticky events for matrixRTC user state
|
||||
msc4354_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
# 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:
|
||||
@@ -64,3 +45,10 @@ rc_message:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
|
||||
per_second: 0.5
|
||||
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
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -6,6 +6,11 @@ rtc:
|
||||
port_range_start: 50100
|
||||
port_range_end: 50200
|
||||
use_external_ip: false
|
||||
#redis:
|
||||
# address: redis:6379
|
||||
# username: ""
|
||||
# password: ""
|
||||
# db: 0
|
||||
turn:
|
||||
enabled: false
|
||||
domain: localhost
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# domain synapse.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@@ -27,53 +26,14 @@ server {
|
||||
# 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:8008";
|
||||
location / {
|
||||
proxy_pass "http://homeserver:8008";
|
||||
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-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;
|
||||
@@ -81,7 +41,6 @@ server {
|
||||
}
|
||||
|
||||
# MatrixRTC reverse proxy
|
||||
# domain matrix-rtc.m.localhost
|
||||
# - MatrixRTC Authorization Service
|
||||
# - LiveKit SFU websocket signaling connection
|
||||
upstream jwt-auth-services {
|
||||
@@ -90,14 +49,16 @@ upstream jwt-auth-services {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
listen 8448 ssl;
|
||||
listen [::]:8448 ssl;
|
||||
server_name matrix-rtc.m.localhost;
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
http2 on;
|
||||
|
||||
|
||||
location ^~ /livekit/jwt/ {
|
||||
|
||||
@@ -133,54 +94,6 @@ 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
|
||||
server {
|
||||
listen 80;
|
||||
@@ -246,36 +159,3 @@ server {
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
|
||||
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
|
||||
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
|
||||
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
|
||||
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
|
||||
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
|
||||
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
|
||||
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
|
||||
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
|
||||
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
|
||||
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
|
||||
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
|
||||
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
|
||||
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
|
||||
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
|
||||
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
|
||||
MIIDGjCCAgKgAwIBAgIUGdiFHhH4KL2pqBjMQHQ+PVIkSV8wDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMDMy
|
||||
MDJaFw0zNTA1MDMxMDMyMDJaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
|
||||
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA2y0hjmNn1vRsVSdy
|
||||
8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH9jdT
|
||||
tG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeFw9fw
|
||||
eRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZNWnie
|
||||
Ui7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAveL9K
|
||||
FGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BLPiQU
|
||||
KGKrAgMBAAGjUDBOMB0GA1UdDgQWBBQJqBjMu61c1p24txw/y+kv3D+V6DAfBgNV
|
||||
HSMEGDAWgBQJqBjMu61c1p24txw/y+kv3D+V6DAMBgNVHRMEBTADAQH/MA0GCSqG
|
||||
SIb3DQEBCwUAA4IBAQB8m2YfFGLugNt5vAAOvNxVqDA8c72yCVYr3CBCpmTIEY5Z
|
||||
d3qVGhG9//ux6+J8ntkSwd9nV5GJyYXHukCG1VavnAWolWdNF/WAllf0jhLuz7kD
|
||||
/cJnuI1By4tBsBmSz851i6HJ4t5k99Be+6GQVzi0e7zzfxTHZE4xP2J6Ox8QbPsP
|
||||
n0m76nIp/WbWaJqzvIIjJhmUUPPv+4wN+eOArgjiGLzptM2qTtGZtd0c9nS5gvep
|
||||
+mEbSUN9zkhAroZf80wf+hEvy+fJ94VbZ9QjTzTg7odZLrsXGIe8DaG63EYRQ25b
|
||||
W5iYBAreln5fGSt7qHsGfqwZibTEk/Lx3dydO1Kg
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
|
||||
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
|
||||
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
|
||||
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
|
||||
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
|
||||
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
|
||||
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
|
||||
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
|
||||
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
|
||||
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
|
||||
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
|
||||
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
|
||||
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
|
||||
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
|
||||
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
|
||||
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
|
||||
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
|
||||
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
|
||||
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
|
||||
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
|
||||
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
|
||||
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
|
||||
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
|
||||
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
|
||||
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
|
||||
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDA2y0hjmNn1vRs
|
||||
VSdy8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH
|
||||
9jdTtG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeF
|
||||
w9fweRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZN
|
||||
WnieUi7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAv
|
||||
eL9KFGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BL
|
||||
PiQUKGKrAgMBAAECggEAAPX2kxi5AQ7ul82SzT1KgpSXyDHLdYaUyAoYnaX9RO+B
|
||||
8ylmpyeqygs4+KQS4EMJm9jpo85Oy37bIKdG3kljU6wQcKlL5Y+ZUOo1nzpV6fid
|
||||
hGVs6ts8VXw8KshKQ9AyccZ8L/pirUfgOffgTwfjY7/90zceAL/s98GuZWc62nkX
|
||||
55joQv/OikqYfAGP/U6Bp2Zyf23DwJB09Z3B6NnZj/ZyAbDrDEHuA15LhCOcCczp
|
||||
IU/mFEywBPHT9Tg4w4Beq78PeAETvku2UalYRLhP3RLlXr2oEbwUtINRVt2QjZ85
|
||||
Esps4uCqL/mgQluIebtudD9HL/YMlNPXue1mDXFxJQKBgQDgZZY4yJBcf488T1V6
|
||||
HNm06b/LvVGj253pKgw14hpY1xQu3Ymgzv1GEqzhSYdzxhpmj0tMUNHxAp+YdGQu
|
||||
SZ0wcPKhw0aYVkIjDRYDC3Wn5GJhyIEYHGYMo/n4l49UzHRBPOTDzp49DkHTKBgh
|
||||
XgIIazYT3CkjTIMRrkUv+qfIPQKBgQDcBGu/mqbjxs4sN3zqPS4aB21o6t6W0sXs
|
||||
ZP9w6RlTPQi5U2oRbftjZtYc0bbEgkMUImB1HwYPQT5pJ+MyC414xDvSc2exBr5d
|
||||
To6yyPIy78Tf5PHM12fpKV92nSvoz/pSjYcGxxDtKfPqu+t8mOJfjCV1lLLA+xuB
|
||||
DDaE4p8dBwKBgQCdAne6A5v/HMH8UQZeCxHJpESvKiiVnnU/UEx651nID7XvlNNX
|
||||
0X0mKqsMd4ZvW43ddSYan/JF0LAa3FW8jYWO/3jF9vzOWoysOdvNBZetgf/Uq5ao
|
||||
aDZ/YbzmVCXWD7jIbPMkjs3pqrAkL0mzDzQc7+dGviWKrV6IYIfIqnn7gQKBgDCz
|
||||
vdIk/qpO+JZrFfiX4Fucp0hhLTJ/p5ZDaRPqVVPKn+K+Jy2ChfIj8mNgvK9VEloj
|
||||
nexvGJ1J2PHYBX+vdPp1nbRhHWPfVUY8PHQw7QP/dToGaMvqJrNDGEGeWvjnCMc7
|
||||
UtdaO1H0Rm0AegkTopB56lTTvJnhO95eALd7nrMDAoGAEPdzJtWoKafp49svhSj0
|
||||
hiXQv2SPBwVUN4LZ4SOWiXUcmYYm80aNpYKLkBxYjrfqFWhE7NUHLGp8YorQWKY2
|
||||
acD9AReHk/xku0ABy6jeYmSCmCxASxst5liKD+l12sk0gB0rk5MBxB4Uu1MIbQZ2
|
||||
aCASX3AVD2/XyC2MKkzc8Eg=
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
|
||||
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
|
||||
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
|
||||
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
|
||||
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
|
||||
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
|
||||
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
|
||||
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
|
||||
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
|
||||
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
|
||||
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
|
||||
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
|
||||
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
|
||||
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
|
||||
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
|
||||
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
|
||||
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
|
||||
MIIDZzCCAk+gAwIBAgIUXizLjwkdqepX0bh0K3abeJxj68IwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMzU5
|
||||
MTFaFw0zNTA1MDMxMzU5MTFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrzGSScSgaQuZdELGFYiLiYRwr
|
||||
LKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI39WmH95aX3d+A
|
||||
U7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ouuN6AOdzWs8hp
|
||||
RYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFft1ZXxIZOCjDs
|
||||
rEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71IfieLCEzMTP1VXa
|
||||
tP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZrQUGnL2RtAgMB
|
||||
AAGjgaIwgZ8wHwYDVR0jBBgwFoAUCagYzLutXNaduLccP8vpL9w/legwCQYDVR0T
|
||||
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwMAYDVR0RBCkw
|
||||
J4IJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdDAdBgNVHQ4E
|
||||
FgQUfdh1p52ZgWyZcBgBXGwKi4EnUE0wDQYJKoZIhvcNAQELBQADggEBAKrHEuB6
|
||||
33j8+EwSHw3zrvt/DRXK2BDHI1Ir9JcztSunaKAjZXVvf/dvZp0Xs1dEdJIdnv6G
|
||||
iZYhBbOqDqpQZbf2h/h0kuu5yZSBUdnQXnYNxlhp2UaC/UEgw5iZT/p1rm7RjVie
|
||||
y4Dp2WytV5iZOLmLj6xDvd3DXazgJPWIRX8p8qJZbKTkwCjTr7nDIj8jjG1sVFf7
|
||||
1RJBO5/6WSnImrpDmlLUrvjiKvbxcdseDJyBOhTwdRdSk4S2M+s5tR5j2I1gXLOq
|
||||
J5ioN76+SCrTY0K0WKRy9oOXWO1/X3+VYcekp+0F3SGkd5w17jylCv1XIGHAdEsQ
|
||||
v2z2/aMI/7sAD2Q=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
|
||||
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
|
||||
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
|
||||
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
|
||||
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
|
||||
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
|
||||
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
|
||||
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
|
||||
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
|
||||
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
|
||||
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
|
||||
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
|
||||
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
|
||||
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
|
||||
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
|
||||
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
|
||||
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
|
||||
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
|
||||
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
|
||||
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
|
||||
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
|
||||
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
|
||||
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
|
||||
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
|
||||
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
|
||||
qYgXLjun9dht6kx9vGJ69wI=
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrzGSScSgaQuZd
|
||||
ELGFYiLiYRwrLKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI3
|
||||
9WmH95aX3d+AU7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ou
|
||||
uN6AOdzWs8hpRYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFf
|
||||
t1ZXxIZOCjDsrEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71Ifi
|
||||
eLCEzMTP1VXatP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZr
|
||||
QUGnL2RtAgMBAAECggEAJaFQii8U/KOYt9vXNoMnZvSkaeSQLLhn2V6Kciu1CtWE
|
||||
aMTWLsFE6nk+G5xXkYcTmM3T0GghtH3u5CjyI6EcsEkeEorCZJt0wbmayDmqiekR
|
||||
LfMzOdHuTHX5+edPgMGYYG1BFyRKyYFsjH1b5zRFZhXdGQnrl5760GsVlz9D1KZQ
|
||||
iHcT+q1S2tmZeoUukQnADENKXUMCyTGM5FCddgNtsWnGDsTDayh7hUdvDkB+mW4G
|
||||
lSp+BZuc3PCwpbD6qkXvfugWs6CUAAtXoV3ceWgxQ+TEnNlwxaG1AyugfgNUBolk
|
||||
8xgeZt4r5QId03jsHDf7hpBAofcaCd5EMIIQYFvWoQKBgQDlbAvAzEFPTZZn2nRV
|
||||
Xagw4xjqVc1LLEKLCWq0N5rEkwn0h90Dz5N7/3NuonP/sIDsDHCbyiOYBI1Ck6Xi
|
||||
0WuB+OyKDh+xeF2mekN9G9ywPahdK5lT/TVsxXFyZlwtVv1x/6KBO4yv5URizxqU
|
||||
gyAPDDxfD/KcNjkOBaodWEwQGQKBgQC/s2gPDBtQkjLwkHXchBomLww5eLlVrac1
|
||||
WK4UX6uSdOgrjJ375OOgMTxe8NVZdOuAKytGXRWDwgH3nVWvuZhe7dGlX3JMuSer
|
||||
e9VwDpBESrvqcR4ruL6wm8wej6BXyjH0wD3FHb0S5HfuBDxTn+4bDwrbRzOUMNgy
|
||||
lSppuflxdQKBgQDiZcIfazFT8evn5nMAvuC4BZNTxIJHmZC9JfjPiUPIkpWzYtOe
|
||||
7BvNtKOT3Op9uw8uYYRKqKqBXJSNy6ha8XCXHS9HeXKbLn20SFkLQBCDNwVLlDfF
|
||||
40zyXtF6JDr4XyzSb4NM5pgKCER5AYloXxGm59s3sEQpFXUuOjbKqJS/GQKBgAoI
|
||||
c7vF4HAZFr1sch62cz/oWnVvkhOf4Q5zs7ixQSOLJtOQqnwSgK9TpFs7s47ZBbJR
|
||||
kBRAru2Ua9Hv1Bo8VnMxczV6h1roneDlvEf/GyHX33nnrbKQGrrXjJlU3wl5NaAf
|
||||
p5v3cHvapUQ5yIZ/6lBUOzc6xMJOxCHxmKSr7Rg5AoGAbEE4lt6Xh2dnBPJ81eNI
|
||||
IDrw/3ITY53qAY4Bx88CByIFuu8CEUdUZprh98jSl6ic1tMinZfUhRMwABLrUD51
|
||||
DGst8iGLPD9u83iMcUHI/L+p7AbxrKLvWXZrF5UZm440c9mSWqfhPaTBosPtNDsG
|
||||
LfETwH1flKXMTXd2xA9RTE4=
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -34,6 +34,5 @@ subjectAltName = @alt_names
|
||||
DNS.1 = localhost
|
||||
DNS.2 = m.localhost
|
||||
DNS.3 = *.m.localhost
|
||||
DNS.4 = *.othersite.m.localhost
|
||||
EOF
|
||||
)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
@@ -19,18 +19,8 @@ database:
|
||||
|
||||
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.othersite.m.localhost"
|
||||
accept_keys_insecurely: true
|
||||
- server_name: "matrix.org"
|
||||
|
||||
experimental_features:
|
||||
# MSC3266: Room summary API. Used for knocking over federation
|
||||
@@ -44,13 +34,6 @@ experimental_features:
|
||||
# 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:
|
||||
@@ -61,10 +44,6 @@ rc_message:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
rc_delayed_event_mgmt:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
@@ -79,3 +58,10 @@ rc_login:
|
||||
rc_registration:
|
||||
per_second: 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
|
||||
|
||||
5
backend/redis.conf
Normal file
5
backend/redis.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
bind 0.0.0.0
|
||||
protected-mode yes
|
||||
port 6379
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
@@ -24,28 +24,6 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
image: livekit/livekit-server:latest
|
||||
pull_policy: always
|
||||
@@ -66,23 +44,15 @@ services:
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:latest
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
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…)
|
||||
command: redis-server /etc/redis.conf
|
||||
ports:
|
||||
# HOST_PORT:CONTAINER_PORT
|
||||
- 17880:17880/tcp
|
||||
- 17881:17881/tcp
|
||||
- 17882:17882/tcp
|
||||
- 50300-50400:50300-50400/udp
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
|
||||
- ./backend/redis.conf:/etc/redis.conf:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -104,24 +74,6 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
image: ghcr.io/element-hq/element-web:develop
|
||||
pull_policy: always
|
||||
@@ -134,19 +86,6 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
# see backend/dev_tls_setup for how to generate the tls certs
|
||||
hostname: synapse.m.localhost
|
||||
@@ -169,7 +108,4 @@ services:
|
||||
networks:
|
||||
ecbackend:
|
||||
aliases:
|
||||
- synapse.m.localhost
|
||||
- synapse.othersite.m.localhost
|
||||
- matrix-rtc.m.localhost
|
||||
- matrix-rtc.othersite.m.localhost
|
||||
|
||||
@@ -283,7 +283,6 @@ self-hosters and developers working with Element Call.
|
||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||
- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/)
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
|
||||
@@ -72,24 +72,12 @@
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
|
||||
"label": "Compatibility: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Compatible with old versions of EC that do not support multi SFU",
|
||||
"label": "Legacy: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
},
|
||||
"title": "MatrixRTC mode"
|
||||
},
|
||||
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
|
||||
"show_connection_stats": "Show connection statistics",
|
||||
"url_params": "URL parameters"
|
||||
"show_non_member_tiles": "Show tiles for non-member media",
|
||||
"url_params": "URL parameters",
|
||||
"use_new_membership_manager": "Use the new implementation of the call MembershipManager",
|
||||
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"error": {
|
||||
@@ -104,7 +92,7 @@
|
||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
||||
"insufficient_capacity": "Insufficient capacity",
|
||||
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"open_elsewhere": "Opened in another tab",
|
||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
||||
"room_creation_restricted": "Failed to create call",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.42.2",
|
||||
"@livekit/protocol": "^1.38.0",
|
||||
"@livekit/track-processors": "^0.5.5",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
@@ -99,7 +99,6 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-rxjs": "^5.0.3",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"fetch-mock": "11.1.5",
|
||||
"global-jsdom": "^26.0.0",
|
||||
"i18next": "^24.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
@@ -109,7 +108,7 @@
|
||||
"livekit-client": "^2.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
|
||||
"matrix-widget-api": "^1.13.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
|
||||
@@ -2,6 +2,3 @@ services:
|
||||
synapse:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
|
||||
@@ -159,8 +159,8 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
} = await registerUser(browser, userB);
|
||||
|
||||
// Invite the second user
|
||||
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
|
||||
await ewPage1.getByRole("button", { name: "Add room" }).click();
|
||||
await ewPage1.getByText("New room").click();
|
||||
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
|
||||
await ewPage1.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
|
||||
@@ -184,9 +184,9 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
// Accept the invite
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
ewPage2.getByRole("treeitem", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),
|
||||
|
||||
@@ -1,60 +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 { 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();
|
||||
});
|
||||
@@ -49,10 +49,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
|
||||
// Check the join indicator on the room list
|
||||
await expect(
|
||||
brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByRole("button", { name: "End call" }),
|
||||
brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// Join from the other side
|
||||
@@ -62,28 +59,26 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
|
||||
// Currently disabled due to recent Element Web is bypassing Lobby
|
||||
// await expect(
|
||||
// whistler.page
|
||||
// .locator('iframe[title="Element Call"]')
|
||||
// .contentFrame()
|
||||
// .getByTestId("lobby_joinCall"),
|
||||
// ).toBeVisible();
|
||||
//
|
||||
// await whistler.page
|
||||
// .locator('iframe[title="Element Call"]')
|
||||
// .contentFrame()
|
||||
// .getByTestId("lobby_joinCall")
|
||||
// .click();
|
||||
await expect(
|
||||
whistler.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall"),
|
||||
).toBeVisible();
|
||||
|
||||
// Currrenty disabled due to recent Element Web not indicating the number of participants
|
||||
// await expect(
|
||||
// whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
|
||||
// ).toBeVisible();
|
||||
await whistler.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall")
|
||||
.click();
|
||||
|
||||
// await expect(
|
||||
// brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
|
||||
// ).toBeVisible();
|
||||
await expect(
|
||||
whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// Whistler leaves
|
||||
await whistler.page.waitForTimeout(1000);
|
||||
|
||||
@@ -23,6 +23,14 @@ export function useMediaDevices(): MediaDevices {
|
||||
return mediaDevices;
|
||||
}
|
||||
|
||||
export const useIsEarpiece = (): boolean => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const available = useObservableEagerState(devices.audioOutput.available$);
|
||||
if (!audioOutput?.id) return false;
|
||||
return available.get(audioOutput.id)?.type === "earpiece";
|
||||
};
|
||||
|
||||
/**
|
||||
* A convenience hook to get the audio node configuration for the earpiece.
|
||||
* It will check the `useAsEarpiece` of the `audioOutput` device and return
|
||||
|
||||
@@ -35,8 +35,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.bg.animate[data-state="closed"] {
|
||||
animation: fade-out 130ms;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
|
||||
@@ -19,26 +19,10 @@ import mediaViewStyles from "../src/tile/MediaView.module.css";
|
||||
interface Props {
|
||||
audio?: 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
|
||||
export const RTCConnectionStats: FC<Props> = ({
|
||||
audio,
|
||||
video,
|
||||
focusUrl,
|
||||
...rest
|
||||
}) => {
|
||||
export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalContents, setModalContents] = useState<
|
||||
"video" | "audio" | "none"
|
||||
@@ -71,13 +55,6 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
{focusUrl && (
|
||||
<div>
|
||||
<Text as="span" size="xs" title="focusURL">
|
||||
{extractDomain(focusUrl)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{audio && (
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -46,11 +46,11 @@ interface Props {
|
||||
*/
|
||||
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||
/**
|
||||
* Whether the toast should be modal, making it fill the screen (by portalling
|
||||
* it into the root of the document) and trap focus until dismissed.
|
||||
* Whether the toast should be portaled into the root of the document (rather
|
||||
* than rendered in-place within the component tree).
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,7 @@ export const Toast: FC<Props> = ({
|
||||
autoDismiss,
|
||||
children,
|
||||
Icon,
|
||||
modal = true,
|
||||
portal = true,
|
||||
}) => {
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -103,8 +103,8 @@ export const Toast: FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||
{modal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,15 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as PlatformMod from "../src/Platform";
|
||||
import {
|
||||
getRoomIdentifierFromUrl,
|
||||
computeUrlParams,
|
||||
HeaderStyle,
|
||||
getUrlParams,
|
||||
HeaderStyle,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
@@ -106,16 +103,16 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("preload", () => {
|
||||
it("defaults to false", () => {
|
||||
expect(computeUrlParams().preload).toBe(false);
|
||||
expect(getUrlParams().preload).toBe(false);
|
||||
});
|
||||
|
||||
it("ignored in SPA mode", () => {
|
||||
expect(computeUrlParams("?preload=true").preload).toBe(false);
|
||||
expect(getUrlParams("?preload=true").preload).toBe(false);
|
||||
});
|
||||
|
||||
it("respected in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).preload,
|
||||
).toBe(true);
|
||||
@@ -124,20 +121,19 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("returnToLobby", () => {
|
||||
it("is false in SPA mode", () => {
|
||||
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
|
||||
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to false in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
"?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).returnToLobby,
|
||||
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo")
|
||||
.returnToLobby,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("respected in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).returnToLobby,
|
||||
).toBe(true);
|
||||
@@ -146,12 +142,12 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("userId", () => {
|
||||
it("is ignored in SPA mode", () => {
|
||||
expect(computeUrlParams("?userId=asd").userId).toBe(null);
|
||||
expect(getUrlParams("?userId=asd").userId).toBe(null);
|
||||
});
|
||||
|
||||
it("is parsed in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).userId,
|
||||
).toBe("asd");
|
||||
@@ -160,12 +156,12 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("deviceId", () => {
|
||||
it("is ignored in SPA mode", () => {
|
||||
expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
|
||||
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null);
|
||||
});
|
||||
|
||||
it("is parsed in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).deviceId,
|
||||
).toBe("asd");
|
||||
@@ -174,12 +170,12 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("baseUrl", () => {
|
||||
it("is ignored in SPA mode", () => {
|
||||
expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
|
||||
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null);
|
||||
});
|
||||
|
||||
it("is parsed in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).baseUrl,
|
||||
).toBe("asd");
|
||||
@@ -189,28 +185,28 @@ describe("UrlParams", () => {
|
||||
describe("viaServers", () => {
|
||||
it("is ignored in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).viaServers,
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("is parsed in SPA mode", () => {
|
||||
expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
|
||||
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("homeserver", () => {
|
||||
it("is ignored in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).homeserver,
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("is parsed in SPA mode", () => {
|
||||
expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
|
||||
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +237,7 @@ describe("UrlParams", () => {
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: platform === "desktop" ? "notification" : "ring",
|
||||
sendNotificationType: "notification",
|
||||
});
|
||||
const joinExistingCallDefaults = (platform: string): object => ({
|
||||
confineToRoom: true,
|
||||
@@ -256,55 +252,24 @@ describe("UrlParams", () => {
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
defaultAudioEnabled: true,
|
||||
defaultVideoEnabled: true,
|
||||
});
|
||||
it("use no-intent-defaults with unknown intent", () => {
|
||||
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
|
||||
expect(getUrlParams()).toMatchObject(noIntentDefaults);
|
||||
});
|
||||
|
||||
it("ignores intent if it is not a valid value", () => {
|
||||
expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
|
||||
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
|
||||
});
|
||||
|
||||
it("accepts start_call", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
"?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",
|
||||
});
|
||||
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
|
||||
).toMatchObject(startNewCallDefaults("desktop"));
|
||||
});
|
||||
|
||||
it("accepts join_existing", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
|
||||
),
|
||||
).toMatchObject(joinExistingCallDefaults("desktop"));
|
||||
@@ -313,55 +278,31 @@ describe("UrlParams", () => {
|
||||
|
||||
describe("skipLobby", () => {
|
||||
it("defaults to false", () => {
|
||||
expect(computeUrlParams().skipLobby).toBe(false);
|
||||
expect(getUrlParams().skipLobby).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to false if intent is start_call in SPA mode", () => {
|
||||
expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
|
||||
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to false if intent is start_call in widget mode", () => {
|
||||
it("defaults to true if intent is start_call in widget mode", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
getUrlParams(
|
||||
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
|
||||
).skipLobby,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("default to false if intent is join_existing", () => {
|
||||
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("header", () => {
|
||||
it("uses header if provided", () => {
|
||||
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||
"app_bar",
|
||||
);
|
||||
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:",
|
||||
{},
|
||||
);
|
||||
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
132
src/UrlParams.ts
132
src/UrlParams.ts
@@ -8,10 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { pickBy } from "lodash-es";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
@@ -29,9 +26,7 @@ export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
@@ -232,16 +227,6 @@ export interface UrlConfiguration {
|
||||
* - 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
|
||||
@@ -249,10 +234,7 @@ interface IntentAndPlatformDerivedConfiguration {
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
export interface UrlParams
|
||||
extends UrlProperties,
|
||||
UrlConfiguration,
|
||||
IntentAndPlatformDerivedConfiguration {}
|
||||
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||
|
||||
// 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
|
||||
@@ -328,14 +310,8 @@ class ParamParser {
|
||||
}
|
||||
}
|
||||
|
||||
let urlParamCache: {
|
||||
search?: string;
|
||||
hash?: string;
|
||||
params?: UrlParams;
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Gets the url params and loads them from a cache if already computed.
|
||||
* 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
|
||||
@@ -344,26 +320,6 @@ export const getUrlParams = (
|
||||
search = window.location.search,
|
||||
hash = window.location.hash,
|
||||
): 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 fontScale = parseFloat(parser.getParam("fontScale") ?? "");
|
||||
@@ -387,7 +343,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
? UserIntent.Unknown
|
||||
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
||||
// Here we only use constants and `platform` to determine the intent preset.
|
||||
let intentPreset: UrlConfiguration = {
|
||||
let intentPreset: UrlConfiguration;
|
||||
const inAppDefault = {
|
||||
confineToRoom: true,
|
||||
appPrompt: false,
|
||||
preload: false,
|
||||
@@ -399,38 +356,37 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
sendNotificationType: "notification" as RTCNotificationType,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
intentPreset = {
|
||||
...inAppDefault,
|
||||
skipLobby: true,
|
||||
};
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
intentPreset = {
|
||||
...inAppDefault,
|
||||
skipLobby: false,
|
||||
};
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.sendNotificationType = "ring";
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.waitForCallPickup = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
intentPreset = {
|
||||
...inAppDefault,
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
};
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
intentPreset = {
|
||||
...inAppDefault,
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
};
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
@@ -452,29 +408,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
};
|
||||
}
|
||||
|
||||
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
|
||||
{};
|
||||
// Desktop also includes web. Its anything that is not mobile.
|
||||
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
|
||||
switch (desktopMobile) {
|
||||
case "desktop":
|
||||
case "mobile":
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
case UserIntent.JoinExistingCall:
|
||||
case UserIntent.StartNewCallDM:
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
|
||||
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
|
||||
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
parentUrl,
|
||||
@@ -527,25 +460,10 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
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 {
|
||||
...properties,
|
||||
...intentPreset,
|
||||
...pickBy(configuration, (v?: unknown) => v !== undefined),
|
||||
...intentAndPlatformDerivedConfiguration,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,22 +6,21 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { type ReactNode } from "react";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ReactionToggleButton } from "./ReactionToggleButton";
|
||||
import { ElementCallReactionEventType } from "../reactions";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local, localRtcMember } from "../utils/test-fixtures";
|
||||
import { type MockRTCSession } from "../utils/test";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
|
||||
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
@@ -34,7 +33,7 @@ function TestComponent({
|
||||
<TooltipProvider>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
||||
</ReactionsSenderProvider>
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
ReactionsRowSize,
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«rbb»"
|
||||
aria-labelledby="«rb5»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
@@ -113,49 +113,19 @@ const roomIsJoinable = (room: Room): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a given room has call events in it, and therefore
|
||||
* is likely to be a call room.
|
||||
* @param room The Matrix room instance.
|
||||
* @returns `true` if the room has call events.
|
||||
*/
|
||||
const roomHasCallMembershipEvents = (room: Room): boolean => {
|
||||
// Check our room membership first, to rule out any rooms
|
||||
// we can't have a call in.
|
||||
const myMembership = room.getMyMembership();
|
||||
if (myMembership === KnownMembership.Knock) {
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
} else if (myMembership !== KnownMembership.Join) {
|
||||
// Otherwise, non-joined rooms should never show up.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy member state checks (cheaper to check.)
|
||||
const timeline = room.getLiveTimeline();
|
||||
if (
|
||||
timeline
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.has(EventType.GroupCallMemberPrefix)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for *active* calls using sticky events.
|
||||
for (const sticky of room._unstable_getStickyEvents()) {
|
||||
if (sticky.getType() === EventType.RTCMembership) {
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
return !!room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.get(EventType.GroupCallMemberPrefix);
|
||||
case KnownMembership.Knock:
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise, check recent event history to see if anyone had
|
||||
// sent a call membership in here.
|
||||
return timeline.getEvents().some(
|
||||
(e) =>
|
||||
// Membership events only count if both of these are true
|
||||
e.unstableStickyInfo && e.getType() === EventType.GroupCallMemberPrefix,
|
||||
);
|
||||
// Otherwise, it's *unlikely* this room was ever a call.
|
||||
};
|
||||
|
||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
|
||||
@@ -6,28 +6,20 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
getTrackReferenceId,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
type Participant,
|
||||
type RemoteAudioTrack,
|
||||
type Room,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { type RemoteAudioTrack } from "livekit-client";
|
||||
import { type ReactNode } from "react";
|
||||
import { useTracks } from "@livekit/components-react";
|
||||
|
||||
import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import { mockMediaDevices, mockTrack } from "../utils/test";
|
||||
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
@@ -56,203 +48,42 @@ vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
let tracks: TrackReference[] = [];
|
||||
|
||||
/**
|
||||
* Render the test component with given rtc members and livekit participant identities.
|
||||
*
|
||||
* It is possible to have rtc members that are not in livekit (e.g. not yet joined) and vice versa.
|
||||
*
|
||||
* @param rtcMembers - Array of active rtc members with userId and deviceId.
|
||||
* @param livekitParticipantIdentities - Array of livekit participant (that are publishing).
|
||||
* @param explicitTracks - Array of tracks available in livekit, if not provided, one audio track per livekitParticipantIdentities will be created.
|
||||
* */
|
||||
|
||||
function renderTestComponent(
|
||||
rtcMembers: { userId: string; deviceId: string }[],
|
||||
livekitParticipantIdentities: string[],
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[],
|
||||
): RenderResult {
|
||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||
mockRemoteParticipant({ identity }),
|
||||
);
|
||||
const participants = rtcMembers.flatMap(({ userId, deviceId }) => {
|
||||
const p = liveKitParticipants.find(
|
||||
(p) => p.identity === `${userId}:${deviceId}`,
|
||||
);
|
||||
return p === undefined ? [] : [p];
|
||||
});
|
||||
const livekitRoom = {
|
||||
remoteParticipants: new Map<string, Participant>(
|
||||
liveKitParticipants.map((p) => [p.identity, p]),
|
||||
),
|
||||
} as unknown as Room;
|
||||
|
||||
if (explicitTracks?.length ?? 0 > 0) {
|
||||
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
|
||||
const participant =
|
||||
liveKitParticipants.find((p) => p.identity === participantId) ??
|
||||
mockRemoteParticipant({ identity: participantId });
|
||||
return mockTrack(participant, kind, source);
|
||||
});
|
||||
} else {
|
||||
tracks = participants.map((p) => mockTrack(p));
|
||||
}
|
||||
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
return render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={participants.map((p) => p.identity)}
|
||||
livekitRoom={livekitRoom}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
}
|
||||
const tracks = [mockTrack("test:123")];
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
|
||||
it("should render for member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
const memberships = [
|
||||
{ sender: "othermember", deviceId: "123" },
|
||||
] as CallMembership[];
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer members={memberships} />
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
});
|
||||
|
||||
const TEST_CASES: {
|
||||
name: string;
|
||||
rtcUsers: { userId: string; deviceId: string }[];
|
||||
livekitParticipantIdentities: string[];
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[];
|
||||
expectedAudioTracks: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "single user single device",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@alice", deviceId: "DEV1" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@alice:DEV1"],
|
||||
expectedAudioTracks: 3,
|
||||
},
|
||||
// Charlie is a rtc member but not in livekit
|
||||
{
|
||||
name: "Charlie is rtc member but not in livekit",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
{ userId: "@charlie", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
// Charlie is in livekit but not rtc member
|
||||
{
|
||||
name: "Charlie is in livekit but not rtc member",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@charlie:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
{
|
||||
name: "no audio track, only video track",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Video,
|
||||
source: Track.Source.Camera,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "Audio track from unknown source",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Unknown,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 1,
|
||||
},
|
||||
{
|
||||
name: "Audio track from other device",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV1",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "two audio tracks, microphone and screenshare",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(TEST_CASES)(
|
||||
`should render sound test cases $name`,
|
||||
({
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
expectedAudioTracks,
|
||||
}) => {
|
||||
const { queryAllByTestId } = renderTestComponent(
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(expectedAudioTracks);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
|
||||
@@ -269,8 +100,13 @@ it("should setup audioContext gain and pan", () => {
|
||||
pan: 1,
|
||||
volume: 0.1,
|
||||
});
|
||||
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalled();
|
||||
|
||||
@@ -6,16 +6,15 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
@@ -23,16 +22,11 @@ import * as controls from "../controls";
|
||||
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The service URL of the LiveKit room.
|
||||
*/
|
||||
url: string;
|
||||
livekitRoom: LivekitRoom;
|
||||
/**
|
||||
* The list of participant identities to render audio for.
|
||||
* The list of participants to render audio for.
|
||||
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
|
||||
* that are not expected to be in the rtc session (local user is excluded).
|
||||
* that are not expected to be in the rtc session.
|
||||
*/
|
||||
validIdentities: ParticipantId[];
|
||||
members: CallMembership[];
|
||||
/**
|
||||
* If set to `true`, mutes all audio tracks rendered by the component.
|
||||
* @remarks
|
||||
@@ -41,9 +35,9 @@ export interface MatrixAudioRendererProps {
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
/**
|
||||
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
|
||||
* It takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
* It also takes care of the earpiece audio configuration for iOS devices.
|
||||
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
|
||||
@@ -55,12 +49,35 @@ const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function LivekitRoomAudioRenderer({
|
||||
url,
|
||||
livekitRoom,
|
||||
validIdentities,
|
||||
export function MatrixAudioRenderer({
|
||||
members,
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const validIdentities = useMemo(
|
||||
() =>
|
||||
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)),
|
||||
[members],
|
||||
);
|
||||
|
||||
const loggedInvalidIdentities = useRef(new Set<string>());
|
||||
|
||||
/**
|
||||
* Log an invalid livekit track identity.
|
||||
* A invalid identity is one that does not match any of the matrix rtc members.
|
||||
*
|
||||
* @param identity The identity of the track that is invalid
|
||||
* @param validIdentities The list of valid identities
|
||||
*/
|
||||
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
|
||||
if (loggedInvalidIdentities.current.has(identity)) return;
|
||||
logger.warn(
|
||||
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
|
||||
`current members: ${Array.from(validIdentities.values())}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
loggedInvalidIdentities.current.add(identity);
|
||||
};
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -70,25 +87,25 @@ export function LivekitRoomAudioRenderer({
|
||||
{
|
||||
updateOnlyOn: [],
|
||||
onlySubscribed: true,
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
prefixedLogger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
).filter((ref) => {
|
||||
const isValid = validIdentities?.has(ref.participant.identity);
|
||||
if (!isValid && !ref.participant.isLocal)
|
||||
logInvalid(ref.participant.identity, validIdentities);
|
||||
return (
|
||||
!ref.participant.isLocal &&
|
||||
ref.publication.kind === Track.Kind.Audio &&
|
||||
isValid
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
}, [tracks, validIdentities]);
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
// responsible for mimicking earpiece audio on iPhones.
|
||||
|
||||
@@ -19,22 +19,14 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
//TODO-MULTI-SFU: This is not yet fully there.
|
||||
// it is a combination of exposing observable and react hooks.
|
||||
// preferably we should not make this a context anymore and instead just a vm?
|
||||
|
||||
export type ProcessorState = {
|
||||
type ProcessorState = {
|
||||
supported: boolean | undefined;
|
||||
processor: undefined | ProcessorWrapper<BackgroundOptions>;
|
||||
};
|
||||
@@ -50,43 +42,6 @@ export function useTrackProcessor(): ProcessorState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
const state = use(ProcessorContext);
|
||||
if (state === undefined)
|
||||
throw new Error(
|
||||
"useTrackProcessor must be used within a ProcessorProvider",
|
||||
);
|
||||
const state$ = useObservable(
|
||||
(init$) => init$.pipe(map(([init]) => init)),
|
||||
[state],
|
||||
);
|
||||
|
||||
return state$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates your video tracks to always use the given processor.
|
||||
*/
|
||||
export const trackProcessorSync = (
|
||||
scope: ObservableScope,
|
||||
videoTrack$: Behavior<LocalVideoTrack | null>,
|
||||
processor$: Behavior<ProcessorState>,
|
||||
): void => {
|
||||
combineLatest([videoTrack$, processor$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([videoTrack, processorState]) => {
|
||||
if (!processorState) return;
|
||||
if (!videoTrack) return;
|
||||
const { processor } = processorState;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useTrackProcessorSync = (
|
||||
videoTrack: LocalVideoTrack | null,
|
||||
): void => {
|
||||
|
||||
@@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||
import { useErrorBoundary } from "../useErrorBoundary";
|
||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||
|
||||
@@ -16,17 +21,51 @@ export interface SFUConfig {
|
||||
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
|
||||
export type OpenIDClientParts = Pick<
|
||||
MatrixClient,
|
||||
"getOpenIdToken" | "getDeviceId"
|
||||
>;
|
||||
|
||||
export function useOpenIDSFU(
|
||||
client: OpenIDClientParts,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): SFUConfig | undefined {
|
||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||
|
||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||
const { showErrorBoundary } = useErrorBoundary();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFocus) {
|
||||
getSFUConfigWithOpenID(client, activeFocus).then(
|
||||
(sfuConfig) => {
|
||||
setSFUConfig(sfuConfig);
|
||||
},
|
||||
(e) => {
|
||||
showErrorBoundary(new FailToGetOpenIdToken(e));
|
||||
logger.error("Failed to get SFU config", e);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setSFUConfig(undefined);
|
||||
}
|
||||
}, [client, activeFocus, showErrorBoundary]);
|
||||
|
||||
return sfuConfig;
|
||||
}
|
||||
|
||||
export async function getSFUConfigWithOpenID(
|
||||
client: OpenIDClientParts,
|
||||
serviceUrl: string,
|
||||
matrixRoomId: string,
|
||||
): Promise<SFUConfig> {
|
||||
activeFocus: LivekitFocus,
|
||||
): Promise<SFUConfig | undefined> {
|
||||
let openIdToken: IOpenIDToken;
|
||||
try {
|
||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||
@@ -39,16 +78,26 @@ export async function getSFUConfigWithOpenID(
|
||||
}
|
||||
logger.debug("Got openID token", openIdToken);
|
||||
|
||||
logger.info(`Trying to get JWT for focus ${serviceUrl}...`);
|
||||
const sfuConfig = await getLiveKitJWT(
|
||||
client,
|
||||
serviceUrl,
|
||||
matrixRoomId,
|
||||
openIdToken,
|
||||
);
|
||||
logger.info(`Got JWT from call's active focus URL.`);
|
||||
try {
|
||||
logger.info(
|
||||
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`,
|
||||
);
|
||||
const sfuConfig = await getLiveKitJWT(
|
||||
client,
|
||||
activeFocus.livekit_service_url,
|
||||
activeFocus.livekit_alias,
|
||||
openIdToken,
|
||||
);
|
||||
logger.info(`Got JWT from call's active focus URL.`);
|
||||
|
||||
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(
|
||||
|
||||
184
src/livekit/useECConnectionState.test.tsx
Normal file
184
src/livekit/useECConnectionState.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { describe, expect, test, vi, vitest } from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type Room,
|
||||
} from "livekit-client";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { useECConnectionState } from "./useECConnectionState";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
|
||||
|
||||
test.each<[string, ConnectionError]>([
|
||||
[
|
||||
"LiveKit hits track limit",
|
||||
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
|
||||
],
|
||||
[
|
||||
"LiveKit hits room participant limit",
|
||||
new ConnectionError("", ConnectionErrorReason.ServerUnreachable, 200),
|
||||
],
|
||||
[
|
||||
"LiveKit Cloud hits connection limit",
|
||||
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
|
||||
],
|
||||
])(
|
||||
"useECConnectionState throws error when %s hits track limit",
|
||||
async (_server, error) => {
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: () => {
|
||||
throw error;
|
||||
},
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: () => [],
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent: FC = () => {
|
||||
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const connect = useCallback(
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
screen.getByText("Insufficient capacity");
|
||||
},
|
||||
);
|
||||
|
||||
describe("Leaking connection prevention", () => {
|
||||
function createTestComponent(mockRoom: Room): FC {
|
||||
const TestComponent: FC = () => {
|
||||
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const connect = useCallback(
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
return TestComponent;
|
||||
}
|
||||
|
||||
test("Should cancel pending connections when the component is unmounted", async () => {
|
||||
const connectCall = vi.fn();
|
||||
const pendingConnection = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: async () => {
|
||||
connectCall.call(undefined);
|
||||
return await pendingConnection.promise;
|
||||
},
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: () => [],
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(connectCall).toHaveBeenCalled();
|
||||
// unmount while the connection is pending
|
||||
unmount();
|
||||
|
||||
// resolve the pending connection
|
||||
pendingConnection.resolve();
|
||||
|
||||
await vitest.waitUntil(
|
||||
() => {
|
||||
return disconnectMock.mock.calls.length > 0;
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
interval: 100,
|
||||
},
|
||||
);
|
||||
|
||||
// There should be some cleaning up to avoid leaking an open connection
|
||||
expect(disconnectMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Should cancel about to open but not yet opened connection", async () => {
|
||||
const createTracksCall = vi.fn();
|
||||
const pendingCreateTrack = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: connectMock,
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: async () => {
|
||||
createTracksCall.call(undefined);
|
||||
await pendingCreateTrack.promise;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(createTracksCall).toHaveBeenCalled();
|
||||
// unmount while createTracks is pending
|
||||
unmount();
|
||||
|
||||
// resolve createTracks
|
||||
pendingCreateTrack.resolve();
|
||||
|
||||
// Yield to the event loop to let the connection attempt finish
|
||||
await sleep(100);
|
||||
|
||||
// The operation should have been aborted before even calling connect.
|
||||
expect(connectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
362
src/livekit/useECConnectionState.ts
Normal file
362
src/livekit/useECConnectionState.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionState,
|
||||
type LocalTrack,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { AbortHandle } from "../utils/abortHandle.ts";
|
||||
|
||||
/*
|
||||
* Additional values for states that a call can be in, beyond what livekit
|
||||
* provides in ConnectionState. Also reconnects the call if the SFU Config
|
||||
* changes.
|
||||
*/
|
||||
export enum ECAddonConnectionState {
|
||||
// We are switching from one focus to another (or between livekit room aliases on the same focus)
|
||||
ECSwitchingFocus = "ec_switching_focus",
|
||||
// The call has just been initialised and is waiting for credentials to arrive before attempting
|
||||
// to connect. This distinguishes from the 'Disconnected' state which is now just for when livekit
|
||||
// gives up on connectivity and we consider the call to have failed.
|
||||
ECWaiting = "ec_waiting",
|
||||
}
|
||||
|
||||
export type ECConnectionState = ConnectionState | ECAddonConnectionState;
|
||||
|
||||
// This is mostly necessary because an empty useRef is an empty object
|
||||
// which is truthy, so we can't just use Boolean(currentSFUConfig.current)
|
||||
function sfuConfigValid(sfuConfig?: SFUConfig): boolean {
|
||||
return Boolean(sfuConfig?.url) && Boolean(sfuConfig?.jwt);
|
||||
}
|
||||
|
||||
async function doConnect(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
audioEnabled: boolean,
|
||||
initialDeviceId: string | undefined,
|
||||
abortHandle: AbortHandle,
|
||||
): Promise<void> {
|
||||
// Always create an audio track manually.
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
if (
|
||||
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
) {
|
||||
logger.warn(
|
||||
"Pre-creating audio track but participant already appears to have an microphone track: this shouldn't happen!",
|
||||
);
|
||||
Sentry.captureMessage(
|
||||
"Pre-creating audio track but participant already appears to have an microphone track!",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Pre-creating microphone track");
|
||||
let preCreatedAudioTrack: LocalTrack | undefined;
|
||||
try {
|
||||
const audioTracks = await livekitRoom!.localParticipant.createTracks({
|
||||
audio: { deviceId: initialDeviceId },
|
||||
});
|
||||
|
||||
if (audioTracks.length < 1) {
|
||||
logger.info("Tried to pre-create local audio track but got no tracks");
|
||||
} else {
|
||||
preCreatedAudioTrack = audioTracks[0];
|
||||
}
|
||||
// There was a yield point previously (awaiting for the track to be created) so we need to check
|
||||
// if the operation was cancelled and stop connecting if needed.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Pre-created microphone track");
|
||||
} catch (e) {
|
||||
logger.error("Failed to pre-create microphone track", e);
|
||||
}
|
||||
|
||||
if (!audioEnabled) {
|
||||
await preCreatedAudioTrack?.mute();
|
||||
// There was a yield point. Check if the operation was cancelled and stop connecting.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// check again having awaited for the track to create
|
||||
if (
|
||||
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
) {
|
||||
logger.warn(
|
||||
"Pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[Lifecycle] Connecting & publishing");
|
||||
try {
|
||||
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
|
||||
);
|
||||
livekitRoom?.disconnect().catch((err) => {
|
||||
logger.error("Failed to disconnect from SFU", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
preCreatedAudioTrack?.stop();
|
||||
logger.debug("Stopped precreated audio tracks.");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the SFU and publish specific tracks, if provided.
|
||||
* This is very specific to what we need to do: for instance, we don't
|
||||
* currently have a need to prepublish video tracks. We just prepublish
|
||||
* a mic track at the start of a call and copy any srceenshare tracks over
|
||||
* when switching focus (because we can't re-acquire them without the user
|
||||
* going through the dialog to choose them again).
|
||||
*/
|
||||
async function connectAndPublish(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
micTrack: LocalTrack | undefined,
|
||||
screenshareTracks: MediaStreamTrack[],
|
||||
): Promise<void> {
|
||||
const tracker = PosthogAnalytics.instance.eventCallConnectDuration;
|
||||
// Track call connect duration
|
||||
tracker.cacheConnectStart();
|
||||
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
|
||||
try {
|
||||
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
logger.info(`[Lifecycle] ... connected to livekit room`);
|
||||
} catch (e) {
|
||||
logger.error("[Lifecycle] Failed to connect", e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (e instanceof ConnectionError) {
|
||||
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||
throw new InsufficientCapacityError();
|
||||
}
|
||||
if (e.status === 404) {
|
||||
// error msg is "Could not establish signal connection: requested room does not exist"
|
||||
// The room does not exist. There are two different modes of operation for the SFU:
|
||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
|
||||
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
tracker.track({ log: true });
|
||||
|
||||
if (micTrack) {
|
||||
logger.info(`Publishing precreated mic track`);
|
||||
await livekitRoom.localParticipant.publishTrack(micTrack, {
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Publishing ${screenshareTracks.length} precreated screenshare tracks`,
|
||||
);
|
||||
for (const st of screenshareTracks) {
|
||||
livekitRoom.localParticipant
|
||||
.publishTrack(st, {
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to publish screenshare track", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useECConnectionState(
|
||||
initialDeviceId: string | undefined,
|
||||
initialAudioEnabled: boolean,
|
||||
livekitRoom?: Room,
|
||||
sfuConfig?: SFUConfig,
|
||||
): ECConnectionState {
|
||||
const [connState, setConnState] = useState(
|
||||
sfuConfig && livekitRoom
|
||||
? livekitRoom.state
|
||||
: ECAddonConnectionState.ECWaiting,
|
||||
);
|
||||
|
||||
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
||||
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
||||
const [error, setError] = useState<ElementCallError | null>(null);
|
||||
if (error !== null) throw error;
|
||||
|
||||
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
||||
if (state == ConnectionState.Connected) setSwitchingFocus(false);
|
||||
setConnState(state);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const oldRoom = livekitRoom;
|
||||
|
||||
if (livekitRoom) {
|
||||
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (oldRoom)
|
||||
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
};
|
||||
}, [livekitRoom, onConnStateChanged]);
|
||||
|
||||
const doFocusSwitch = useCallback(async (): Promise<void> => {
|
||||
const screenshareTracks: MediaStreamTrack[] = [];
|
||||
for (const t of livekitRoom!.localParticipant.videoTrackPublications.values()) {
|
||||
if (t.track && t.source == Track.Source.ScreenShare) {
|
||||
const newTrack = t.track.mediaStreamTrack.clone();
|
||||
newTrack.enabled = true;
|
||||
screenshareTracks.push(newTrack);
|
||||
}
|
||||
}
|
||||
|
||||
// Flag that we're currently switching focus. This will get reset when the
|
||||
// connection state changes back to connected in onConnStateChanged above.
|
||||
setSwitchingFocus(true);
|
||||
await livekitRoom?.disconnect();
|
||||
setIsInDoConnect(true);
|
||||
try {
|
||||
await connectAndPublish(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
undefined,
|
||||
screenshareTracks,
|
||||
);
|
||||
} finally {
|
||||
setIsInDoConnect(false);
|
||||
}
|
||||
}, [livekitRoom, sfuConfig]);
|
||||
|
||||
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
|
||||
|
||||
// Protection against potential leaks, where the component to be unmounted and there is
|
||||
// still a pending doConnect promise. This would lead the user to still be in the call even
|
||||
// if the component is unmounted.
|
||||
const abortHandlesBag = useRef(new Set<AbortHandle>());
|
||||
|
||||
// This is a cleanup function that will be called when the component is about to be unmounted.
|
||||
// It will cancel all abortHandles in the bag
|
||||
useEffect(() => {
|
||||
const bag = abortHandlesBag.current;
|
||||
return (): void => {
|
||||
bag.forEach((handle) => {
|
||||
handle.abort();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Id we are transitioning from a valid config to another valid one, we need
|
||||
// to explicitly switch focus
|
||||
useEffect(() => {
|
||||
if (
|
||||
sfuConfigValid(sfuConfig) &&
|
||||
sfuConfigValid(currentSFUConfig.current) &&
|
||||
!sfuConfigEquals(currentSFUConfig.current, sfuConfig)
|
||||
) {
|
||||
logger.info(
|
||||
`SFU config changed! URL was ${currentSFUConfig.current?.url} now ${sfuConfig?.url}`,
|
||||
);
|
||||
|
||||
doFocusSwitch().catch((e) => {
|
||||
logger.error("Failed to switch focus", e);
|
||||
});
|
||||
} else if (
|
||||
!sfuConfigValid(currentSFUConfig.current) &&
|
||||
sfuConfigValid(sfuConfig)
|
||||
) {
|
||||
// if we're transitioning from an invalid config to a valid one (ie. connecting)
|
||||
// then do an initial connection, including publishing the microphone track:
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
setIsInDoConnect(true);
|
||||
const abortHandle = new AbortHandle();
|
||||
abortHandlesBag.current.add(abortHandle);
|
||||
doConnect(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
initialAudioEnabled,
|
||||
initialDeviceId,
|
||||
abortHandle,
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e instanceof ElementCallError) {
|
||||
setError(e); // Bubble up any error screens to React
|
||||
} else if (e instanceof Error) {
|
||||
setError(new UnknownCallError(e));
|
||||
} else logger.error("Failed to connect to SFU", e);
|
||||
})
|
||||
.finally(() => {
|
||||
abortHandlesBag.current.delete(abortHandle);
|
||||
setIsInDoConnect(false);
|
||||
});
|
||||
}
|
||||
|
||||
currentSFUConfig.current = Object.assign({}, sfuConfig);
|
||||
}, [
|
||||
sfuConfig,
|
||||
livekitRoom,
|
||||
initialDeviceId,
|
||||
initialAudioEnabled,
|
||||
doFocusSwitch,
|
||||
]);
|
||||
|
||||
// Because we create audio tracks by hand, there's more to connecting than
|
||||
// just what LiveKit does in room.connect, and we should continue to return
|
||||
// ConnectionState.Connecting for the entire duration of the doConnect promise
|
||||
return isSwitchingFocus
|
||||
? ECAddonConnectionState.ECSwitchingFocus
|
||||
: isInDoConnect
|
||||
? ConnectionState.Connecting
|
||||
: connState;
|
||||
}
|
||||
403
src/livekit/useLivekit.ts
Normal file
403
src/livekit/useLivekit.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -62,7 +61,7 @@ Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App vm={new AppViewModel(globalScope)} />
|
||||
<App vm={new AppViewModel()} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -22,13 +22,11 @@ export function RaisedHandIndicator({
|
||||
miniature,
|
||||
showTimer,
|
||||
onClick,
|
||||
tabIndex,
|
||||
}: {
|
||||
raisedHandTime?: Date;
|
||||
miniature?: boolean;
|
||||
showTimer?: boolean;
|
||||
onClick?: () => void;
|
||||
tabIndex?: number;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const [raisedHandDuration, setRaisedHandDuration] = useState("");
|
||||
@@ -96,7 +94,6 @@ export function RaisedHandIndicator({
|
||||
background: "none",
|
||||
}}
|
||||
onClick={clickCallback}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, test, vitest } from "vitest";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
MatrixEvent,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { getBasicRTCSession } from "../utils/test-viewmodel";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { ElementCallReactionEventType, ReactionSet } from ".";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,8 +38,7 @@ test("handles a hand raised reaction", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("ab", {
|
||||
a: () => {},
|
||||
@@ -48,7 +48,7 @@ test("handles a hand raised reaction", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: localTimestamp.getTime(),
|
||||
content: {
|
||||
@@ -68,7 +68,7 @@ test("handles a hand raised reaction", () => {
|
||||
expectObservable(raisedHands$).toBe("ab", {
|
||||
a: {},
|
||||
b: {
|
||||
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
|
||||
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
|
||||
reactionEventId,
|
||||
membershipEventId: localRtcMember.eventId,
|
||||
time: localTimestamp,
|
||||
@@ -86,8 +86,7 @@ test("handles a redaction", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("abc", {
|
||||
a: () => {},
|
||||
@@ -97,7 +96,7 @@ test("handles a redaction", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: localTimestamp.getTime(),
|
||||
content: {
|
||||
@@ -119,7 +118,7 @@ test("handles a redaction", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.RoomRedaction,
|
||||
redacts: reactionEventId,
|
||||
}),
|
||||
@@ -131,7 +130,7 @@ test("handles a redaction", () => {
|
||||
expectObservable(raisedHands$).toBe("abc", {
|
||||
a: {},
|
||||
b: {
|
||||
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
|
||||
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
|
||||
reactionEventId,
|
||||
membershipEventId: localRtcMember.eventId,
|
||||
time: localTimestamp,
|
||||
@@ -150,8 +149,7 @@ test("handles waiting for event decryption", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("abc", {
|
||||
a: () => {},
|
||||
@@ -159,7 +157,7 @@ test("handles waiting for event decryption", () => {
|
||||
const encryptedEvent = new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: localTimestamp.getTime(),
|
||||
content: {
|
||||
@@ -186,7 +184,7 @@ test("handles waiting for event decryption", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: localTimestamp.getTime(),
|
||||
content: {
|
||||
@@ -202,7 +200,7 @@ test("handles waiting for event decryption", () => {
|
||||
expectObservable(raisedHands$).toBe("a-c", {
|
||||
a: {},
|
||||
c: {
|
||||
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
|
||||
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
|
||||
reactionEventId,
|
||||
membershipEventId: localRtcMember.eventId,
|
||||
time: localTimestamp,
|
||||
@@ -220,8 +218,7 @@ test("hands rejecting events without a proper membership", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("ab", {
|
||||
a: () => {},
|
||||
@@ -231,7 +228,7 @@ test("hands rejecting events without a proper membership", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: localTimestamp.getTime(),
|
||||
content: {
|
||||
@@ -266,8 +263,7 @@ test("handles a reaction", () => {
|
||||
withTestScheduler(({ schedule, time, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule(`abc`, {
|
||||
a: () => {},
|
||||
@@ -277,7 +273,7 @@ test("handles a reaction", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: reaction.emoji,
|
||||
@@ -302,7 +298,7 @@ test("handles a reaction", () => {
|
||||
{
|
||||
a: {},
|
||||
b: {
|
||||
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
|
||||
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
|
||||
reactionOption: reaction,
|
||||
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
|
||||
},
|
||||
@@ -325,8 +321,7 @@ test("ignores bad reaction events", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("ab", {
|
||||
a: () => {},
|
||||
@@ -337,7 +332,7 @@ test("ignores bad reaction events", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {},
|
||||
}),
|
||||
@@ -352,7 +347,7 @@ test("ignores bad reaction events", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: reaction.emoji,
|
||||
@@ -373,7 +368,7 @@ test("ignores bad reaction events", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: aliceRtcMember.userId,
|
||||
sender: aliceRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: reaction.emoji,
|
||||
@@ -394,7 +389,7 @@ test("ignores bad reaction events", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
name: reaction.name,
|
||||
@@ -414,7 +409,7 @@ test("ignores bad reaction events", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: " ",
|
||||
@@ -450,8 +445,7 @@ test("that reactions cannot be spammed", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
);
|
||||
schedule("abcd", {
|
||||
a: () => {},
|
||||
@@ -461,7 +455,7 @@ test("that reactions cannot be spammed", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: reactionA.emoji,
|
||||
@@ -483,7 +477,7 @@ test("that reactions cannot be spammed", () => {
|
||||
new MatrixEvent({
|
||||
room_id: rtcSession.room.roomId,
|
||||
event_id: reactionEventId,
|
||||
sender: localRtcMember.userId,
|
||||
sender: localRtcMember.sender,
|
||||
type: ElementCallReactionEventType,
|
||||
content: {
|
||||
emoji: reactionB.emoji,
|
||||
@@ -508,7 +502,7 @@ test("that reactions cannot be spammed", () => {
|
||||
{
|
||||
a: {},
|
||||
b: {
|
||||
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
|
||||
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
|
||||
reactionOption: reactionA,
|
||||
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
EventType,
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { BehaviorSubject, delay } from "rxjs";
|
||||
import { BehaviorSubject, delay, type Subscription } from "rxjs";
|
||||
|
||||
import {
|
||||
ElementCallReactionEventType,
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type RaisedHandInfo,
|
||||
type ReactionInfo,
|
||||
} from ".";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
export const REACTION_ACTIVE_TIME_MS = 3000;
|
||||
|
||||
@@ -55,13 +54,12 @@ export class ReactionsReader {
|
||||
*/
|
||||
public readonly reactions$ = this.reactionsSubject$.asObservable();
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly rtcSession: MatrixRTCSession,
|
||||
) {
|
||||
private readonly reactionsSub: Subscription;
|
||||
|
||||
public constructor(private readonly rtcSession: MatrixRTCSession) {
|
||||
// Hide reactions after a given time.
|
||||
this.reactionsSubject$
|
||||
.pipe(delay(REACTION_ACTIVE_TIME_MS), this.scope.bind())
|
||||
this.reactionsSub = this.reactionsSubject$
|
||||
.pipe(delay(REACTION_ACTIVE_TIME_MS))
|
||||
.subscribe((reactions) => {
|
||||
const date = new Date();
|
||||
const nextEntries = Object.fromEntries(
|
||||
@@ -73,38 +71,15 @@ export class ReactionsReader {
|
||||
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.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Timeline,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
this.rtcSession.room.on(
|
||||
MatrixRoomEvent.Redaction,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Redaction,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
this.rtcSession.room.client.on(
|
||||
MatrixEventEvent.Decrypted,
|
||||
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
|
||||
// may still be sending.
|
||||
@@ -112,23 +87,11 @@ export class ReactionsReader {
|
||||
MatrixRoomEvent.LocalEchoUpdated,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.LocalEchoUpdated,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
this.rtcSession.on(
|
||||
rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
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.
|
||||
this.onMembershipsChanged([]);
|
||||
@@ -167,7 +130,7 @@ export class ReactionsReader {
|
||||
private onMembershipsChanged = (oldMemberships: CallMembership[]): void => {
|
||||
// Remove any raised hands for users no longer joined to the call.
|
||||
for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter(
|
||||
(rhId) => oldMemberships.find((u) => u.userId == rhId),
|
||||
(rhId) => oldMemberships.find((u) => u.sender == rhId),
|
||||
)) {
|
||||
this.removeRaisedHand(identifier);
|
||||
}
|
||||
@@ -175,10 +138,10 @@ export class ReactionsReader {
|
||||
// For each member in the call, check to see if a reaction has
|
||||
// been raised and adjust.
|
||||
for (const m of this.rtcSession.memberships) {
|
||||
if (!m.userId || !m.eventId) {
|
||||
if (!m.sender || !m.eventId) {
|
||||
continue;
|
||||
}
|
||||
const identifier = `${m.userId}:${m.deviceId}`;
|
||||
const identifier = `${m.sender}:${m.deviceId}`;
|
||||
if (
|
||||
this.raisedHandsSubject$.value[identifier] &&
|
||||
this.raisedHandsSubject$.value[identifier].membershipEventId !==
|
||||
@@ -188,13 +151,13 @@ export class ReactionsReader {
|
||||
// was raised, reset.
|
||||
this.removeRaisedHand(identifier);
|
||||
}
|
||||
const reaction = this.getLastReactionEvent(m.eventId, m.userId);
|
||||
const reaction = this.getLastReactionEvent(m.eventId, m.sender);
|
||||
if (reaction) {
|
||||
const eventId = reaction?.getId();
|
||||
if (!eventId) {
|
||||
continue;
|
||||
}
|
||||
this.addRaisedHand(`${m.userId}:${m.deviceId}`, {
|
||||
this.addRaisedHand(`${m.sender}:${m.deviceId}`, {
|
||||
membershipEventId: m.eventId,
|
||||
reactionEventId: eventId,
|
||||
time: new Date(reaction.localTimestamp),
|
||||
@@ -256,7 +219,7 @@ export class ReactionsReader {
|
||||
|
||||
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
||||
const membershipEvent = this.rtcSession.memberships.find(
|
||||
(e) => e.eventId === membershipEventId && e.userId === sender,
|
||||
(e) => e.eventId === membershipEventId && e.sender === sender,
|
||||
);
|
||||
// Check to see if this reaction was made to a membership event (and the
|
||||
// sender of the reaction matches the membership)
|
||||
@@ -266,8 +229,7 @@ export class ReactionsReader {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// TODO refactor to use memer id `membershipEvent.membershipID` (needs to happen in combination with other memberId refactors)
|
||||
const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`;
|
||||
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`;
|
||||
|
||||
if (!content.emoji) {
|
||||
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
|
||||
@@ -316,7 +278,7 @@ export class ReactionsReader {
|
||||
// Check to see if this reaction was made to a membership event (and the
|
||||
// sender of the reaction matches the membership)
|
||||
const membershipEvent = this.rtcSession.memberships.find(
|
||||
(e) => e.eventId === membershipEventId && e.userId === sender,
|
||||
(e) => e.eventId === membershipEventId && e.sender === sender,
|
||||
);
|
||||
if (!membershipEvent) {
|
||||
logger.warn(
|
||||
@@ -327,7 +289,7 @@ export class ReactionsReader {
|
||||
|
||||
if (content?.["m.relates_to"].key === "🖐️") {
|
||||
this.addRaisedHand(
|
||||
`${membershipEvent.userId}:${membershipEvent.deviceId}`,
|
||||
`${membershipEvent.sender}:${membershipEvent.deviceId}`,
|
||||
{
|
||||
reactionEventId,
|
||||
membershipEventId,
|
||||
@@ -347,4 +309,31 @@ export class ReactionsReader {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url";
|
||||
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
|
||||
import waveSoundOgg from "../sound/reactions/wave.ogg?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";
|
||||
|
||||
@@ -193,15 +191,6 @@ export const ReactionSet: ReactionOption[] = [
|
||||
mp3: waveSoundMp3,
|
||||
},
|
||||
},
|
||||
{
|
||||
emoji: "🥁",
|
||||
name: "drum",
|
||||
alias: ["joke"],
|
||||
sound: {
|
||||
ogg: baduntssSoundOgg,
|
||||
mp3: baduntssSoundMp3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface RaisedHandInfo {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { ElementCallReactionEventType, type ReactionOption } from ".";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface ReactionsSenderContextType {
|
||||
@@ -65,7 +65,7 @@ export const ReactionsSenderProvider = ({
|
||||
const myMembershipEvent = useMemo(
|
||||
() =>
|
||||
memberships.find(
|
||||
(m) => m.userId === myUserId && m.deviceId === myDeviceId,
|
||||
(m) => m.sender === myUserId && m.deviceId === myDeviceId,
|
||||
)?.eventId,
|
||||
[memberships, myUserId, myDeviceId],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
afterAll,
|
||||
beforeEach,
|
||||
expect,
|
||||
type MockedFunction,
|
||||
@@ -15,17 +16,9 @@ import {
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { act } from "react";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
import {
|
||||
type LivekitTransport,
|
||||
type CallMembership,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
exampleTransport,
|
||||
mockMatrixRoomMember,
|
||||
mockRtcMembership,
|
||||
} from "../utils/test";
|
||||
import { mockRtcMembership } from "../utils/test";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
@@ -33,23 +26,21 @@ import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
alice,
|
||||
aliceRtcMember,
|
||||
bob,
|
||||
bobRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
|
||||
|
||||
vitest.mock("livekit-client/e2ee-worker?worker");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
makeTransport: (): [LivekitTransport] => [exampleTransport],
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vitest.clearAllMocks();
|
||||
vitest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
let playSound: MockedFunction<
|
||||
@@ -79,7 +70,6 @@ test("plays one sound when entering a call", () => {
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
bob,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
@@ -94,7 +84,6 @@ test("plays a sound when a user joins", () => {
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
bob,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
@@ -133,16 +122,15 @@ test("does not play a sound before the call is successful", () => {
|
||||
});
|
||||
|
||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||
const mockMembers: RoomMember[] = [local];
|
||||
const mockRtcMemberships: CallMembership[] = [localRtcMember];
|
||||
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
||||
const membership = mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`);
|
||||
mockMembers.push(mockMatrixRoomMember(membership));
|
||||
mockRtcMemberships.push(membership);
|
||||
mockRtcMemberships.push(
|
||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
mockMembers,
|
||||
[local, alice],
|
||||
mockRtcMemberships,
|
||||
);
|
||||
|
||||
@@ -162,14 +150,12 @@ test("plays one sound when a hand is raised", () => {
|
||||
const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
bob,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
// TODO: What is this string supposed to be?
|
||||
[`${bobRtcMember.userId}:${bobRtcMember.deviceId}`]: {
|
||||
[bobRtcMember.callId]: {
|
||||
time: new Date(),
|
||||
membershipEventId: "",
|
||||
reactionEventId: "",
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
import joinCallSoundOgg from "../sound/join_call.ogg";
|
||||
import leftCallSoundMp3 from "../sound/left_call.mp3";
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
.overlay[data-show="false"] {
|
||||
animation: fade-out 130ms forwards;
|
||||
content-visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={styles.overlay} data-show={show} aria-hidden={!show}>
|
||||
<div className={styles.overlay} data-show={show}>
|
||||
<BigIcon className={styles.icon}>
|
||||
<VoiceCallIcon aria-hidden />
|
||||
</BigIcon>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
E2EENotSupportedError,
|
||||
type ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
MatrixRTCTransportMissingError,
|
||||
MatrixRTCFocusMissingError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
@@ -34,7 +34,7 @@ import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
error: new MatrixRTCTransportMissingError("example.com"),
|
||||
error: new MatrixRTCFocusMissingError("example.com"),
|
||||
expectedTitle: "Call is not supported",
|
||||
},
|
||||
{
|
||||
@@ -85,7 +85,7 @@ test.each([
|
||||
);
|
||||
|
||||
test("should render the error page with link back to home", async () => {
|
||||
const error = new MatrixRTCTransportMissingError("example.com");
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
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");
|
||||
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
|
||||
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await screen.findByRole("button", { name: "Return to home screen" });
|
||||
@@ -213,7 +213,7 @@ describe("Rageshake button", () => {
|
||||
});
|
||||
|
||||
test("should have a close button in widget mode", async () => {
|
||||
const error = new MatrixRTCTransportMissingError("example.com");
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
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 {
|
||||
beforeEach,
|
||||
expect,
|
||||
@@ -30,6 +26,7 @@ import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-cont
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
@@ -45,12 +42,11 @@ import {
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { MatrixRTCFocusMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MuteStates } from "../state/MuteStates.ts";
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
@@ -77,13 +73,12 @@ const leaveRTCSession = vi.hoisted(() =>
|
||||
),
|
||||
);
|
||||
|
||||
// vi.mock("../rtcSessionHelpers", async (importOriginal) => {
|
||||
// // TODO: perhaps there is a more elegant way to manage the type import here?
|
||||
// // eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
// const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
|
||||
// // TODO: leaveRTCSession no longer exists! Tests need adapting.
|
||||
// return { ...orig, enterRTCSession, leaveRTCSession };
|
||||
// });
|
||||
vi.mock("../rtcSessionHelpers", async (importOriginal) => {
|
||||
// TODO: perhaps there is a more elegant way to manage the type import here?
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
|
||||
return { ...orig, enterRTCSession, leaveRTCSession };
|
||||
});
|
||||
|
||||
let playSound: MockedFunction<
|
||||
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
||||
@@ -108,7 +103,7 @@ beforeEach(() => {
|
||||
});
|
||||
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
||||
({ onLeft: onLeave }) => {
|
||||
({ onLeave }) => {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onLeave("user")}>Leave</button>
|
||||
@@ -122,12 +117,12 @@ function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
joined = true,
|
||||
): {
|
||||
rtcSession: MatrixRTCSession;
|
||||
rtcSession: MockRTCSession;
|
||||
getByText: ReturnType<typeof render>["getByText"];
|
||||
} {
|
||||
const client = {
|
||||
getUser: () => null,
|
||||
getUserId: () => localRtcMember.userId,
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getRoom: (rId) => (rId === roomId ? room : null),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
@@ -155,8 +150,7 @@ function createGroupCallView(
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
|
||||
} as unknown as MuteStates;
|
||||
} as MuteStates;
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
@@ -169,12 +163,10 @@ function createGroupCallView(
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
isJoined={joined}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
@@ -183,11 +175,11 @@ function createGroupCallView(
|
||||
);
|
||||
return {
|
||||
getByText,
|
||||
rtcSession: rtcSession.asMockedSession(),
|
||||
rtcSession,
|
||||
};
|
||||
}
|
||||
|
||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(null);
|
||||
const leaveButton = getByText("Leave");
|
||||
@@ -204,7 +196,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
||||
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||
});
|
||||
|
||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
test("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
@@ -243,7 +235,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
test("GroupCallView leaves the session when an error occurs", async () => {
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
if (error !== null) throw error;
|
||||
@@ -264,10 +256,9 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
test("GroupCallView shows errors that occur during joining", async () => {
|
||||
const user = userEvent.setup();
|
||||
// This should not mock this error that deep. it should only mock the CallViewModel.
|
||||
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
|
||||
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError(""));
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../utils/media";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import {
|
||||
saveKeyForRoom,
|
||||
useRoomEncryptionSystem,
|
||||
@@ -49,12 +50,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import {
|
||||
getUrlParams,
|
||||
HeaderStyle,
|
||||
type UrlParams,
|
||||
useUrlParams,
|
||||
} from "../UrlParams";
|
||||
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import {
|
||||
@@ -70,17 +66,16 @@ import {
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||
import {
|
||||
useNewMembershipManager as useNewMembershipManagerSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
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 {
|
||||
interface Window {
|
||||
rtcSession?: MatrixRTCSession;
|
||||
@@ -95,8 +90,7 @@ interface Props {
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
header: HeaderStyle;
|
||||
rtcSession: MatrixRTCSession;
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
isJoined: boolean;
|
||||
muteStates: MuteStates;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
@@ -109,8 +103,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
skipLobby,
|
||||
header,
|
||||
rtcSession,
|
||||
joined,
|
||||
setJoined,
|
||||
isJoined,
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
@@ -131,7 +124,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
muteStates.audio.setEnabled$.value?.(false);
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -182,6 +175,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
password: passwordFromUrl,
|
||||
} = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
|
||||
// Save the password once we start the groupCallView
|
||||
useEffect(() => {
|
||||
@@ -206,7 +203,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
// Count each member only once, regardless of how many devices they use
|
||||
const participantCount = useMemo(
|
||||
() => new Set<string>(memberships.map((m) => m.userId!)).size,
|
||||
() => new Set<string>(memberships.map((m) => m.sender!)).size,
|
||||
[memberships],
|
||||
);
|
||||
|
||||
@@ -216,9 +213,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
const enterRTCSessionOrError = useCallback(
|
||||
async (rtcSession: MatrixRTCSession): Promise<void> => {
|
||||
try {
|
||||
setJoined(true);
|
||||
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
|
||||
// @BillCarsonFr
|
||||
await enterRTCSession(
|
||||
rtcSession,
|
||||
perParticipantE2EE,
|
||||
useNewMembershipManager,
|
||||
useExperimentalToDeviceTransport,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ElementCallError) {
|
||||
setExternalError(e);
|
||||
@@ -230,9 +230,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
setExternalError(error);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
[setJoined],
|
||||
[
|
||||
perParticipantE2EE,
|
||||
useExperimentalToDeviceTransport,
|
||||
useNewMembershipManager,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,7 +254,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
if (!deviceId) {
|
||||
logger.warn("Unknown audio input: " + audioInput);
|
||||
// override the default mute state
|
||||
latestMuteStates.current!.audio.setEnabled$.value?.(false);
|
||||
latestMuteStates.current!.audio.setEnabled?.(false);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
||||
@@ -265,7 +268,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
if (!deviceId) {
|
||||
logger.warn("Unknown video input: " + videoInput);
|
||||
// override the default mute state
|
||||
latestMuteStates.current!.video.setEnabled$.value?.(false);
|
||||
latestMuteStates.current!.video.setEnabled?.(false);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Found video input ID ${deviceId} for name ${videoInput}`,
|
||||
@@ -281,7 +284,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
setJoined(true);
|
||||
await enterRTCSessionOrError(rtcSession);
|
||||
widget.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session on preload", e);
|
||||
@@ -293,7 +296,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
setJoined(true);
|
||||
(async (): Promise<void> => {
|
||||
await enterRTCSessionOrError(rtcSession);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session immediately", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -304,85 +311,61 @@ export const GroupCallView: FC<Props> = ({
|
||||
perParticipantE2EE,
|
||||
mediaDevices,
|
||||
latestMuteStates,
|
||||
setJoined,
|
||||
enterRTCSessionOrError,
|
||||
useNewMembershipManager,
|
||||
]);
|
||||
|
||||
// TODO refactor this + "joined" to just one callState
|
||||
const [left, setLeft] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLeft = useCallback(
|
||||
(reason: "timeout" | "user" | "allOthersLeft" | "decline"): void => {
|
||||
let playSound: CallEventSounds = "left";
|
||||
if (reason === "timeout" || reason === "decline") playSound = reason;
|
||||
|
||||
setJoined(false);
|
||||
setLeft(true);
|
||||
const onLeave = useCallback(
|
||||
(
|
||||
cause: "user" | "error" = "user",
|
||||
playSound: CallEventSounds = "left",
|
||||
): void => {
|
||||
const audioPromise = leaveSoundContext.current?.playSound(playSound);
|
||||
// We need to wait until the callEnded event is tracked on PostHog,
|
||||
// otherwise the iframe may get killed first.
|
||||
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
|
||||
// therefore we want the event to be sent instantly without getting queued/batched.
|
||||
const sendInstantly = !!widget;
|
||||
setLeft(true);
|
||||
// we need to wait until the callEnded event is tracked on posthog.
|
||||
// Otherwise the iFrame gets killed before the callEnded event got tracked.
|
||||
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(
|
||||
room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
sendInstantly,
|
||||
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);
|
||||
});
|
||||
|
||||
void Promise.all([audioPromise, posthogRequest])
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
"Failed to play leave audio and/or send PostHog leave event",
|
||||
e,
|
||||
),
|
||||
)
|
||||
leaveRTCSession(
|
||||
rtcSession,
|
||||
cause,
|
||||
// Wait for the sound in widget mode (it's not long)
|
||||
Promise.all([audioPromise, posthogRequest]),
|
||||
)
|
||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||
.then(async () => {
|
||||
if (
|
||||
!isPasswordlessUser &&
|
||||
!confineToRoom &&
|
||||
!PosthogAnalytics.instance.isEnabled()
|
||||
)
|
||||
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 === "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();
|
||||
}
|
||||
) {
|
||||
await navigate("/");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Error leaving RTC session", e);
|
||||
});
|
||||
},
|
||||
[
|
||||
setJoined,
|
||||
leaveSoundContext,
|
||||
widget,
|
||||
room.roomId,
|
||||
rtcSession,
|
||||
room.roomId,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
navigate,
|
||||
@@ -390,12 +373,25 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget && joined)
|
||||
if (widget && isJoined) {
|
||||
// set widget to sticky once joined.
|
||||
widget.api.setAlwaysOnScreen(true).catch((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);
|
||||
|
||||
@@ -430,7 +426,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
onEnter={() => void enterRTCSessionOrError(rtcSession)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
participantCount={participantCount}
|
||||
@@ -448,7 +444,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
throw externalError;
|
||||
};
|
||||
body = <ErrorComponent />;
|
||||
} else if (joined) {
|
||||
} else if (isJoined) {
|
||||
body = (
|
||||
<>
|
||||
{shareModal}
|
||||
@@ -457,7 +453,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
onLeave={onLeave}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
@@ -518,8 +514,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
}}
|
||||
onError={
|
||||
(/**error*/) => {
|
||||
// TODO this should not be "user". It needs a new case
|
||||
if (rtcSession.isJoined()) onLeft("user");
|
||||
if (rtcSession.isJoined()) onLeave("error");
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -13,15 +13,18 @@ import {
|
||||
type MockedFunction,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { act, render, type RenderResult } from "@testing-library/react";
|
||||
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 LocalParticipant } from "livekit-client";
|
||||
import { of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
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 {
|
||||
mockLivekitRoom,
|
||||
@@ -29,7 +32,6 @@ import {
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
type MockRTCSession,
|
||||
@@ -37,12 +39,19 @@ import {
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import {
|
||||
developerMode as developerModeSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
} from "../settings/settings";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
// vi.hoisted(() => {
|
||||
// localStorage = {} as unknown as Storage;
|
||||
// });
|
||||
vi.hoisted(
|
||||
() =>
|
||||
(global.ImageData = class MockImageData {
|
||||
@@ -55,7 +64,6 @@ vi.mock("../useAudioContext");
|
||||
vi.mock("../tile/GridTile");
|
||||
vi.mock("../tile/SpotlightTile");
|
||||
vi.mock("@livekit/components-react");
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
vi.mock("../e2ee/sharedKeyManagement");
|
||||
vi.mock("../livekit/MatrixAudioRenderer");
|
||||
vi.mock("react-use-measure", () => ({
|
||||
@@ -80,7 +88,7 @@ beforeEach(() => {
|
||||
|
||||
// MatrixAudioRenderer is tested separately.
|
||||
(
|
||||
LivekitRoomAudioRenderer as MockedFunction<typeof LivekitRoomAudioRenderer>
|
||||
MatrixAudioRenderer as MockedFunction<typeof MatrixAudioRenderer>
|
||||
).mockImplementation((_props) => {
|
||||
return <div>mocked: MatrixAudioRenderer</div>;
|
||||
});
|
||||
@@ -103,10 +111,9 @@ function createInCallView(): RenderResult & {
|
||||
} {
|
||||
const client = {
|
||||
getUser: () => null,
|
||||
getUserId: () => localRtcMember.userId,
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getRoom: (rId) => (rId === roomId ? room : null),
|
||||
getDomain: () => "example.com",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const room = mockMatrixRoom({
|
||||
relations: {
|
||||
@@ -117,8 +124,7 @@ function createInCallView(): RenderResult & {
|
||||
} as unknown as RelationsContainer,
|
||||
client,
|
||||
roomId,
|
||||
// getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
getMembers: () => Array.from(roomMembers.values()),
|
||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
getMxcAvatarUrl: () => null,
|
||||
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
|
||||
getCanonicalAlias: () => null,
|
||||
@@ -127,7 +133,10 @@ function createInCallView(): RenderResult & {
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
|
||||
const muteState = mockMuteStates();
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
} as MuteStates;
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{
|
||||
localParticipant,
|
||||
@@ -144,14 +153,14 @@ function createInCallView(): RenderResult & {
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<InCallView
|
||||
client={client}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
@@ -167,6 +176,10 @@ function createInCallView(): RenderResult & {
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
livekitRoom={livekitRoom}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext>
|
||||
@@ -188,4 +201,71 @@ describe("InCallView", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
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 { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
@@ -23,8 +25,13 @@ import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import {
|
||||
useObservable,
|
||||
useObservableEagerState,
|
||||
useSubscription,
|
||||
} from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import {
|
||||
VoiceCallSolidIcon,
|
||||
VolumeOnSolidIcon,
|
||||
@@ -47,26 +54,32 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useLivekit } from "../livekit/useLivekit.ts";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
CallViewModel,
|
||||
type GridMode,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
type Layout,
|
||||
} from "../state/CallViewModel";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
useRoomEncryptionSystem,
|
||||
type EncryptionSystem,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
@@ -85,88 +98,126 @@ import {
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
type CallEventSounds,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
developerMode as developerModeSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { ConnectionLostError } from "../utils/errors.ts";
|
||||
import { useTypedEventEmitter } from "../useEvents.ts";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
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 canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
// TODO refactor those reasons into an enum
|
||||
onLeft: (reason: "user" | "timeout" | "decline" | "allOthersLeft") => void;
|
||||
}
|
||||
|
||||
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 urlParams = useUrlParams();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
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$),
|
||||
logger.info(
|
||||
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
|
||||
);
|
||||
setVm(vm);
|
||||
|
||||
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
||||
return (): void => {
|
||||
scope.end();
|
||||
logger.info(
|
||||
`[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, sendNotificationType, waitForCallPickup } =
|
||||
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,
|
||||
waitForCallPickup:
|
||||
waitForCallPickup && sendNotificationType === "ring",
|
||||
},
|
||||
connStateObservable$,
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
);
|
||||
setVm(vm);
|
||||
return (): void => {
|
||||
vm.destroy();
|
||||
reactionsReader.destroy();
|
||||
};
|
||||
}
|
||||
}, [
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.e2eeSystem,
|
||||
connStateObservable$,
|
||||
autoLeaveWhenOthersLeft,
|
||||
sendNotificationType,
|
||||
waitForCallPickup,
|
||||
]);
|
||||
|
||||
if (vm === null) return null;
|
||||
if (livekitRoom === undefined || vm === null) return null;
|
||||
|
||||
return (
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView {...props} vm={vm} />
|
||||
</ReactionsSenderProvider>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
|
||||
</ReactionsSenderProvider>
|
||||
</RoomContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -176,7 +227,10 @@ export interface InCallViewProps {
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
livekitRoom: LivekitRoom;
|
||||
muteStates: MuteStates;
|
||||
/** Function to call when the user explicitly ends the call */
|
||||
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
|
||||
header: HeaderStyle;
|
||||
otelGroupCallMembership?: OTelGroupCallMembership;
|
||||
onShareClick: (() => void) | null;
|
||||
@@ -186,9 +240,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
vm,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
|
||||
onLeave,
|
||||
header: headerStyle,
|
||||
onShareClick,
|
||||
}) => {
|
||||
@@ -197,23 +253,23 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
// TODO-MULTI-SFU This is unused now??
|
||||
// const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
||||
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
||||
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
// This needs to be done differential. with the vm connection state we start with Disconnected.
|
||||
// TODO-MULTI-SFU decide how to handle this properly
|
||||
// @BillCarsonFr
|
||||
// if (connectionState === ConnectionState.Disconnected)
|
||||
// throw new ConnectionLostError();
|
||||
if (connectionState === ConnectionState.Disconnected)
|
||||
throw new ConnectionLostError();
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure();
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const { showControls } = useUrlParams();
|
||||
const { hideScreensharing, showControls } = useUrlParams();
|
||||
|
||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
// Call pickup state and display names are needed for waiting overlay/sounds
|
||||
@@ -232,24 +288,55 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
muted: muteAllAudio,
|
||||
});
|
||||
|
||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||
// This seems like it might be enough logic to use move it into the call view model?
|
||||
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
RoomAndToDeviceEvents.EnabledTransportsChanged,
|
||||
(enabled) => setDidFallbackToRoomKey(enabled.room),
|
||||
);
|
||||
|
||||
const [developerMode] = useSetting(developerModeSetting);
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
() =>
|
||||
developerMode &&
|
||||
useExperimentalToDeviceTransport &&
|
||||
encryptionSystem.kind === E2eeType.PER_PARTICIPANT &&
|
||||
!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.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
setAudioEnabled,
|
||||
toggleMicrophone,
|
||||
toggleCamera,
|
||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||
(reaction) => void sendReaction(reaction),
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const audioParticipants = useBehavior(vm.audioParticipants$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
@@ -261,12 +348,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
|
||||
const ringOverlay = useBehavior(vm.ringOverlay$);
|
||||
const fatalCallError = useBehavior(vm.configError$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
if (fatalCallError) throw fatalCallError;
|
||||
useSubscription(vm.autoLeave$, () => onLeave("user"));
|
||||
|
||||
// We need to set the proper timings on the animation based upon the sound length.
|
||||
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
||||
@@ -287,6 +369,16 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
};
|
||||
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
|
||||
|
||||
// When we enter timeout or decline we will leave the call.
|
||||
useEffect((): void | (() => void) => {
|
||||
if (callPickupState === "timeout") {
|
||||
onLeave("user", "timeout");
|
||||
}
|
||||
if (callPickupState === "decline") {
|
||||
onLeave("user", "decline");
|
||||
}
|
||||
}, [callPickupState, onLeave, pickupPhaseAudio]);
|
||||
|
||||
// When waiting for pickup, loop a waiting sound
|
||||
useEffect((): void | (() => void) => {
|
||||
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
|
||||
@@ -300,26 +392,46 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
// Waiting UI overlay
|
||||
const waitingOverlay: JSX.Element | null = useMemo(() => {
|
||||
return ringOverlay ? (
|
||||
// No overlay if not in ringing state
|
||||
if (callPickupState !== "ringing") return null;
|
||||
|
||||
// Use room state for other participants data (the one that we likely want to reach)
|
||||
const roomOthers = [
|
||||
...matrixRoom.getMembersWithMembership("join"),
|
||||
...matrixRoom.getMembersWithMembership("invite"),
|
||||
].filter((m) => m.userId !== client.getUserId());
|
||||
// Yield if there are not other members in the room.
|
||||
if (roomOthers.length === 0) return null;
|
||||
|
||||
const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined;
|
||||
const isOneOnOne = roomOthers.length === 1 && otherMember;
|
||||
const text = isOneOnOne
|
||||
? `Waiting for ${otherMember.name ?? otherMember.userId} to join…`
|
||||
: "Waiting for other participants…";
|
||||
const avatarMxc = isOneOnOne
|
||||
? (otherMember.getMxcAvatarUrl?.() ?? undefined)
|
||||
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
|
||||
|
||||
return (
|
||||
<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}
|
||||
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
|
||||
name={isOneOnOne ? otherMember.name : matrixRoom.name}
|
||||
src={avatarMxc}
|
||||
size={AvatarSize.XL}
|
||||
/>
|
||||
</div>
|
||||
<Text size="md" className={waitingStyles.text}>
|
||||
{ringOverlay.text}
|
||||
{text}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}, [ringOverlay]);
|
||||
);
|
||||
}, [callPickupState, client, matrixRoom]);
|
||||
|
||||
// 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
|
||||
@@ -529,38 +641,6 @@ 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(
|
||||
() =>
|
||||
function Tile({
|
||||
@@ -590,7 +670,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
||||
focusable={!contentObscured}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightTile
|
||||
@@ -601,13 +680,12 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicatorsValue}
|
||||
focusable={!contentObscured}
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[vm, openProfile, contentObscured],
|
||||
[vm, openProfile],
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
@@ -636,8 +714,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetWidth={gridBounds.height}
|
||||
targetHeight={gridBounds.width}
|
||||
showIndicators={false}
|
||||
focusable={!contentObscured}
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -655,7 +731,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
model={layout}
|
||||
Layout={layers.fixed}
|
||||
Tile={Tile}
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
);
|
||||
const scrollingGrid = (
|
||||
@@ -665,7 +740,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
model={layout}
|
||||
Layout={layers.scrolling}
|
||||
Tile={Tile}
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
);
|
||||
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||
@@ -687,33 +761,44 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
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[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="audio"
|
||||
muted={!audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
muted={!muteStates.audio.enabled}
|
||||
onClick={toggleMicrophone}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleAudio === null}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="video"
|
||||
muted={!videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
muted={!muteStates.video.enabled}
|
||||
onClick={toggleCamera}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleVideo === null}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if (vm.toggleScreenSharing !== null) {
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
key="share_screen"
|
||||
className={styles.shareScreen}
|
||||
enabled={sharingScreen}
|
||||
onClick={vm.toggleScreenSharing}
|
||||
enabled={isScreenShareEnabled}
|
||||
onClick={toggleScreensharing}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
@@ -743,7 +828,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<EndCallButton
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
vm.hangup();
|
||||
onLeave("user");
|
||||
}}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_leave"
|
||||
@@ -784,6 +869,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The reconnecting toast cannot be dismissed
|
||||
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
@@ -795,20 +883,33 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
{header}
|
||||
{audioParticipants.map(({ livekitRoom, url, participants }) => (
|
||||
<LivekitRoomAudioRenderer
|
||||
key={url}
|
||||
url={url}
|
||||
livekitRoom={livekitRoom}
|
||||
validIdentities={participants}
|
||||
muted={muteAllAudio}
|
||||
/>
|
||||
))}
|
||||
{
|
||||
// TODO: remove this once we remove the developer flag gets removed and we have shipped to
|
||||
// device transport as the default.
|
||||
showToDeviceEncryption && (
|
||||
<Text
|
||||
style={{ height: 0, zIndex: 1, alignSelf: "center", margin: 0 }}
|
||||
size="sm"
|
||||
>
|
||||
using to Device key transport
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
{reconnectingToast}
|
||||
{earpieceOverlay}
|
||||
<Toast
|
||||
onDismiss={onDismissReconnectingToast}
|
||||
open={reconnecting}
|
||||
portal={false}
|
||||
>
|
||||
{t("common.reconnecting")}
|
||||
</Toast>
|
||||
<EarpieceOverlay
|
||||
show={earpieceMode && !reconnecting}
|
||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||
/>
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{waitingOverlay}
|
||||
{footer}
|
||||
@@ -822,8 +923,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
// TODO expose correct data to setttings modal
|
||||
livekitRooms={[]}
|
||||
livekitRoom={livekitRoom}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
EndCallButton,
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
useTrackProcessorSync,
|
||||
} from "../livekit/TrackProcessorContext";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -88,10 +88,14 @@ export const LobbyView: FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(matrixInfo.roomName);
|
||||
|
||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
const onAudioPress = useCallback(
|
||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
const onVideoPress = useCallback(
|
||||
() => muteStates.video.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
@@ -129,7 +133,7 @@ export const LobbyView: FC<Props> = ({
|
||||
// re-open the devices when they change (see below).
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
audioEnabled && {
|
||||
muteStates.audio.enabled && {
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id,
|
||||
},
|
||||
);
|
||||
@@ -146,21 +150,27 @@ export const LobbyView: FC<Props> = ({
|
||||
// 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.
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
video: videoEnabled && {
|
||||
video: muteStates.video.enabled && {
|
||||
deviceId: videoInputId,
|
||||
processor: initialProcessor,
|
||||
},
|
||||
}),
|
||||
[initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
|
||||
[
|
||||
initialAudioOptions,
|
||||
muteStates.video.enabled,
|
||||
videoInputId,
|
||||
initialProcessor,
|
||||
],
|
||||
);
|
||||
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
muteStates.audio.setEnabled$.value?.(false);
|
||||
muteStates.video.setEnabled$.value?.(false);
|
||||
latestMuteStates.current.audio.setEnabled?.(false);
|
||||
latestMuteStates.current.video.setEnabled?.(false);
|
||||
},
|
||||
[muteStates],
|
||||
[latestMuteStates],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
@@ -207,7 +217,7 @@ export const LobbyView: FC<Props> = ({
|
||||
<div className={styles.content}>
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
videoEnabled={videoEnabled}
|
||||
muteStates={muteStates}
|
||||
videoTrack={videoTrack}
|
||||
>
|
||||
<Button
|
||||
@@ -215,7 +225,6 @@ export const LobbyView: FC<Props> = ({
|
||||
[styles.wait]: waitingForInvite,
|
||||
})}
|
||||
size={waitingForInvite ? "sm" : "lg"}
|
||||
disabled={waitingForInvite}
|
||||
onClick={() => {
|
||||
if (!waitingForInvite) onEnter();
|
||||
}}
|
||||
@@ -230,14 +239,14 @@ export const LobbyView: FC<Props> = ({
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<div className={inCallStyles.buttons}>
|
||||
<MicButton
|
||||
muted={!audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
muted={!muteStates.audio.enabled}
|
||||
onClick={onAudioPress}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={!videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
muted={!muteStates.video.enabled}
|
||||
onClick={onVideoPress}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
/>
|
||||
<SettingsButton onClick={openSettings} />
|
||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||
|
||||
258
src/room/MuteStates.test.tsx
Normal file
258
src/room/MuteStates.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
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");
|
||||
});
|
||||
});
|
||||
166
src/room/MuteStates.ts
Normal file
166
src/room/MuteStates.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
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]);
|
||||
}
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
test,
|
||||
vitest,
|
||||
type MockedFunction,
|
||||
type Mock,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
alice,
|
||||
@@ -46,129 +45,125 @@ function TestComponent({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
vitest.mock("livekit-client/e2ee-worker?worker");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
|
||||
afterEach(() => {
|
||||
vitest.resetAllMocks();
|
||||
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
|
||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
let playSound: Mock<
|
||||
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
||||
>;
|
||||
|
||||
describe("ReactionAudioRenderer", () => {
|
||||
afterEach(() => {
|
||||
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
|
||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||
beforeEach(() => {
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||
sound: new ArrayBuffer(0),
|
||||
});
|
||||
beforeEach(() => {
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue(
|
||||
{
|
||||
sound: new ArrayBuffer(0),
|
||||
},
|
||||
);
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue(
|
||||
{
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("preloads all audio elements", () => {
|
||||
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
|
||||
playReactionsSoundSetting.setValue(true);
|
||||
render(<TestComponent vm={vm} />);
|
||||
expect(prefetchSounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("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",
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: chosenReaction,
|
||||
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);
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: chosenReaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
test("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);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
|
||||
const soundMap = Object.fromEntries([
|
||||
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, afterEach, vi } from "vitest";
|
||||
import { expect, test, afterEach } from "vitest";
|
||||
import { act } from "react";
|
||||
|
||||
import { showReactions } from "../settings/settings";
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
} from "../utils/test-fixtures";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
afterEach(() => {
|
||||
showReactions.setValue(showReactions.defaultValue);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import styles from "./ReactionsOverlay.module.css";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
CheckIcon,
|
||||
UnknownSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||
@@ -37,13 +35,12 @@ import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
import { ErrorView } from "../ErrorView";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { MuteStates } from "../state/MuteStates";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
|
||||
@@ -64,19 +61,10 @@ export const RoomPage: FC = () => {
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
const [joined, setJoined] = useState(false);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const [muteStates, setMuteStates] = useState<MuteStates | null>(null);
|
||||
const joined$ = useObservable(
|
||||
(inputs$) => inputs$.pipe(map(([joined]) => joined)),
|
||||
[joined],
|
||||
const isJoined = useMatrixRTCSessionJoinState(
|
||||
groupCallState.kind === "loaded" ? groupCallState.rtcSession : undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setMuteStates(new MuteStates(scope, devices, joined$));
|
||||
return (): void => scope.end();
|
||||
}, [devices, joined$]);
|
||||
const muteStates = useMuteStates(isJoined);
|
||||
|
||||
useEffect(() => {
|
||||
// If we've finished loading, are not already authed and we've been given a display name as
|
||||
@@ -113,25 +101,22 @@ export const RoomPage: FC = () => {
|
||||
}
|
||||
}, [groupCallState.kind]);
|
||||
|
||||
const groupCallView = (): ReactNode => {
|
||||
const groupCallView = (): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
muteStates && (
|
||||
<GroupCallView
|
||||
widget={widget}
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
joined={joined}
|
||||
setJoined={setJoined}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
)
|
||||
<GroupCallView
|
||||
widget={widget}
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isJoined={isJoined}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
@@ -150,35 +135,31 @@ export const RoomPage: FC = () => {
|
||||
</>
|
||||
);
|
||||
return (
|
||||
muteStates && (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header !== "standard"}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
)
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header !== "standard"}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "loading":
|
||||
|
||||
@@ -5,12 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeAll } from "vitest";
|
||||
import { expect, describe, it, vi, beforeAll } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
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", () => {
|
||||
const matrixInfo: MatrixInfo = {
|
||||
userId: "@a:example.org",
|
||||
@@ -41,7 +49,7 @@ describe("VideoPreview", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
videoEnabled={false}
|
||||
muteStates={mockMuteStates({ video: false })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
@@ -53,7 +61,7 @@ describe("VideoPreview", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
videoEnabled
|
||||
muteStates={mockMuteStates({ video: true })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TileAvatar } from "../tile/TileAvatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type MatrixInfo = {
|
||||
@@ -28,14 +29,14 @@ export type MatrixInfo = {
|
||||
|
||||
interface Props {
|
||||
matrixInfo: MatrixInfo;
|
||||
videoEnabled: boolean;
|
||||
muteStates: MuteStates;
|
||||
videoTrack: LocalVideoTrack | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const VideoPreview: FC<Props> = ({
|
||||
matrixInfo,
|
||||
videoEnabled,
|
||||
muteStates,
|
||||
videoTrack,
|
||||
children,
|
||||
}) => {
|
||||
@@ -55,8 +56,8 @@ export const VideoPreview: FC<Props> = ({
|
||||
}, [videoTrack]);
|
||||
|
||||
const cameraIsStarting = useMemo(
|
||||
() => videoEnabled && !videoTrack,
|
||||
[videoEnabled, videoTrack],
|
||||
() => muteStates.video.enabled && !videoTrack,
|
||||
[muteStates.video.enabled, videoTrack],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,7 +76,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
/>
|
||||
{(!videoEnabled || cameraIsStarting) && (
|
||||
{(!muteStates.video.enabled || cameraIsStarting) && (
|
||||
<>
|
||||
<div className={styles.avatarContainer}>
|
||||
{cameraIsStarting && (
|
||||
|
||||
@@ -292,7 +292,7 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
Call is not supported
|
||||
</h1>
|
||||
<p>
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_vczzf_8"
|
||||
@@ -445,7 +445,7 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
Call is not supported
|
||||
</h1>
|
||||
<p>
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_vczzf_8 homeLink"
|
||||
@@ -598,7 +598,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
Call is not supported
|
||||
</h1>
|
||||
<p>
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_vczzf_8 homeLink"
|
||||
|
||||
@@ -83,6 +83,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="nav rightNav"
|
||||
/>
|
||||
</header>
|
||||
<div>
|
||||
mocked: MatrixAudioRenderer
|
||||
</div>
|
||||
<div
|
||||
class="scrollingGrid grid"
|
||||
>
|
||||
@@ -108,11 +111,6 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="bg animate"
|
||||
data-state="closed"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="overlay"
|
||||
data-show="false"
|
||||
>
|
||||
@@ -285,7 +283,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r8»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
@@ -308,7 +306,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«rd»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
|
||||
155
src/room/checkForParallelCalls.test.ts
Normal file
155
src/room/checkForParallelCalls.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/room/checkForParallelCalls.ts
Normal file
55
src/room/checkForParallelCalls.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
45
src/room/useActiveFocus.ts
Normal file
45
src/room/useActiveFocus.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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]),
|
||||
);
|
||||
}
|
||||
@@ -6,20 +6,21 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { MatrixRTCMode } from "../../../settings/settings";
|
||||
import { mockConfig } from "../../../utils/test";
|
||||
import { enterRTCSession } from "./LocalMembership";
|
||||
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import { ElementWidgetActions, widget } from "./widget";
|
||||
import { ErrorCode } from "./utils/errors.ts";
|
||||
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
|
||||
vi.mock("./UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
vi.mock("../../../widget", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
|
||||
vi.mock("./widget", () => ({
|
||||
...actualWidget,
|
||||
widget: {
|
||||
api: {
|
||||
setAlwaysOnScreen: (): void => {},
|
||||
@@ -84,33 +85,103 @@ test("It joins the correct Session", async () => {
|
||||
}),
|
||||
joinRoomSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
await enterRTCSession(
|
||||
mockedSession,
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MATRIX_RTC_MODE,
|
||||
},
|
||||
);
|
||||
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",
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
manageMediaKeys: true,
|
||||
{
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,16 +214,5 @@ test("It should not fail with configuration error if homeserver config has livek
|
||||
joinRoomSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
await enterRTCSession(
|
||||
mockedSession,
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MATRIX_RTC_MODE,
|
||||
},
|
||||
);
|
||||
await enterRTCSession(mockedSession, false);
|
||||
});
|
||||
199
src/rtcSessionHelpers.ts
Normal file
199
src/rtcSessionHelpers.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,103 +5,72 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useId,
|
||||
} from "react";
|
||||
import { type ChangeEvent, type FC, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
UNSTABLE_MSC4354_STICKY_EVENTS,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
Root as Form,
|
||||
Heading,
|
||||
HelpMessage,
|
||||
InlineField,
|
||||
Label,
|
||||
RadioControl,
|
||||
} from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import {
|
||||
useSetting,
|
||||
duplicateTiles as duplicateTilesSetting,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
showNonMemberTiles as showNonMemberTilesSetting,
|
||||
showConnectionStats as showConnectionStatsSetting,
|
||||
useNewMembershipManager as useNewMembershipManagerSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
muteAllAudio as muteAllAudioSetting,
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
MatrixRTCMode,
|
||||
} from "./settings";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import styles from "./DeveloperSettingsTab.module.css";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[];
|
||||
livekitRoom?: LivekitRoom;
|
||||
}
|
||||
|
||||
export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
|
||||
export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
|
||||
const { t } = useTranslation();
|
||||
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
|
||||
const [debugTileLayout, setDebugTileLayout] = useSetting(
|
||||
debugTileLayoutSetting,
|
||||
);
|
||||
|
||||
const [stickyEventsSupported, setStickyEventsSupported] = useState(false);
|
||||
useEffect(() => {
|
||||
client
|
||||
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
|
||||
.then((result) => {
|
||||
setStickyEventsSupported(result);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.warn("Failed to check if sticky events are supported", ex);
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
const [matrixRTCMode, setMatrixRTCMode] = useSetting(matrixRTCModeSetting);
|
||||
const matrixRTCModeRadioGroup = useId();
|
||||
const onMatrixRTCModeChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setMatrixRTCMode(e.target.value as MatrixRTCMode);
|
||||
},
|
||||
[setMatrixRTCMode],
|
||||
const [showNonMemberTiles, setShowNonMemberTiles] = useSetting(
|
||||
showNonMemberTilesSetting,
|
||||
);
|
||||
|
||||
const [showConnectionStats, setShowConnectionStats] = useSetting(
|
||||
showConnectionStatsSetting,
|
||||
);
|
||||
|
||||
const [useNewMembershipManager, setNewMembershipManager] = useSetting(
|
||||
useNewMembershipManagerSetting,
|
||||
);
|
||||
|
||||
const [alwaysShowIphoneEarpiece, setAlwaysShowIphoneEarpiece] = useSetting(
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
const [
|
||||
useExperimentalToDeviceTransport,
|
||||
setUseExperimentalToDeviceTransport,
|
||||
] = useSetting(useExperimentalToDeviceTransportSetting);
|
||||
|
||||
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
|
||||
|
||||
const urlParams = useUrlParams();
|
||||
|
||||
const localSfuUrl = useMemo((): URL | null => {
|
||||
const localRoom = livekitRooms?.find((r) => r.isLocal)?.room;
|
||||
if (localRoom?.engine.client.ws?.url) {
|
||||
const sfuUrl = useMemo((): URL | null => {
|
||||
if (livekitRoom?.engine.client.ws?.url) {
|
||||
// strip the URL params
|
||||
const url = new URL(localRoom.engine.client.ws.url);
|
||||
const url = new URL(livekitRoom.engine.client.ws.url);
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}, [livekitRooms]);
|
||||
}, [livekitRoom]);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<>
|
||||
<p>
|
||||
{t("developer_mode.hostname", {
|
||||
hostname: window.location.hostname || "unknown",
|
||||
@@ -158,6 +127,20 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showNonMemberTiles"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.show_non_member_tiles")}
|
||||
checked={!!showNonMemberTiles}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setShowNonMemberTiles(event.target.checked);
|
||||
},
|
||||
[setShowNonMemberTiles],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showConnectionStats"
|
||||
@@ -172,6 +155,34 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="useNewMembershipManager"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.use_new_membership_manager")}
|
||||
checked={!!useNewMembershipManager}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setNewMembershipManager(event.target.checked);
|
||||
},
|
||||
[setNewMembershipManager],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="useToDeviceKeyTransport"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.use_to_device_key_transport")}
|
||||
checked={!!useExperimentalToDeviceTransport}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setUseExperimentalToDeviceTransport(event.target.checked);
|
||||
},
|
||||
[setUseExperimentalToDeviceTransport],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="muteAllAudio"
|
||||
@@ -200,79 +211,26 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
|
||||
)}
|
||||
/>{" "}
|
||||
</FieldRow>
|
||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||
{t("developer_mode.matrixRTCMode.title")}
|
||||
</Heading>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Legacy}
|
||||
value={MatrixRTCMode.Legacy}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Label>{t("developer_mode.matrixRTCMode.Legacy.label")}</Label>
|
||||
<HelpMessage>
|
||||
{t("developer_mode.matrixRTCMode.Legacy.description")}
|
||||
</HelpMessage>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Compatibil}
|
||||
value={MatrixRTCMode.Compatibil}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Label>{t("developer_mode.matrixRTCMode.Comptibility.label")}</Label>
|
||||
<HelpMessage>
|
||||
{t("developer_mode.matrixRTCMode.Comptibility.description")}
|
||||
</HelpMessage>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Matrix_2_0}
|
||||
value={MatrixRTCMode.Matrix_2_0}
|
||||
disabled={!stickyEventsSupported}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Label>{t("developer_mode.matrixRTCMode.Matrix_2_0.label")}</Label>
|
||||
<HelpMessage>
|
||||
{t("developer_mode.matrixRTCMode.Matrix_2_0.description")}
|
||||
</HelpMessage>
|
||||
</InlineField>
|
||||
{livekitRooms?.map((livekitRoom) => (
|
||||
{livekitRoom ? (
|
||||
<>
|
||||
<h3>
|
||||
{t("developer_mode.livekit_sfu", {
|
||||
url: livekitRoom.url || "unknown",
|
||||
})}
|
||||
</h3>
|
||||
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}(
|
||||
{livekitRoom.isLocal ? "local" : "remote"})
|
||||
{t("developer_mode.livekit_sfu", {
|
||||
url: sfuUrl?.href || "unknown",
|
||||
})}
|
||||
</p>
|
||||
<p>{t("developer_mode.livekit_server_info")}</p>
|
||||
<pre className={styles.pre}>
|
||||
{livekitRoom.room.serverInfo
|
||||
? JSON.stringify(livekitRoom.room.serverInfo, null, 2)
|
||||
{livekitRoom.serverInfo
|
||||
? JSON.stringify(livekitRoom.serverInfo, null, 2)
|
||||
: "undefined"}
|
||||
{livekitRoom.room.metadata}
|
||||
{livekitRoom.metadata}
|
||||
</pre>
|
||||
</>
|
||||
))}
|
||||
) : null}
|
||||
<p>{t("developer_mode.environment_variables")}</p>
|
||||
<pre>{JSON.stringify(import.meta.env, null, 2)}</pre>
|
||||
<p>{t("developer_mode.url_params")}</p>
|
||||
<pre>{JSON.stringify(urlParams, null, 2)}</pre>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,11 +51,7 @@ interface Props {
|
||||
onTabChange: (tab: SettingsTab) => void;
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
isLocal?: boolean;
|
||||
}[];
|
||||
livekitRoom?: LivekitRoom;
|
||||
}
|
||||
|
||||
export const defaultSettingsTab: SettingsTab = "audio";
|
||||
@@ -67,7 +63,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
onTabChange,
|
||||
client,
|
||||
roomId,
|
||||
livekitRooms,
|
||||
livekitRoom,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -208,9 +204,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
const developerTab: Tab<SettingsTab> = {
|
||||
key: "developer",
|
||||
name: t("settings.developer_tab_title"),
|
||||
content: (
|
||||
<DeveloperSettingsTab client={client} livekitRooms={livekitRooms} />
|
||||
),
|
||||
content: <DeveloperSettingsTab client={client} livekitRoom={livekitRoom} />,
|
||||
};
|
||||
|
||||
const tabs = [audioTab, videoTab];
|
||||
|
||||
@@ -76,6 +76,10 @@ export const developerMode = new Setting("developer-settings-tab", false);
|
||||
|
||||
export const duplicateTiles = new Setting("duplicate-tiles", 0);
|
||||
|
||||
export const showNonMemberTiles = new Setting<boolean>(
|
||||
"show-non-member-tiles",
|
||||
false,
|
||||
);
|
||||
export const debugTileLayout = new Setting("debug-tile-layout", false);
|
||||
|
||||
export const showConnectionStats = new Setting<boolean>(
|
||||
@@ -115,6 +119,16 @@ export const soundEffectVolume = new Setting<number>(
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const useNewMembershipManager = new Setting<boolean>(
|
||||
"new-membership-manager",
|
||||
true,
|
||||
);
|
||||
|
||||
export const useExperimentalToDeviceTransport = new Setting<boolean>(
|
||||
"experimental-to-device-transport",
|
||||
true,
|
||||
);
|
||||
|
||||
export const muteAllAudio = new Setting<boolean>("mute-all-audio", false);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
@@ -123,14 +137,3 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
|
||||
"always-show-iphone-earpiece",
|
||||
false,
|
||||
);
|
||||
|
||||
export enum MatrixRTCMode {
|
||||
Legacy = "legacy",
|
||||
Compatibil = "compatibil",
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
|
||||
export const matrixRTCMode = new Setting<MatrixRTCMode>(
|
||||
"matrix-rtc-mode",
|
||||
MatrixRTCMode.Legacy,
|
||||
);
|
||||
|
||||
@@ -23,4 +23,3 @@ The following sound effects have been originally created by Element.
|
||||
- `end_call`
|
||||
- `reactions/rock`
|
||||
- `reactions/wave`
|
||||
- `reactions/baduntss`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -6,16 +6,14 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* The top-level state holder for the application.
|
||||
*/
|
||||
export class AppViewModel {
|
||||
export class AppViewModel extends ViewModel {
|
||||
public readonly mediaDevices = new MediaDevices(this.scope);
|
||||
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {}
|
||||
}
|
||||
|
||||
1690
src/state/CallViewModel.test.ts
Normal file
1690
src/state/CallViewModel.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1749
src/state/CallViewModel.ts
Normal file
1749
src/state/CallViewModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,354 +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 ICallNotifyContent,
|
||||
type IRTCNotificationContent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
type IEvent,
|
||||
type IRoomTimelineData,
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
import {
|
||||
aliceRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../../utils/test-fixtures";
|
||||
import {
|
||||
createCallNotificationLifecycle$,
|
||||
type Props as CallNotificationLifecycleProps,
|
||||
} from "./CallNotificationLifecycle";
|
||||
import { trackEpoch } from "../ObservableScope";
|
||||
|
||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||
function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
): { event_id: string } & IRTCNotificationContent {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
}
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
it("unknown -> ringing -> timeout when notified and nobody joins", () => {
|
||||
withTestScheduler(({ scope, expectObservable, behavior, hot }) => {
|
||||
// No one ever joins (only local user)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", { a: [] }).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif1", 30), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "timeout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ringing -> success if someone joins before timeout is reached", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a 19ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("5ms a", {
|
||||
a: [mockRingEvent("$notif2", 100), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("success when someone joins before we notify", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a 9ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("20ms a", {
|
||||
a: [mockRingEvent("$notif2", 50), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("notify without lifetime -> immediate timeout", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif2", undefined), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "timeout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("stays null when waitForCallPickup=false", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const validProps: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a--b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif5", 30), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const propsDeactivated = {
|
||||
...validProps,
|
||||
options: {
|
||||
...validProps.options,
|
||||
waitForCallPickup: false,
|
||||
},
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("n", {
|
||||
n: null,
|
||||
});
|
||||
const lifecycleReference = createCallNotificationLifecycle$(validProps);
|
||||
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
|
||||
u: "unknown",
|
||||
s: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("decline before timeout window ends -> decline", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl1", 50), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$decl1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
e: "decline",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("decline after timeout window ends -> stays timeout", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl", 20), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$decl",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
|
||||
"a 9ms b 19ms e",
|
||||
{
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
e: "timeout",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
//
|
||||
function testStaysRinging(
|
||||
declineEvent: Partial<IEvent>,
|
||||
expectDecline: boolean,
|
||||
): void {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$right", 50), mockLegacyRingEvent],
|
||||
}),
|
||||
receivedDecline$: hot("20ms d", {
|
||||
d: [
|
||||
new MatrixEvent(declineEvent),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
|
||||
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
d: "decline",
|
||||
});
|
||||
});
|
||||
}
|
||||
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
|
||||
event_id: "$decline",
|
||||
type: EventType.RTCDecline,
|
||||
sender: sender ?? "@other:example.org",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: refId ?? "$right",
|
||||
},
|
||||
},
|
||||
});
|
||||
it("decline reference works", () => {
|
||||
testStaysRinging(reference(), true);
|
||||
});
|
||||
it("decline with wrong id is ignored (stays ringing)", () => {
|
||||
testStaysRinging(reference("$wrong"), false);
|
||||
});
|
||||
it("decline with wrong id is ignored (stays ringing)", () => {
|
||||
testStaysRinging(reference(undefined, local.userId), false);
|
||||
});
|
||||
});
|
||||
@@ -1,211 +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 CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
concat,
|
||||
endWith,
|
||||
filter,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
type Room as MatrixRoom,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
export type CallPickupState =
|
||||
| "unknown"
|
||||
| "ringing"
|
||||
| "timeout"
|
||||
| "decline"
|
||||
| "success"
|
||||
| null;
|
||||
export type CallNotificationWrapper = Parameters<
|
||||
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
|
||||
>;
|
||||
export function createSentCallNotification$(
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<CallNotificationWrapper | null> {
|
||||
const sentCallNotification$ = scope.behavior(
|
||||
fromEvent(matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification),
|
||||
null,
|
||||
) as Behavior<CallNotificationWrapper | null>;
|
||||
return sentCallNotification$;
|
||||
}
|
||||
|
||||
export function createReceivedDecline$(
|
||||
matrixRoom: MatrixRoom,
|
||||
): Observable<Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>> {
|
||||
return (
|
||||
fromEvent(matrixRoom, RoomEvent.Timeline) as Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>
|
||||
).pipe(filter(([event]) => event.getType() === EventType.RTCDecline));
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
scope: ObservableScope;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
sentCallNotification$: Observable<CallNotificationWrapper | null>;
|
||||
receivedDecline$: Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>;
|
||||
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
/**
|
||||
* @returns {callPickupState$, autoLeave$}
|
||||
* `callPickupState$` The current call pickup state of the call.
|
||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||
* Then we can conclude if we were the first one to join or not.
|
||||
* This may also be set if we are disconnected.
|
||||
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
||||
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
||||
* The call failed. If desired this can be used as a trigger to exit the call.
|
||||
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
|
||||
* - null: EC is configured to never show any waiting for answer state.
|
||||
*
|
||||
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
|
||||
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
|
||||
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
|
||||
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
|
||||
*
|
||||
*/
|
||||
export function createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$,
|
||||
sentCallNotification$,
|
||||
receivedDecline$,
|
||||
options,
|
||||
localUser,
|
||||
}: Props): {
|
||||
callPickupState$: Behavior<CallPickupState>;
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
} {
|
||||
const allOthersLeft$ = memberships$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([{ value: prev }, { value: current }]) =>
|
||||
current.every((m) => m.userId === localUser.userId) &&
|
||||
prev.some((m) => m.userId !== localUser.userId),
|
||||
),
|
||||
map(() => {}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether some Matrix user other than ourself is joined to the call.
|
||||
*/
|
||||
const someoneElseJoined$ = memberships$.pipe(
|
||||
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
|
||||
) as Behavior<Epoch<boolean>>;
|
||||
|
||||
/**
|
||||
* Whenever the RTC session tells us that it intends to ring the remote
|
||||
* participant's devices, this emits an Observable tracking the current state of
|
||||
* that ringing process.
|
||||
*/
|
||||
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
|
||||
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
|
||||
// A behavior will emit the latest observable with the running timer to new subscribers.
|
||||
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
|
||||
// `ring$` would not be a behavior.
|
||||
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
|
||||
scope.behavior(
|
||||
sentCallNotification$.pipe(
|
||||
filter(
|
||||
(newAndLegacyEvents) =>
|
||||
// only care about new events (legacy do not have decline pattern)
|
||||
newAndLegacyEvents?.[0].notification_type === "ring",
|
||||
),
|
||||
map((e) => e as CallNotificationWrapper),
|
||||
switchMap(([notificationEvent]) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
of(null)
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetimeMs).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has likely
|
||||
// stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
// This makes sure we will not drop into the `endWith("decline" as const)` state
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(
|
||||
receivedDecline$.pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id ===
|
||||
notificationEvent.event_id &&
|
||||
event.getSender() !== localUser.userId &&
|
||||
callPickupState$.value !== "timeout",
|
||||
),
|
||||
),
|
||||
),
|
||||
endWith("decline" as const),
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const callPickupState$ = scope.behavior(
|
||||
options.waitForCallPickup === true
|
||||
? combineLatest(
|
||||
[someoneElseJoined$, remoteRingState$],
|
||||
(someoneElseJoined, ring) => {
|
||||
if (someoneElseJoined.value === true) {
|
||||
return "success" as const;
|
||||
}
|
||||
// Show the ringing state of the most recent ringing attempt.
|
||||
// as long as we have not yet sent an RTC notification event or noone else joined,
|
||||
// ring will be null -> callPickupState$ = unknown.
|
||||
return ring ?? ("unknown" as const);
|
||||
},
|
||||
)
|
||||
: NEVER,
|
||||
null,
|
||||
);
|
||||
|
||||
const autoLeave$ = merge(
|
||||
options.autoLeaveWhenOthersLeft === true
|
||||
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
|
||||
: NEVER,
|
||||
callPickupState$.pipe(
|
||||
filter((state) => state === "timeout" || state === "decline"),
|
||||
),
|
||||
);
|
||||
return { autoLeave$, callPickupState$ };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user