mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
170 Commits
hs/rtc-slo
...
robin/butt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d60b2c29 | ||
|
|
4d69565312 | ||
|
|
fa844446b6 | ||
|
|
6d14f1d06f | ||
|
|
5cf5227eaa | ||
|
|
78240c2ec8 | ||
|
|
bf8bf80417 | ||
|
|
7da9bca08c | ||
|
|
748c8e6d0d | ||
|
|
40ef16a55f | ||
|
|
07ad8374a9 | ||
|
|
a0d5c79999 | ||
|
|
3e171d9639 | ||
|
|
413329cd26 | ||
|
|
6b8f6e9405 | ||
|
|
af54b39698 | ||
|
|
c05d223133 | ||
|
|
c7f25feb66 | ||
|
|
a20edca9a1 | ||
|
|
d00ff78d65 | ||
|
|
839c4dd738 | ||
|
|
41f7b643fb | ||
|
|
3a9d394529 | ||
|
|
c9557e91d5 | ||
|
|
1e400bc550 | ||
|
|
6485da8fff | ||
|
|
54bef07b3b | ||
|
|
273eedd256 | ||
|
|
38382539ad | ||
|
|
8db1c4c370 | ||
|
|
c98d03e2e1 | ||
|
|
143b560b20 | ||
|
|
3da762ab36 | ||
|
|
efcde9edfe | ||
|
|
ca3837f44e | ||
|
|
467675ff40 | ||
|
|
5a612fea91 | ||
|
|
17ec488f0b | ||
|
|
8f418ce9fd | ||
|
|
4c7fad30c0 | ||
|
|
4922249f41 | ||
|
|
06c15ded37 | ||
|
|
885a523e91 | ||
|
|
313b8285d9 | ||
|
|
d00e1ca983 | ||
|
|
513477d280 | ||
|
|
273fff20bd | ||
|
|
3bbbac23a0 | ||
|
|
c7a16e9dfd | ||
|
|
5f2d1c8a7e | ||
|
|
e99e8628d6 | ||
|
|
65045c264b | ||
|
|
3216d68470 | ||
|
|
99401a7285 | ||
|
|
c74d19ad33 | ||
|
|
ea074ceb2d | ||
|
|
b88daf0198 | ||
|
|
a34fe7817a | ||
|
|
72520dbb3f | ||
|
|
2cf4a38c17 | ||
|
|
00f880108e | ||
|
|
a2ee0163b6 | ||
|
|
5165e95d82 | ||
|
|
6e0f48e99b | ||
|
|
adc329a7e7 | ||
|
|
c199d00300 | ||
|
|
38e79bb7db | ||
|
|
23050075b7 | ||
|
|
b2475e4d1d | ||
|
|
8e7e099294 | ||
|
|
dcb7143359 | ||
|
|
0ce24929dc | ||
|
|
9930288d1f | ||
|
|
ae8b1f840f | ||
|
|
15aa67ebb9 | ||
|
|
1de8d93b4b | ||
|
|
6b51b7dc58 | ||
|
|
d16fa4531c | ||
|
|
44eb8acaee | ||
|
|
2fca7e3719 | ||
|
|
62b408a2b3 | ||
|
|
465d28a1cd | ||
|
|
dcf3a722a7 | ||
|
|
6995388a29 | ||
|
|
4039272e75 | ||
|
|
c8f5be7e4f | ||
|
|
4a0e89730d | ||
|
|
bc238778ad | ||
|
|
9d37125677 | ||
|
|
d87c3293c9 | ||
|
|
789de1963a | ||
|
|
ba95780813 | ||
|
|
4508002947 | ||
|
|
2a56830426 | ||
|
|
6cf859fd9e | ||
|
|
91da088da4 | ||
|
|
076cc035d3 | ||
|
|
92992df7df | ||
|
|
13d131c2e9 | ||
|
|
8ea773aa24 | ||
|
|
0d24995c3e | ||
|
|
1e9f2e6282 | ||
|
|
73245e875d | ||
|
|
a329ae0ad1 | ||
|
|
652ff0060c | ||
|
|
d3476c0446 | ||
|
|
a0209eb433 | ||
|
|
e1ef28fdcc | ||
|
|
2e26193c4a | ||
|
|
42efc05bd0 | ||
|
|
809186a2e7 | ||
|
|
927e8e195c | ||
|
|
33f6271d13 | ||
|
|
2c1476f151 | ||
|
|
97ee4630b0 | ||
|
|
02b29f98b6 | ||
|
|
fdc33a223f | ||
|
|
7beafcac92 | ||
|
|
5563f30bf2 | ||
|
|
9d8ebf8ef3 | ||
|
|
a0ad238952 | ||
|
|
18b771e620 | ||
|
|
461a1f1b70 | ||
|
|
a066495c5f | ||
|
|
506b85770b | ||
|
|
bd8c4188d0 | ||
|
|
c78e8c54b8 | ||
|
|
c18cc964a8 | ||
|
|
8ee098eef1 | ||
|
|
a7bd2d3668 | ||
|
|
ca7c8a2f11 | ||
|
|
146375e320 | ||
|
|
992fb9093c | ||
|
|
99ccfcfbd4 | ||
|
|
420304fd9d | ||
|
|
81cb89e599 | ||
|
|
ee8b27837a | ||
|
|
a645bbe0ba | ||
|
|
4c7db0147e | ||
|
|
c1b2346e22 | ||
|
|
92a4150d6d | ||
|
|
829619cca7 | ||
|
|
408495b07a | ||
|
|
02f1a0da82 | ||
|
|
6d8ade073a | ||
|
|
1c946f7bb2 | ||
|
|
5724c3a09c | ||
|
|
d55464f3ab | ||
|
|
73a5a10c6d | ||
|
|
112420cb5b | ||
|
|
31823dbd53 | ||
|
|
15c39372f4 | ||
|
|
330ad3448d | ||
|
|
220fc51eb5 | ||
|
|
b754106feb | ||
|
|
05107a3e5c | ||
|
|
d516f0f602 | ||
|
|
c376d9e5c1 | ||
|
|
f09e2c8b55 | ||
|
|
dbdcde2489 | ||
|
|
636f737cd3 | ||
|
|
93c9225e8a | ||
|
|
582d1d8495 | ||
|
|
eb60473510 | ||
|
|
c35b960627 | ||
|
|
df2f503a05 | ||
|
|
c20b206ab2 | ||
|
|
5ceb140d13 | ||
|
|
ed4517703f | ||
|
|
bd3e91738e |
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
|
||||
|
||||
## Content
|
||||
|
||||
<!-- Describe shortly what has been changed -->
|
||||
|
||||
## Motivation and context
|
||||
|
||||
<!-- Provide link to the corresponding issue if applicable or explain the context -->
|
||||
|
||||
## Screenshots / GIFs
|
||||
|
||||
<!--
|
||||
|
||||
You can use a table like this to show screenshots comparison.
|
||||
Uncomment this markdown table below and edit the last line `|||`:
|
||||
|copy screenshot of before here|copy screenshot of after here|
|
||||
|
||||
|Before|After|
|
||||
|-|-|
|
||||
|||
|
||||
-->
|
||||
|
||||
## Tests
|
||||
|
||||
<!-- Explain how you tested your development -->
|
||||
|
||||
- Step 1
|
||||
- Step 2
|
||||
- Step ...
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
|
||||
- [ ] Pull request includes screenshots or videos if containing UI changes
|
||||
- [ ] Tests written for new code (and old code if feasible).
|
||||
- [ ] Linter and other CI checks pass.
|
||||
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)
|
||||
9
.github/workflows/blocked.yaml
vendored
9
.github/workflows/blocked.yaml
vendored
@@ -1,7 +1,16 @@
|
||||
name: Prevent blocked
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This workflow does not checkout code or use secrets.
|
||||
# It only reads labels to set a failure status on the PR.
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Required to fail the check on the PR
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
|
||||
55
.github/workflows/build-and-publish-docker.yaml
vendored
55
.github/workflows/build-and-publish-docker.yaml
vendored
@@ -20,10 +20,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
id-token: write # needed for login into tailscale with GitHub OIDC Token
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -34,26 +37,64 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
audience: ${{ secrets.TS_AUDIENCE }}
|
||||
tags: tag:github-actions
|
||||
|
||||
- name: Compute vault jwt role name
|
||||
id: vault-jwt-role
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get team registry token
|
||||
id: import-secrets
|
||||
uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
url: https://vault.infra.ci.i.element.dev
|
||||
role: ${{ steps.vault-jwt-role.outputs.role_name }}
|
||||
path: service-management/github-actions
|
||||
jwtGithubAudience: https://vault.infra.ci.i.element.dev
|
||||
method: jwt
|
||||
secrets: |
|
||||
services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ;
|
||||
services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
|
||||
|
||||
- name: Login to oci.element.io Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: oci-push.vpn.infra.element.io
|
||||
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
|
||||
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
oci-push.vpn.infra.element.io/element-call
|
||||
tags: ${{ inputs.docker_tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
8
.github/workflows/build-element-call.yaml
vendored
8
.github/workflows/build-element-call.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
type: string
|
||||
package:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
||||
description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk'
|
||||
required: true
|
||||
build_mode:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build Element Call
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -52,6 +54,8 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PACKAGE: ${{ inputs.package }}
|
||||
BUILD_MODE: ${{ inputs.build_mode }}
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
16
.github/workflows/build.yaml
vendored
16
.github/workflows/build.yaml
vendored
@@ -49,7 +49,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.run_id }}
|
||||
docker_tags: |
|
||||
@@ -69,3 +71,17 @@ jobs:
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build_sdk_element_call:
|
||||
# Use the embedded package vite build
|
||||
uses: ./.github/workflows/build-element-call.yaml
|
||||
with:
|
||||
package: sdk
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
8
.github/workflows/changelog-label.yml
vendored
8
.github/workflows/changelog-label.yml
vendored
@@ -1,8 +1,16 @@
|
||||
name: PR changelog label
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# This is safe because we do not use actions/checkout or execute untrusted code.
|
||||
# Using pull_request_target is necessary to allow status writes for PRs from forks.
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
20
.github/workflows/deploy-to-netlify.yaml
vendored
20
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -14,6 +14,10 @@ on:
|
||||
deployment_ref:
|
||||
required: true
|
||||
type: string
|
||||
package:
|
||||
required: true
|
||||
type: string
|
||||
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
@@ -50,7 +54,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output-full
|
||||
name: build-output-${{ inputs.package }}
|
||||
path: webapp
|
||||
|
||||
- name: Add redirects file
|
||||
@@ -58,15 +62,23 @@ jobs:
|
||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||
|
||||
- name: Add config file
|
||||
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||
|
||||
run: |
|
||||
if [ "${INPUTS_PACKAGE}" = "full" ]; then
|
||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
|
||||
else
|
||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview_sdk.json" > webapp/config.json
|
||||
fi
|
||||
env:
|
||||
INPUTS_PACKAGE: ${{ inputs.package }}
|
||||
INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }}
|
||||
INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }}
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
alias: pr${{ inputs.pr_number }}
|
||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -8,6 +8,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
33
.github/workflows/pr-deploy.yaml
vendored
33
.github/workflows/pr-deploy.yaml
vendored
@@ -1,5 +1,7 @@
|
||||
name: Deploy previews for PRs
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This is now restricted to internal PRs only using the 'if' condition below.
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
@@ -7,7 +9,14 @@ on:
|
||||
|
||||
jobs:
|
||||
prdetails:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
# Logic:
|
||||
# 1. Build must be successful
|
||||
# 2. Event must be a pull_request
|
||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
@@ -20,7 +29,7 @@ jobs:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
netlify:
|
||||
netlify-full:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
@@ -31,6 +40,24 @@ jobs:
|
||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||
package: full
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
netlify-sdk:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||
package: sdk
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -42,7 +69,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
|
||||
85
.github/workflows/publish-embedded-packages.yaml
vendored
85
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -22,8 +22,18 @@ jobs:
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
steps:
|
||||
- name: Calculate VERSION
|
||||
# We should only use the hard coded test value for a dry run
|
||||
run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV"
|
||||
# Safely store dynamic values in environment variables
|
||||
# to prevent shell injection (template-injection)
|
||||
run: |
|
||||
# The logic is executed within the shell using the env variables
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
- id: dry_run
|
||||
name: Set DRY_RUN
|
||||
# We perform a dry run for all events except releases.
|
||||
@@ -71,7 +81,9 @@ jobs:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
- name: Determine filename
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -80,9 +92,9 @@ jobs:
|
||||
name: build-output-embedded
|
||||
path: ${{ env.FILENAME_PREFIX}}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
@@ -104,6 +116,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -123,13 +137,16 @@ jobs:
|
||||
- name: Publish npm
|
||||
working-directory: embedded/web
|
||||
run: |
|
||||
npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version
|
||||
npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version
|
||||
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
|
||||
npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_android:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -143,6 +160,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -153,7 +172,7 @@ jobs:
|
||||
path: embedded/android/lib/src/main/assets/element-call
|
||||
|
||||
- name: ☕️ Setup Java
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -161,16 +180,19 @@ jobs:
|
||||
- name: Get artifact version
|
||||
# Anything that is not a final release will be tagged as a snapshot
|
||||
run: |
|
||||
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
|
||||
- name: Publish AAR
|
||||
working-directory: embedded/android
|
||||
@@ -184,7 +206,7 @@ jobs:
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_ios:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -200,6 +222,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
path: element-call
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -215,15 +238,18 @@ jobs:
|
||||
repository: element-hq/element-call-swift
|
||||
path: element-call-swift
|
||||
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Copy files
|
||||
run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift
|
||||
|
||||
- name: Get artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
|
||||
- name: Test build
|
||||
working-directory: element-call-swift
|
||||
@@ -235,17 +261,22 @@ jobs:
|
||||
git config --global user.email "ci@element.io"
|
||||
git config --global user.name "Element CI"
|
||||
git add -A
|
||||
git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}"
|
||||
git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}"
|
||||
git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}"
|
||||
git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
|
||||
|
||||
- name: Push
|
||||
working-directory: element-call-swift
|
||||
run: |
|
||||
git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_notes:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
@@ -257,9 +288,13 @@ jobs:
|
||||
steps:
|
||||
- name: Log versions
|
||||
run: |
|
||||
echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}"
|
||||
echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}"
|
||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||
echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}"
|
||||
env:
|
||||
NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
|
||||
6
.github/workflows/publish.yaml
vendored
6
.github/workflows/publish.yaml
vendored
@@ -38,9 +38,9 @@ jobs:
|
||||
name: build-output-full
|
||||
path: ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
@@ -55,7 +55,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
|
||||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -10,6 +10,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -22,7 +24,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -34,6 +36,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@@ -15,6 +15,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
|
||||
23
.github/workflows/zizmor.yml
vendored
Normal file
23
.github/workflows/zizmor.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
16
config/config_netlify_preview_sdk.json
Normal file
16
config/config_netlify_preview_sdk.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://call-unstable.ems.host",
|
||||
"server_name": "call-unstable.ems.host"
|
||||
}
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.4
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:v1.9.4
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -106,7 +106,7 @@ services:
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
|
||||
@@ -46,32 +46,32 @@ possible to support encryption.
|
||||
|
||||
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.13.1"
|
||||
android_gradle_plugin = "8.13.2"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
6
knip.ts
6
knip.ts
@@ -34,6 +34,12 @@ export default {
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// Yarn plugins are allowed to depend on packages provided by the Yarn
|
||||
// runtime. These shouldn't be listed in package.json, because plugins
|
||||
// should work before Yarn even installs dependencies for the first time.
|
||||
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
|
||||
"@yarnpkg/core",
|
||||
"@yarnpkg/parsers",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"membership_manager": "Membership Manager Error",
|
||||
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
|
||||
"no_matrix_2_authorization_service": "Your authorization service for you media server (SFU) is not on the newest version",
|
||||
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
|
||||
"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",
|
||||
@@ -250,11 +250,11 @@
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"camera_starting": "Video loading...",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
}
|
||||
|
||||
28
package.json
28
package.json
@@ -13,8 +13,9 @@
|
||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "yarn build:embedded",
|
||||
"build:embedded:development": "yarn build:embedded --mode development",
|
||||
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||
"build:sdk:development": "yarn build:sdk --mode development",
|
||||
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "yarn build:sdk",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
@@ -42,12 +43,12 @@
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
"@formatjs/intl-durationformat": "^0.9.0",
|
||||
"@formatjs/intl-durationformat": "^0.10.0",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.42.2",
|
||||
"@livekit/track-processors": "^0.6.0 || ^0.7.1",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
@@ -78,7 +79,7 @@
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
@@ -100,12 +101,12 @@
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-parser": "^9.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"knip": "^5.86.0",
|
||||
"livekit-client": "^2.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#4a75d2c92f1ac7476a6d398057b91c65054f1b80",
|
||||
"matrix-widget-api": "^1.14.0",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
@@ -117,7 +118,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^16.0.0 <16.1.0",
|
||||
"react-i18next": "^16.0.0 <16.6.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -127,17 +128,22 @@
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Check the button toolbar
|
||||
// await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "End call" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
).toBeVisible();
|
||||
|
||||
// Tab order should jump directly from header to footer, skipping media tiles
|
||||
await page.getByRole("button", { name: "Mute microphone" }).focus();
|
||||
await page.getByRole("switch", { name: "Mute microphone" }).focus();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Mute microphone" }),
|
||||
page.getByRole("switch", { name: "Mute microphone" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
|
||||
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
|
||||
// Most critically, we should be able to press the hangup button
|
||||
await page.getByRole("button", { name: "End call" }).click();
|
||||
});
|
||||
|
||||
@@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// No lobby, should start with video on
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
@@ -101,13 +98,15 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const florianMuteButton = florianFrame.getByTestId("incall_videomute");
|
||||
await florianMuteButton.click();
|
||||
const florianVideoButton = florianFrame.getByRole("switch", {
|
||||
name: /video/,
|
||||
});
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Stop video");
|
||||
await expect(florianVideoButton).toBeChecked();
|
||||
await florianVideoButton.click();
|
||||
// Now the button should indicate we can start video
|
||||
await expect(florianMuteButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Start video$/,
|
||||
);
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Start video");
|
||||
await expect(florianVideoButton).not.toBeChecked();
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
|
||||
73
playwright/widget/pip-call-button-interaction.test.ts
Normal file
73
playwright/widget/pip-call-button-interaction.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
|
||||
const callRoom = "CallRoom";
|
||||
await TestHelpers.createRoom("CallRoom", valere.page);
|
||||
|
||||
await TestHelpers.createRoom("OtherRoom", valere.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
// wait a bit so that the PIP has rendered
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
|
||||
|
||||
// We should see the PIP overlay
|
||||
const iFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
{
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const audioBtn = iFrame.getByRole("switch", { name: /microphone/ });
|
||||
const videoBtn = iFrame.getByRole("switch", { name: /video/ });
|
||||
await expect(
|
||||
iFrame.getByRole("button", { name: "End call" }),
|
||||
).toBeVisible();
|
||||
await expect(audioBtn).toBeVisible();
|
||||
await expect(videoBtn).toBeVisible();
|
||||
await expect(audioBtn).toHaveAccessibleName("Mute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Stop video");
|
||||
await expect(videoBtn).toBeChecked();
|
||||
|
||||
await videoBtn.click();
|
||||
await audioBtn.click();
|
||||
|
||||
// stop hovering on any of the buttons
|
||||
await iFrame.getByTestId("videoTile").hover();
|
||||
|
||||
await expect(audioBtn).toHaveAccessibleName("Unmute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Start video");
|
||||
await expect(videoBtn).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
72
playwright/widget/pip-call.test.ts
Normal file
72
playwright/widget/pip-call.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
|
||||
const callRoom = "TeamRoom";
|
||||
await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.createRoom("DoubleTask", valere.page);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(callRoom, timo.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
const frame = timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// check that the video is on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
|
||||
// We should see the PIP overlay
|
||||
await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible();
|
||||
|
||||
{
|
||||
// wait a bit so that the PIP has rendered the video
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(1);
|
||||
|
||||
const pipVideo = videoElements[0];
|
||||
await expect(pipVideo).toHaveCSS("object-fit", "cover");
|
||||
}
|
||||
});
|
||||
@@ -276,4 +276,16 @@ export class TestHelpers {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
* @param roomName - The name of the room to switch to
|
||||
*/
|
||||
public static async switchToRoomNamed(
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,34 +51,36 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -90,10 +92,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
@@ -142,34 +141,30 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -181,10 +176,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SDK mode
|
||||
# SDK mode (EXPERIMENTAL)
|
||||
|
||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||
|
||||
|
||||
@@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details.
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { scan } from "rxjs";
|
||||
|
||||
import { widget as _widget } from "../src/widget";
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
|
||||
if (!_widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
export const widget = _widget;
|
||||
|
||||
export const tryMakeSticky = (): void => {
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
|
||||
108
sdk/main.ts
108
sdk/main.ts
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
@@ -30,8 +32,8 @@ import {
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
@@ -50,14 +52,12 @@ import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import {
|
||||
currentAndPrev,
|
||||
logger,
|
||||
TEXT_LK_TOPIC,
|
||||
tryMakeSticky,
|
||||
widget,
|
||||
} from "./helper";
|
||||
import { ElementWidgetActions } from "../src/widget";
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
@@ -68,7 +68,13 @@ interface MatrixRTCSdk {
|
||||
join: () => void;
|
||||
/** @throws on leave errors */
|
||||
leave: () => void;
|
||||
data$: Observable<{ sender: string; data: string }>;
|
||||
/**
|
||||
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
||||
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
||||
* A new sdk needs to be created via createMatrixRTCSdk.
|
||||
*/
|
||||
stop: () => void;
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/**
|
||||
* flattened list of members
|
||||
*/
|
||||
@@ -79,32 +85,54 @@ interface MatrixRTCSdk {
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
/**
|
||||
* flattened local members
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | null;
|
||||
} | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
logger.info("Hello");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
initializeWidget(application, true);
|
||||
const widget = _widget;
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
logger.info("client created");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// url params
|
||||
const { roomId } = getUrlParams();
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
|
||||
// rtc session
|
||||
const slot = { application, id };
|
||||
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
|
||||
rtcSessionManager.start();
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope);
|
||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
});
|
||||
const slot = { application, id };
|
||||
const rtcSession = new MatrixRTCSession(client, room, slot);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
@@ -117,8 +145,9 @@ export async function createMatrixRTCSdk(
|
||||
constant({ supported: false, processor: undefined }),
|
||||
);
|
||||
logger.info("CallViewModelCreated");
|
||||
|
||||
// create data listener
|
||||
const data$ = new Subject<{ sender: string; data: string }>();
|
||||
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||
|
||||
const lkTextStreamHandlerFunction = async (
|
||||
reader: TextStreamReader,
|
||||
@@ -140,7 +169,7 @@ export async function createMatrixRTCSdk(
|
||||
if (participants && participants.includes(participantInfo.identity)) {
|
||||
const text = await reader.readAll();
|
||||
logger.info(`Received text: ${text}`);
|
||||
data$.next({ sender: participantInfo.identity, data: text });
|
||||
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||
} else {
|
||||
logger.warn(
|
||||
"Received text from unknown participant",
|
||||
@@ -230,6 +259,16 @@ export async function createMatrixRTCSdk(
|
||||
}
|
||||
};
|
||||
|
||||
const sendRoomMessage = async (message: string): Promise<void> => {
|
||||
const messageString = JSON.stringify(message);
|
||||
logger.info("try sending to room: ", messageString);
|
||||
try {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
} catch (e) {
|
||||
logger.error("failed sending to room: ", messageString, e);
|
||||
}
|
||||
};
|
||||
|
||||
// after hangup gets called
|
||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||
@@ -257,9 +296,6 @@ export async function createMatrixRTCSdk(
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
void scheduleWidgetCloseOnLeave();
|
||||
|
||||
// actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast)
|
||||
scope.end();
|
||||
});
|
||||
|
||||
logger.info("createMatrixRTCSdk done");
|
||||
@@ -267,15 +303,40 @@ export async function createMatrixRTCSdk(
|
||||
return {
|
||||
join: (): void => {
|
||||
// first lets try making the widget sticky
|
||||
tryMakeSticky();
|
||||
if (sticky) tryMakeSticky(widget);
|
||||
callViewModel.join();
|
||||
},
|
||||
leave: (): void => {
|
||||
callViewModel.hangup();
|
||||
callViewModel.leave();
|
||||
},
|
||||
stop: (): void => {
|
||||
leaveSubs.unsubscribe();
|
||||
livekitRoomItemsSub.unsubscribe();
|
||||
scope.end();
|
||||
},
|
||||
data$,
|
||||
localMember$: scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
tap((member) =>
|
||||
logger.info("localMatrixLivekitMember$ next: ", member),
|
||||
),
|
||||
switchMap((member) => {
|
||||
if (member === null) return of(null);
|
||||
return combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
tap((member) => logger.info("localMember$ next: ", member)),
|
||||
),
|
||||
),
|
||||
connected$: callViewModel.connected$,
|
||||
members$: scope.behavior(
|
||||
callViewModel.matrixLivekitMembers$.pipe(
|
||||
@@ -302,5 +363,6 @@ export async function createMatrixRTCSdk(
|
||||
[],
|
||||
),
|
||||
sendData,
|
||||
sendRoomMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,8 +473,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
posthogApiKey: parser.getParam("posthogApiKey"),
|
||||
posthogUserId:
|
||||
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
|
||||
posthogUserId: parser.getParam("posthogUserId"),
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
|
||||
@@ -22,23 +22,25 @@ import {
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
|
||||
const label = muted
|
||||
? t("unmute_microphone_button_label")
|
||||
: t("mute_microphone_button_label");
|
||||
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("mute_microphone_button_label")
|
||||
: t("unmute_microphone_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -46,23 +48,25 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
};
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
|
||||
const label = muted
|
||||
? t("start_video_button_label")
|
||||
: t("stop_video_button_label");
|
||||
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("stop_video_button_label")
|
||||
: t("start_video_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -71,6 +75,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -88,13 +93,19 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
iconOnly
|
||||
Icon={ShareScreenSolidIcon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
@@ -105,7 +116,6 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
aria-label={t("hangup_button_label")}
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
@@ -114,9 +124,10 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
|
||||
props,
|
||||
) => {
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,8 @@ import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local, localRtcMember } from "../utils/test-fixtures";
|
||||
import { type MockRTCSession } from "../utils/test";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
|
||||
|
||||
@@ -166,6 +166,7 @@ export function ReactionPopupMenu({
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
identifier: string;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
|
||||
@@ -27,7 +27,13 @@ interface Props<M, R extends HTMLElement> {
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||
) => void
|
||||
> | null;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
model: M;
|
||||
Tile: ComponentType<TileProps<M, R>>;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -115,6 +116,8 @@ export class Initializer {
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
initializeWidget();
|
||||
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
||||
|
||||
@@ -28,8 +28,15 @@ import {
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ export type OpenIDClientParts = Pick<
|
||||
* @param membership Our own membership identity parts used to send to jwt service.
|
||||
* @param serviceUrl The URL of the livekit SFU service
|
||||
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
|
||||
* @param opts Additional options to modify which endpoint with which data will be used to aquire the jwt token.
|
||||
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatination
|
||||
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
|
||||
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatenation
|
||||
* instead of a hash.
|
||||
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
|
||||
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";
|
||||
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vitest.mock("livekit-client/e2ee-worker?worker");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
|
||||
@@ -65,6 +65,7 @@ Please see LICENSE in the repository root for full details.
|
||||
.footer.overlay.hidden {
|
||||
display: grid;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
@@ -107,22 +108,9 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
display: none;
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +120,27 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
@media (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PIP custom css */
|
||||
@media (max-height: 400px) {
|
||||
.shareScreen {
|
||||
display: flex;
|
||||
}
|
||||
.footer {
|
||||
padding-block-start: var(--cpd-space-3x);
|
||||
padding-block-end: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.hoisted(
|
||||
() =>
|
||||
(global.ImageData = class MockImageData {
|
||||
|
||||
@@ -9,8 +9,8 @@ import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
type TouchEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -110,8 +110,6 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState"
|
||||
@@ -334,40 +332,20 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
) : null;
|
||||
}, [ringOverlay]);
|
||||
|
||||
// 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
|
||||
// in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility
|
||||
// Instead we have to watch for sufficiently fast touch events.
|
||||
const touchStart = useRef<number | null>(null);
|
||||
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
|
||||
const onTouchEnd = useCallback(() => {
|
||||
const start = touchStart.current;
|
||||
if (start !== null && Date.now() - start <= maxTapDurationMs)
|
||||
vm.tapScreen();
|
||||
touchStart.current = null;
|
||||
}, [vm]);
|
||||
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
|
||||
|
||||
// We also need to tell the footer controls to prevent touch events from
|
||||
// bubbling up, or else the footer will be dismissed before a click/change
|
||||
// event can be registered on the control
|
||||
const onControlsTouchEnd = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
// Somehow applying pointer-events: none to the controls when the footer
|
||||
// is hidden is not enough to stop clicks from happening as the footer
|
||||
// becomes visible, so we check manually whether the footer is shown
|
||||
if (showFooter) {
|
||||
e.stopPropagation();
|
||||
vm.tapControls();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
const onViewClick = useCallback(
|
||||
(e: ReactMouseEvent) => {
|
||||
if (
|
||||
(e.nativeEvent as PointerEvent).pointerType === "touch" &&
|
||||
// If an interactive element was tapped, don't count this as a tap on the screen
|
||||
(e.target as Element).closest?.("button, input") === null
|
||||
)
|
||||
vm.tapScreen();
|
||||
},
|
||||
[vm, showFooter],
|
||||
[vm],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
(e: ReactPointerEvent) => {
|
||||
if (e.pointerType === "mouse") vm.hoverScreen();
|
||||
},
|
||||
[vm],
|
||||
@@ -606,8 +584,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
vm={layout.spotlight}
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
targetWidth={gridBounds.height}
|
||||
targetHeight={gridBounds.width}
|
||||
targetWidth={gridBounds.width}
|
||||
targetHeight={gridBounds.height}
|
||||
showIndicators={false}
|
||||
focusable={!contentObscured}
|
||||
aria-hidden={contentObscured}
|
||||
@@ -662,20 +640,21 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const buttonSize = layout.type === "pip" ? "sm" : "lg";
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
muted={!audioEnabled}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleAudio === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
muted={!videoEnabled}
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleVideo === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
@@ -683,11 +662,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (vm.toggleScreenSharing !== null) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
size={buttonSize}
|
||||
key="share_screen"
|
||||
className={styles.shareScreen}
|
||||
enabled={sharingScreen}
|
||||
onClick={vm.toggleScreenSharing}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
@@ -695,30 +674,30 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (supportsReactions) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
vm={vm}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (layout.type !== "pip")
|
||||
buttons.push(
|
||||
<SettingsButton
|
||||
size={buttonSize}
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
<EndCallButton
|
||||
size={buttonSize}
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
vm.hangup();
|
||||
}}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
@@ -751,7 +730,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -760,12 +738,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
return (
|
||||
// The onClick handler here exists to control the visibility of the footer,
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
ref={containerRef}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchCancel}
|
||||
onClick={onViewClick}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ChangeEvent, type FC, type TouchEvent, useCallback } from "react";
|
||||
import { type ChangeEvent, type FC, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
@@ -22,15 +22,9 @@ interface Props {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
className?: string;
|
||||
onTouchEnd?: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
export const LayoutToggle: FC<Props> = ({
|
||||
layout,
|
||||
setLayout,
|
||||
className,
|
||||
onTouchEnd,
|
||||
}) => {
|
||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange = useCallback(
|
||||
@@ -47,7 +41,6 @@ export const LayoutToggle: FC<Props> = ({
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||
@@ -58,7 +51,6 @@ export const LayoutToggle: FC<Props> = ({
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
|
||||
@@ -230,12 +230,12 @@ export const LobbyView: FC<Props> = ({
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<div className={inCallStyles.buttons}>
|
||||
<MicButton
|
||||
muted={!audioEnabled}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={!videoEnabled}
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
|
||||
function TestComponent({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
bobRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
|
||||
@@ -285,14 +285,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Unmute microphone"
|
||||
aria-labelledby="_r_8_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -309,14 +309,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Start video"
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -354,7 +354,6 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="End call"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||
data-kind="primary"
|
||||
|
||||
@@ -5,13 +5,16 @@ 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, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
|
||||
// Mock url params hook to avoid environment-dependent snapshot churn.
|
||||
vi.mock("../UrlParams", () => ({
|
||||
useUrlParams: (): { mocked: boolean; answer: number } => ({
|
||||
@@ -20,6 +23,14 @@ vi.mock("../UrlParams", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
|
||||
vi.mock("../livekit/openIDSFU", () => ({
|
||||
getSFUConfigWithOpenID: vi.fn().mockResolvedValue({
|
||||
url: "mock-url",
|
||||
jwt: "mock-jwt",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Provide a minimal mock of a Livekit Room structure used by the component.
|
||||
function createMockLivekitRoom(
|
||||
wsUrl: string,
|
||||
@@ -86,6 +97,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
const { container } = render(
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId={"#room:example.org"}
|
||||
livekitRooms={livekitRooms}
|
||||
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
|
||||
/>,
|
||||
@@ -99,4 +111,141 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
describe("custom livekit url", () => {
|
||||
afterEach(() => {
|
||||
customLivekitUrlSetting.setValue(null);
|
||||
});
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
|
||||
getCrypto: () => ({ getVersion: (): string => "x" }),
|
||||
getUserId: () => "@u:hs",
|
||||
getDeviceId: () => "DEVICE",
|
||||
} as unknown as MatrixClient;
|
||||
it("will not update custom livekit url without roomId", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url without text in input", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url when pressing cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const cancelButton = screen.getByRole("button", {
|
||||
name: "Reset overwrite",
|
||||
});
|
||||
await user.click(cancelButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will update custom livekit url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"wss://example.livekit.valid",
|
||||
"#testRoom",
|
||||
);
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(
|
||||
"wss://example.livekit.valid",
|
||||
);
|
||||
});
|
||||
it("will show error on invalid url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
(getSFUConfigWithOpenID as Mock).mockImplementation(() => {
|
||||
throw new Error("Invalid URL");
|
||||
});
|
||||
await user.click(saveButton);
|
||||
expect(
|
||||
screen.getByText("invalid URL (did not update)"),
|
||||
).toBeInTheDocument();
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
EditInPlace,
|
||||
ErrorMessage,
|
||||
Root as Form,
|
||||
Heading,
|
||||
HelpMessage,
|
||||
@@ -45,9 +46,11 @@ import {
|
||||
} from "./settings";
|
||||
import styles from "./DeveloperSettingsTab.module.css";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
@@ -60,6 +63,7 @@ interface Props {
|
||||
export const DeveloperSettingsTab: FC<Props> = ({
|
||||
client,
|
||||
livekitRooms,
|
||||
roomId,
|
||||
env,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,6 +101,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
|
||||
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
|
||||
useState<string | null>(null);
|
||||
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
|
||||
customLivekitUrlSetting,
|
||||
);
|
||||
@@ -234,14 +240,36 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
savingLabel={t("developer_mode.custom_livekit_url.saving")}
|
||||
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
|
||||
onSave={useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
setCustomLivekitUrl(
|
||||
customLivekitUrlTextBuffer === ""
|
||||
? null
|
||||
: customLivekitUrlTextBuffer,
|
||||
);
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
if (
|
||||
roomId === undefined ||
|
||||
customLivekitUrlTextBuffer === "" ||
|
||||
customLivekitUrlTextBuffer === null
|
||||
) {
|
||||
setCustomLivekitUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
|
||||
if (userId === null || deviceId === null) {
|
||||
throw new Error("Invalid user or device ID");
|
||||
}
|
||||
await getSFUConfigWithOpenID(
|
||||
client,
|
||||
{ userId, deviceId, memberId: "" },
|
||||
customLivekitUrlTextBuffer,
|
||||
roomId,
|
||||
);
|
||||
setCustomLivekitUrlUpdateError(null);
|
||||
setCustomLivekitUrl(customLivekitUrlTextBuffer);
|
||||
} catch {
|
||||
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
|
||||
}
|
||||
},
|
||||
[setCustomLivekitUrl, customLivekitUrlTextBuffer],
|
||||
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
|
||||
)}
|
||||
value={customLivekitUrlTextBuffer ?? ""}
|
||||
onChange={useCallback(
|
||||
@@ -256,7 +284,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
},
|
||||
[setCustomLivekitUrl],
|
||||
)}
|
||||
/>
|
||||
serverInvalid={customLivekitUrlUpdateError !== null}
|
||||
>
|
||||
{customLivekitUrlUpdateError !== null && (
|
||||
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
|
||||
)}
|
||||
</EditInPlace>
|
||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||
{t("developer_mode.matrixRTCMode.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
env={import.meta.env}
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
roomId={roomId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
34
src/settings/rageshake.test.ts
Normal file
34
src/settings/rageshake.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { init as initRageshake } from "./rageshake";
|
||||
|
||||
it("Logger should not crash if JSON.stringify fails", async () => {
|
||||
// JSON.stringify can throw. We want to make sure that the logger can handle this gracefully.
|
||||
await initRageshake();
|
||||
|
||||
const bigIntObj = { n: 1n };
|
||||
const notStringifiable = {
|
||||
bigIntObj,
|
||||
};
|
||||
// @ts-expect-error - we want to create an object that cannot be stringified
|
||||
notStringifiable.foo = notStringifiable; // circular reference
|
||||
|
||||
// ensure this cannot be stringified
|
||||
expect(() => JSON.stringify(notStringifiable)).toThrow();
|
||||
|
||||
expect(() =>
|
||||
global.mx_rage_logger.log(
|
||||
1,
|
||||
"test",
|
||||
"This is a test message",
|
||||
notStringifiable,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
@@ -75,7 +75,14 @@ class ConsoleLogger extends EventEmitter {
|
||||
} else if (arg instanceof Error) {
|
||||
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
||||
} else if (typeof arg === "object") {
|
||||
return JSON.stringify(arg, getCircularReplacer());
|
||||
try {
|
||||
return JSON.stringify(arg, getCircularReplacer());
|
||||
} catch {
|
||||
// Stringify can fail if the object has circular references or if
|
||||
// there is a bigInt.
|
||||
// Did happen even with our `getCircularReplacer`. In this case, just log
|
||||
return "<$ failed to serialize object $>";
|
||||
}
|
||||
} else {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@@ -34,15 +34,20 @@ export class Setting<T> {
|
||||
|
||||
this._value$ = new BehaviorSubject(initialValue);
|
||||
this.value$ = this._value$;
|
||||
this._lastUpdateReason$ = new BehaviorSubject<string | null>(null);
|
||||
this.lastUpdateReason$ = this._lastUpdateReason$;
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||
public readonly value$: Behavior<T>;
|
||||
public readonly lastUpdateReason$: Behavior<string | null>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
public readonly setValue = (value: T, reason?: string): void => {
|
||||
this._value$.next(value);
|
||||
this._lastUpdateReason$.next(reason ?? null);
|
||||
localStorage.setItem(this.key, JSON.stringify(value));
|
||||
};
|
||||
public readonly getValue = (): T => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ICallNotifyContent,
|
||||
type IRTCNotificationContent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
@@ -25,23 +21,23 @@ import {
|
||||
localRtcMember,
|
||||
} from "../../utils/test-fixtures";
|
||||
import {
|
||||
type CallNotificationWrapper,
|
||||
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 {
|
||||
): CallNotificationWrapper {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
} as unknown as CallNotificationWrapper;
|
||||
}
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
@@ -54,7 +50,7 @@ describe("waitForCallPickup$", () => {
|
||||
behavior("a", { a: [] }).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif1", 30), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -86,7 +82,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("5ms a", {
|
||||
a: [mockRingEvent("$notif2", 100), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", 100),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -115,7 +111,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("20ms a", {
|
||||
a: [mockRingEvent("$notif2", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", 50),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -142,7 +138,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif2", undefined), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", undefined),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -171,7 +167,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif5", 30), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif5", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -210,7 +206,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl1", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$decl1", 50),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
@@ -254,7 +250,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl", 20), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$decl", 20),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
@@ -305,7 +301,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$right", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$right", 50),
|
||||
}),
|
||||
receivedDecline$: hot("20ms d", {
|
||||
d: [
|
||||
|
||||
@@ -7,9 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
export type CallPickupState =
|
||||
| "unknown"
|
||||
@@ -46,9 +47,11 @@ export type CallPickupState =
|
||||
| "decline"
|
||||
| "success"
|
||||
| null;
|
||||
export type CallNotificationWrapper = Parameters<
|
||||
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
|
||||
>;
|
||||
|
||||
export type CallNotificationWrapper = {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent;
|
||||
|
||||
export function createSentCallNotification$(
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
@@ -80,6 +83,7 @@ export interface Props {
|
||||
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns two observables:
|
||||
* `callPickupState$` The current call pickup state of the call.
|
||||
@@ -140,12 +144,12 @@ export function createCallNotificationLifecycle$({
|
||||
scope.behavior(
|
||||
sentCallNotification$.pipe(
|
||||
filter(
|
||||
(newAndLegacyEvents) =>
|
||||
(notificationEventArgs: CallNotificationWrapper | null) =>
|
||||
// only care about new events (legacy do not have decline pattern)
|
||||
newAndLegacyEvents?.[0].notification_type === "ring",
|
||||
notificationEventArgs?.notification_type === "ring",
|
||||
),
|
||||
map((e) => e as CallNotificationWrapper),
|
||||
switchMap(([notificationEvent]) => {
|
||||
switchMap((notificationEvent) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Status,
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type ICallNotifyContent,
|
||||
MatrixRTCSessionEvent,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -62,6 +61,9 @@ import { getValue } from "../../utils/observable.ts";
|
||||
import { type Behavior, constant } from "../Behavior.ts";
|
||||
import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
|
||||
import { MatrixRTCMode } from "../../settings/settings.ts";
|
||||
import { initializeWidget } from "../../widget.ts";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
vi.mock("rxjs", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
@@ -229,10 +231,6 @@ function mockRingEvent(
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
}
|
||||
|
||||
// The app doesn't really care about the content of these legacy events, we just
|
||||
// need a value to fill in for them when emitting notifications
|
||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||
|
||||
describe.each([
|
||||
[MatrixRTCMode.Legacy],
|
||||
[MatrixRTCMode.Compatibility],
|
||||
@@ -1106,7 +1104,6 @@ describe.each([
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1148,7 +1145,6 @@ describe.each([
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif2", 100),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
d: () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
@@ -51,15 +51,9 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
type MediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "../MediaViewModel";
|
||||
import {
|
||||
accumulate,
|
||||
createToggle$,
|
||||
filterBehavior,
|
||||
generateItem,
|
||||
generateItems,
|
||||
pauseWhen,
|
||||
} from "../../utils/observable";
|
||||
@@ -69,7 +63,7 @@ import {
|
||||
playReactionsSound,
|
||||
showReactions,
|
||||
} from "../../settings/settings";
|
||||
import { isFirefox } from "../../Platform";
|
||||
import { isFirefox, platform } from "../../Platform";
|
||||
import { setPipEnabled$ } from "../../controls";
|
||||
import { TileStore } from "../TileStore";
|
||||
import { gridLikeLayout } from "../GridLikeLayout";
|
||||
@@ -91,8 +85,6 @@ import { type MuteStates } from "../MuteStates";
|
||||
import { getUrlParams } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import { UserMedia } from "../UserMedia.ts";
|
||||
import { ScreenShare } from "../ScreenShare.ts";
|
||||
import {
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
@@ -103,7 +95,7 @@ import {
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
} from "../layout-types.ts";
|
||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||
import {
|
||||
createLocalMembership$,
|
||||
@@ -143,6 +135,14 @@ import {
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||
import {
|
||||
createWrappedUserMedia,
|
||||
type MediaItem,
|
||||
type WrappedUserMediaViewModel,
|
||||
} from "../media/MediaItem.ts";
|
||||
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
||||
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
@@ -192,7 +192,6 @@ interface LayoutScanState {
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
export type LivekitRoomItem = {
|
||||
livekitRoom: LivekitRoom;
|
||||
participants: string[];
|
||||
@@ -217,15 +216,23 @@ export interface CallViewModel {
|
||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
||||
>;
|
||||
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope.
|
||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||
* - by ending the scope
|
||||
* - or calling requestDisconnect
|
||||
*
|
||||
* TODO: it seems more reasonable to add a leave() method (that calls requestDisconnect) that will then update leave$ and remove the hangup pattern
|
||||
*/
|
||||
leave$: Observable<"user" | AutoLeaveReason>;
|
||||
/** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */
|
||||
/** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
|
||||
hangup: () => void;
|
||||
|
||||
// joining
|
||||
join: () => void;
|
||||
|
||||
/**
|
||||
* calls requestDisconnect. The async leave state can than be observed via connected$
|
||||
*/
|
||||
leave: () => void;
|
||||
// screen sharing
|
||||
/**
|
||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||
@@ -274,7 +281,6 @@ export interface CallViewModel {
|
||||
allConnections$: Behavior<ConnectionManagerData>;
|
||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||
userMedia$: Behavior<UserMedia[]>;
|
||||
/** use the layout instead, this is just for the sdk export. */
|
||||
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
|
||||
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
|
||||
@@ -325,10 +331,6 @@ export interface CallViewModel {
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
|
||||
// media view models and layout
|
||||
grid$: Behavior<UserMediaViewModel[]>;
|
||||
spotlight$: Behavior<MediaViewModel[]>;
|
||||
pip$: Behavior<UserMediaViewModel | null>;
|
||||
/**
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
@@ -436,38 +438,42 @@ export function createCallViewModel$(
|
||||
memberId: uuidv4(),
|
||||
};
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
// The type of reemitted event includes the original emitted as the second arg.
|
||||
) as Observable<[string | undefined, IMembershipManager]>
|
||||
).pipe(map(([delayId]) => delayId ?? null)),
|
||||
matrixRTCSession.delayId ?? null,
|
||||
),
|
||||
roomId: matrixRoom.roomId,
|
||||
forceJwtEndpoint$: scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
map((v) =>
|
||||
v === MatrixRTCMode.Matrix_2_0
|
||||
? JwtEndpointVersion.Matrix_2_0
|
||||
: JwtEndpointVersion.Legacy,
|
||||
),
|
||||
const localTransport$ = scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
generateItem(
|
||||
"CallViewModel localTransport$",
|
||||
// Re-create LocalTransport whenever the mode changes
|
||||
(mode) => ({ keys: [mode], data: undefined }),
|
||||
(scope, _data$, mode) =>
|
||||
createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
// The type of reemitted event includes the original emitted as the second arg.
|
||||
) as Observable<[string | undefined, IMembershipManager]>
|
||||
).pipe(map(([delayId]) => delayId ?? null)),
|
||||
matrixRTCSession.delayId ?? null,
|
||||
),
|
||||
roomId: matrixRoom.roomId,
|
||||
forceJwtEndpoint:
|
||||
mode === MatrixRTCMode.Matrix_2_0
|
||||
? JwtEndpointVersion.Matrix_2_0
|
||||
: JwtEndpointVersion.Legacy,
|
||||
useOldestMember: mode === MatrixRTCMode.Legacy,
|
||||
}),
|
||||
),
|
||||
),
|
||||
useOldestMember$: scope.behavior(
|
||||
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
const connectionFactory = new ECConnectionFactory(
|
||||
client,
|
||||
matrixRoom.roomId,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
livekitKeyProvider,
|
||||
@@ -482,6 +488,7 @@ export function createCallViewModel$(
|
||||
connectionFactory: connectionFactory,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport$.pipe(
|
||||
switchMap((t) => t.active$),
|
||||
catchError((e: unknown) => {
|
||||
logger.info(
|
||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||
@@ -496,12 +503,13 @@ export function createCallViewModel$(
|
||||
ownMembershipIdentity,
|
||||
});
|
||||
|
||||
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
});
|
||||
const matrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>> =
|
||||
createMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
});
|
||||
|
||||
const connectOptions$ = scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
@@ -514,14 +522,14 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope: scope,
|
||||
scope,
|
||||
homeserverConnected: createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
matrixRTCSession,
|
||||
),
|
||||
muteStates: muteStates,
|
||||
joinMatrixRTC: (transport: LivekitTransport) => {
|
||||
muteStates,
|
||||
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
||||
return enterRTCSession(
|
||||
matrixRTCSession,
|
||||
ownMembershipIdentity,
|
||||
@@ -540,9 +548,11 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
},
|
||||
connectionManager: connectionManager,
|
||||
matrixRTCSession: matrixRTCSession,
|
||||
localTransport$: localTransport$,
|
||||
connectionManager,
|
||||
matrixRTCSession,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport$.pipe(switchMap((t) => t.advertised$)),
|
||||
),
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
@@ -704,7 +714,7 @@ export function createCallViewModel$(
|
||||
/**
|
||||
* List of user media (camera feeds) that we want tiles for.
|
||||
*/
|
||||
const userMedia$ = scope.behavior<UserMedia[]>(
|
||||
const userMedia$ = scope.behavior<WrappedUserMediaViewModel[]>(
|
||||
combineLatest([
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$,
|
||||
@@ -713,6 +723,7 @@ export function createCallViewModel$(
|
||||
// Generate a collection of MediaItems from the list of expected (whether
|
||||
// present or missing) LiveKit participants.
|
||||
generateItems(
|
||||
"CallViewModel userMedia$",
|
||||
function* ([
|
||||
localMatrixLivekitMember,
|
||||
matrixLivekitMembers,
|
||||
@@ -749,36 +760,35 @@ export function createCallViewModel$(
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, _, dup, mediaId, userId, participant, connection$, rtcId) => {
|
||||
const livekitRoom$ = scope.behavior(
|
||||
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||
);
|
||||
const focusUrl$ = scope.behavior(
|
||||
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||
);
|
||||
const displayName$ = scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
);
|
||||
|
||||
return new UserMedia(
|
||||
scope,
|
||||
`${mediaId}:${dup}`,
|
||||
(scope, _, dup, mediaId, userId, participant, connection$, rtcId) =>
|
||||
createWrappedUserMedia(scope, {
|
||||
id: `${mediaId}:${dup}`,
|
||||
userId,
|
||||
rtcId,
|
||||
rtcBackendIdentity: rtcId,
|
||||
participant,
|
||||
options.encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
encryptionSystem: options.encryptionSystem,
|
||||
livekitRoom$: scope.behavior(
|
||||
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||
),
|
||||
focusUrl$: scope.behavior(
|
||||
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||
),
|
||||
mediaDevices,
|
||||
localMembership.reconnecting$,
|
||||
displayName$,
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
reactions$.pipe(map((v) => v[mediaId] ?? undefined)),
|
||||
);
|
||||
},
|
||||
pretendToBeDisconnected$: localMembership.reconnecting$,
|
||||
displayName$: scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
handRaised$: scope.behavior(
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
),
|
||||
reaction$: scope.behavior(
|
||||
reactions$.pipe(map((v) => v[mediaId] ?? undefined)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -803,11 +813,9 @@ export function createCallViewModel$(
|
||||
/**
|
||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||
*/
|
||||
const screenShares$ = scope.behavior<ScreenShare[]>(
|
||||
const screenShares$ = scope.behavior<ScreenShareViewModel[]>(
|
||||
mediaItems$.pipe(
|
||||
map((mediaItems) =>
|
||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||
),
|
||||
map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -870,39 +878,39 @@ export function createCallViewModel$(
|
||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||
).pipe(scope.share);
|
||||
|
||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||
userMedia$.pipe(
|
||||
switchMap((mediaItems) =>
|
||||
mediaItems.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
mediaItems.map((m) =>
|
||||
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
m.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
|
||||
(prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.vm.local)?.[0]);
|
||||
},
|
||||
null,
|
||||
),
|
||||
map((speaker) => speaker?.vm ?? null),
|
||||
scan<
|
||||
(readonly [UserMediaViewModel, boolean])[],
|
||||
UserMediaViewModel | undefined,
|
||||
undefined
|
||||
>((prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.local)?.[0]);
|
||||
}, undefined),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -916,7 +924,7 @@ export function createCallViewModel$(
|
||||
return bins.length === 0
|
||||
? of([])
|
||||
: combineLatest(bins, (...bins) =>
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
@@ -926,9 +934,7 @@ export function createCallViewModel$(
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0) {
|
||||
return of(screenShares.map((m) => m.vm));
|
||||
}
|
||||
if (screenShares.length > 0) return of(screenShares);
|
||||
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
@@ -938,7 +944,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const pip$ = scope.behavior<UserMediaViewModel | null>(
|
||||
const pip$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||
combineLatest([
|
||||
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
|
||||
screenShares$,
|
||||
@@ -950,28 +956,17 @@ export function createCallViewModel$(
|
||||
return spotlightSpeaker$;
|
||||
}
|
||||
if (!spotlight || spotlight.local) {
|
||||
return of(null);
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
const localUserMedia = mediaItems.find(
|
||||
(m) => m.vm instanceof LocalUserMediaViewModel,
|
||||
) as UserMedia | undefined;
|
||||
|
||||
const localUserMediaViewModel = localUserMedia?.vm as
|
||||
| LocalUserMediaViewModel
|
||||
| undefined;
|
||||
|
||||
if (!localUserMediaViewModel) {
|
||||
return of(null);
|
||||
(m) => m.type === "user" && m.local,
|
||||
);
|
||||
if (!localUserMedia) {
|
||||
return of(undefined);
|
||||
}
|
||||
return localUserMediaViewModel.alwaysShow$.pipe(
|
||||
map((alwaysShow) => {
|
||||
if (alwaysShow) {
|
||||
return localUserMediaViewModel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
return localUserMedia.alwaysShow$.pipe(
|
||||
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -980,7 +975,7 @@ export function createCallViewModel$(
|
||||
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
||||
spotlight$.pipe(
|
||||
map((spotlight) =>
|
||||
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
||||
spotlight.some((vm) => vm.type === "screen share" && !vm.local),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1021,8 +1016,10 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
const spotlightExpandedToggle$ = new Subject<void>();
|
||||
const spotlightExpanded$ = scope.behavior<boolean>(
|
||||
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
||||
const spotlightExpanded$ = createToggle$(
|
||||
scope,
|
||||
false,
|
||||
spotlightExpandedToggle$,
|
||||
);
|
||||
|
||||
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||
@@ -1035,7 +1032,7 @@ export function createCallViewModel$(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
grid,
|
||||
@@ -1067,12 +1064,8 @@ export function createCallViewModel$(
|
||||
mediaItems$.pipe(
|
||||
map((mediaItems) => {
|
||||
if (mediaItems.length !== 2) return null;
|
||||
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
|
||||
| LocalUserMediaViewModel
|
||||
| undefined;
|
||||
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
|
||||
| RemoteUserMediaViewModel
|
||||
| undefined;
|
||||
const local = mediaItems.find((vm) => vm.type === "user" && vm.local);
|
||||
const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local);
|
||||
// There might not be a remote tile if there are screen shares, or if
|
||||
// only the local user is in the call and they're using the duplicate
|
||||
// tiles option
|
||||
@@ -1120,7 +1113,7 @@ export function createCallViewModel$(
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
@@ -1227,7 +1220,7 @@ export function createCallViewModel$(
|
||||
// screen sharing feeds are in the spotlight we still need them.
|
||||
return l.spotlight.media$.pipe(
|
||||
map((models: MediaViewModel[]) =>
|
||||
models.some((m) => m instanceof ScreenShareViewModel),
|
||||
models.some((m) => m.type === "screen share"),
|
||||
),
|
||||
);
|
||||
// In expanded spotlight layout, the active speaker is always shown in
|
||||
@@ -1278,7 +1271,7 @@ export function createCallViewModel$(
|
||||
switchMap((mode) => {
|
||||
switch (mode) {
|
||||
case "pip":
|
||||
return of(false);
|
||||
return of(platform === "desktop" ? true : false);
|
||||
case "normal":
|
||||
case "narrow":
|
||||
return of(true);
|
||||
@@ -1494,6 +1487,7 @@ export function createCallViewModel$(
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
leave: localMembership.requestDisconnect,
|
||||
toggleScreenSharing: toggleScreenSharing,
|
||||
sharingScreen$: sharingScreen$,
|
||||
|
||||
@@ -1533,17 +1527,21 @@ export function createCallViewModel$(
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
setGridMode: setGridMode,
|
||||
grid$: grid$,
|
||||
spotlight$: spotlight$,
|
||||
pip$: pip$,
|
||||
layout$: layout$,
|
||||
userMedia$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$: scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
map((members) => members.value),
|
||||
tap((v) => {
|
||||
logger.debug("matrixLivekitMembers$ updated (exported)", v);
|
||||
const listForLogs = v
|
||||
.map(
|
||||
(m) =>
|
||||
m.membership$.value.userId + "|" + m.membership$.value.deviceId,
|
||||
)
|
||||
.join(",");
|
||||
logger.debug(
|
||||
`matrixLivekitMembers$ updated (exported) [${listForLogs}]`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
Status as RTCMemberStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -39,7 +39,9 @@ import { constant } from "../../Behavior";
|
||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
@@ -213,11 +215,10 @@ describe("LocalMembership", () => {
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||
const localTransport$ =
|
||||
scope.behavior<null | LocalTransportWithSFUConfig>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
|
||||
// we do not need any connection data since we want to fail before reaching that.
|
||||
const mockConnectionManager = {
|
||||
@@ -276,23 +277,11 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
const aTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransportConfig;
|
||||
const bTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const connectionTransportAConnected = {
|
||||
livekitRoom: mockLivekitRoom({
|
||||
@@ -302,7 +291,7 @@ describe("LocalMembership", () => {
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport.transport,
|
||||
transport: aTransport,
|
||||
} as unknown as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
@@ -311,7 +300,7 @@ describe("LocalMembership", () => {
|
||||
} as unknown as Connection;
|
||||
const connectionTransportBConnected = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: bTransport.transport,
|
||||
transport: bTransport,
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
@@ -365,12 +354,8 @@ describe("LocalMembership", () => {
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
||||
aTransport.transport,
|
||||
);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(
|
||||
bTransport.transport,
|
||||
);
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
@@ -443,8 +428,9 @@ describe("LocalMembership", () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
const localTransport$ =
|
||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
|
||||
null,
|
||||
);
|
||||
const connectionManagerData$ = new BehaviorSubject(
|
||||
new Epoch(connectionManagerData),
|
||||
);
|
||||
@@ -516,7 +502,7 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
(
|
||||
connectionManagerData2.getConnectionForTransport(aTransport.transport)!
|
||||
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next(ConnectionState.LivekitConnected);
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { observeParticipantEvents } from "@livekit/components-core";
|
||||
import {
|
||||
Status as RTCSessionStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
@@ -61,7 +62,6 @@ import {
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { and$ } from "../../../utils/observable.ts";
|
||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
|
||||
|
||||
export enum TransportState {
|
||||
/** Not even a transport is available to the LocalMembership */
|
||||
@@ -125,9 +125,9 @@ interface Props {
|
||||
muteStates: MuteStates;
|
||||
connectionManager: IConnectionManager;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
joinMatrixRTC: (transport: LivekitTransport) => void;
|
||||
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
localTransport$: Behavior<LivekitTransportConfig | null>;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
@@ -146,7 +146,7 @@ interface Props {
|
||||
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
||||
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
||||
* @param props.homeserverConnected The homeserver connected state.
|
||||
* @param props.localTransport$ The local transport to use for publishing.
|
||||
* @param props.localTransport$ The transport to advertise in our membership.
|
||||
* @param props.logger The logger to use.
|
||||
* @param props.muteStates The mute states for video and audio.
|
||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||
@@ -236,9 +236,7 @@ export const createLocalMembership$ = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionData.getConnectionForTransport(
|
||||
localTransport.transport,
|
||||
);
|
||||
return connectionData.getConnectionForTransport(localTransport);
|
||||
}),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
@@ -548,7 +546,7 @@ export const createLocalMembership$ = ({
|
||||
if (!shouldConnect) return;
|
||||
|
||||
try {
|
||||
joinMatrixRTC(transport.transport);
|
||||
joinMatrixRTC(transport);
|
||||
} catch (error) {
|
||||
logger.error("Error entering RTC session", error);
|
||||
if (error instanceof Error)
|
||||
@@ -717,7 +715,7 @@ interface EnterRTCSessionOptions {
|
||||
export function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
options: EnterRTCSessionOptions,
|
||||
): void {
|
||||
const { encryptMedia, matrixRTCMode } = options;
|
||||
@@ -735,12 +733,26 @@ export function enterRTCSession(
|
||||
const multiSFU =
|
||||
matrixRTCMode === MatrixRTCMode.Compatibility ||
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
|
||||
|
||||
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
|
||||
// we add the livekit alias to the transport.
|
||||
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
|
||||
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
|
||||
backwardCompatibleTransport = transport;
|
||||
} else {
|
||||
backwardCompatibleTransport = {
|
||||
livekit_alias: rtcSession.room.roomId,
|
||||
...transport,
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||
|
||||
rtcSession.joinRTCSession(
|
||||
ownMembershipIdentity,
|
||||
multiSFU ? [] : [transport],
|
||||
multiSFU ? transport : undefined,
|
||||
multiSFU ? [] : [backwardCompatibleTransport],
|
||||
multiSFU ? backwardCompatibleTransport : undefined,
|
||||
{
|
||||
notificationType,
|
||||
callIntent,
|
||||
|
||||
@@ -13,15 +13,24 @@ import {
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
|
||||
import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
mockRtcMembership,
|
||||
} from "../../../utils/test";
|
||||
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -34,7 +43,7 @@ describe("LocalTransport", () => {
|
||||
const openIdResponse: openIDSFU.SFUConfig = {
|
||||
url: "https://lk.example.org",
|
||||
jwt: testJWTToken,
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
};
|
||||
|
||||
@@ -43,10 +52,10 @@ describe("LocalTransport", () => {
|
||||
afterEach(() => scope.end());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -58,14 +67,15 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => localTransport$.value).toThrow(
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
|
||||
});
|
||||
|
||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||
@@ -83,10 +93,10 @@ describe("LocalTransport", () => {
|
||||
);
|
||||
const observations: unknown[] = [];
|
||||
const errors: Error[] = [];
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://lk.example.org",
|
||||
@@ -98,10 +108,10 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
localTransport$.subscribe(
|
||||
active$.subscribe(
|
||||
(o) => observations.push(o),
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
@@ -111,7 +121,8 @@ describe("LocalTransport", () => {
|
||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||
expect(observations).toStrictEqual([null]);
|
||||
expect(errors).toStrictEqual([expectedError]);
|
||||
expect(() => localTransport$.value).toThrow(expectedError);
|
||||
expect(() => advertised$.value).toThrow(expectedError);
|
||||
expect(() => active$.value).toThrow(expectedError);
|
||||
});
|
||||
|
||||
it("emits preferred transport after OpenID resolves", async () => {
|
||||
@@ -126,10 +137,10 @@ describe("LocalTransport", () => {
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -140,80 +151,152 @@ describe("LocalTransport", () => {
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.({
|
||||
url: "https://lk.example.org",
|
||||
jwt: "jwt",
|
||||
livekitAlias: "!room:example.org",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||
});
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!room:example.org",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "jwt",
|
||||
livekitAlias: "!room:example.org",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@alice:example.org:DEVICE",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates local transport when oldest member changes", async () => {
|
||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
describe("oldest member mode", () => {
|
||||
const aliceTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://alice.example.org",
|
||||
};
|
||||
const bobTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://bob.example.org",
|
||||
};
|
||||
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
|
||||
fociPreferred: [aliceTransport],
|
||||
});
|
||||
const memberships$ = new BehaviorSubject(new Epoch([]));
|
||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(true),
|
||||
memberships$,
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
delayId$: constant("delay_id_mock"),
|
||||
const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
|
||||
fociPreferred: [bobTransport],
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
|
||||
beforeEach(() => {
|
||||
openIdSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
.mockResolvedValue(openIdResponse);
|
||||
});
|
||||
|
||||
it("updates active transport when oldest member changes", async () => {
|
||||
// Initially, Alice is the only member
|
||||
const memberships$ = new BehaviorSubject([aliceMembership]);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Alice's transport should be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob joins the call, but Alice is still the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([aliceMembership, bobMembership]);
|
||||
await flushPromises();
|
||||
// No new SFU config should've been fetched
|
||||
expect(openIdSpy).not.toHaveBeenCalled();
|
||||
// Alice's transport should still be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob takes Alice's place as the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([bobMembership, aliceMembership]);
|
||||
// Active transport should reset to null until we have Bob's SFU config
|
||||
expect(active$.value).toStrictEqual(null);
|
||||
await flushPromises();
|
||||
// Bob's SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Bob's transport should be active, but Alice's should remain advertised
|
||||
// (since we don't want the change in oldest member to cause a wave of new
|
||||
// state events)
|
||||
expect(active$.value?.transport).toStrictEqual(bobTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
});
|
||||
|
||||
it("advertises preferred transport when no other member exists", async () => {
|
||||
// Initially, there are no members
|
||||
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () =>
|
||||
Promise.resolve([aliceTransport]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// Our own preferred transport should be advertised
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// No transport should be active however (there is still no oldest member)
|
||||
expect(active$.value).toBe(null);
|
||||
|
||||
// Now Bob joins the call and becomes the oldest member
|
||||
memberships$.next([bobMembership]);
|
||||
await flushPromises();
|
||||
// We should still advertise our own preferred transport (to avoid
|
||||
// unnecessary state changes)
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// Bob's transport should become active
|
||||
expect(active$.value?.transport).toBe(bobTransport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,8 +314,8 @@ describe("LocalTransport", () => {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
@@ -258,66 +341,75 @@ describe("LocalTransport", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("supports getting transport via user settings", async () => {
|
||||
customLivekitUrl.setValue("https://lk.example.org");
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("supports getting transport via backend", async () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails fast if the openID request fails for backend config", async () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
@@ -325,13 +417,11 @@ describe("LocalTransport", () => {
|
||||
openIdResolver.reject(
|
||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||
);
|
||||
try {
|
||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
||||
throw Error("Expected test to throw");
|
||||
} catch (ex) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
}
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
});
|
||||
|
||||
it("supports getting transport via well-known", async () => {
|
||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||
@@ -339,25 +429,29 @@ describe("LocalTransport", () => {
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
],
|
||||
});
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
expect(fetchMock.done()).toEqual(true);
|
||||
});
|
||||
|
||||
it("fails fast if the openId request fails for the well-known config", async () => {
|
||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||
@@ -368,20 +462,18 @@ describe("LocalTransport", () => {
|
||||
openIdResolver.reject(
|
||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||
);
|
||||
try {
|
||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
||||
throw Error("Expected test to throw");
|
||||
} catch (ex) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
}
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
});
|
||||
|
||||
it("throws if no options are available", async () => {
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
@@ -396,7 +488,10 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => localTransport$.value).toThrow(
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,19 +7,21 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
isLivekitTransportConfig,
|
||||
type Transport,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
@@ -57,9 +59,10 @@ interface Props {
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
roomId: string;
|
||||
useOldestMember$: Behavior<boolean>;
|
||||
forceJwtEndpoint$: Behavior<JwtEndpointVersion>;
|
||||
useOldestMember: boolean;
|
||||
forceJwtEndpoint: JwtEndpointVersion;
|
||||
delayId$: Behavior<string | null>;
|
||||
}
|
||||
|
||||
@@ -90,26 +93,38 @@ export enum JwtEndpointVersion {
|
||||
// 2.
|
||||
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
|
||||
export interface LocalTransportWithSFUConfig {
|
||||
transport: LivekitTransport;
|
||||
transport: LivekitTransportConfig;
|
||||
sfuConfig: SFUConfig;
|
||||
}
|
||||
|
||||
export function isLocalTransportWithSFUConfig(
|
||||
obj: LivekitTransport | LocalTransportWithSFUConfig,
|
||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||
): obj is LocalTransportWithSFUConfig {
|
||||
return "transport" in obj && "sfuConfig" in obj;
|
||||
}
|
||||
|
||||
interface LocalTransport {
|
||||
/**
|
||||
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||
* yet fetched/validated.
|
||||
*/
|
||||
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||
/**
|
||||
* The transport to connect to and publish media on. `null` when not yet known
|
||||
* or available.
|
||||
*/
|
||||
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the local transport.
|
||||
* "Which transport is the local member going to use"
|
||||
* Connects to the JWT service and determines the transports that the local member should use.
|
||||
*
|
||||
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
||||
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
||||
*
|
||||
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||
* (which is expected for non sticky event based rtc member events)
|
||||
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value.
|
||||
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
export const createLocalTransport$ = ({
|
||||
@@ -118,115 +133,156 @@ export const createLocalTransport$ = ({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
useOldestMember$,
|
||||
forceJwtEndpoint$,
|
||||
useOldestMember,
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
|
||||
}: Props): LocalTransport => {
|
||||
/**
|
||||
* The transport over which we should be actively publishing our media.
|
||||
* undefined when not joined.
|
||||
* The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
* oldest member has no such transport.
|
||||
*/
|
||||
const oldestMemberTransport$ =
|
||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||
combineLatest([memberships$, useOldestMember$]).pipe(
|
||||
map(([memberships, useOldestMember]) => {
|
||||
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member
|
||||
const oldestMember = memberships.value[0];
|
||||
const transport = oldestMember?.getTransport(oldestMember);
|
||||
if (!transport) return null;
|
||||
return transport;
|
||||
}),
|
||||
switchMap((transport) => {
|
||||
if (transport !== null && isLivekitTransport(transport)) {
|
||||
// Get the open jwt token to connect to the sfu
|
||||
const computeLocalTransportWithSFUConfig =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
||||
// await sleep(1000);
|
||||
return {
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
};
|
||||
};
|
||||
return from(computeLocalTransportWithSFUConfig());
|
||||
}
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
const oldestMemberTransport$ = scope.behavior<LivekitTransportConfig | null>(
|
||||
memberships$.pipe(
|
||||
map((memberships) => {
|
||||
const oldestMember = memberships.value[0];
|
||||
if (oldestMember === undefined) {
|
||||
logger.info("Oldest member: not found");
|
||||
return null;
|
||||
}
|
||||
const transport = oldestMember.getTransport(oldestMember);
|
||||
if (transport === undefined) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!isLivekitTransportConfig(transport)) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logger.info(
|
||||
"Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
|
||||
);
|
||||
return transport;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The transport that we would personally prefer to publish on (if not for the
|
||||
* transport preferences of others, perhaps).
|
||||
* transport preferences of others, perhaps). `null` until fetched and
|
||||
* validated.
|
||||
*
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
const preferredTransport$ = scope.behavior(
|
||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||
// When using sticky events (we need to use the new endpoint).
|
||||
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe(
|
||||
switchMap(([customUrl, delayId, forceEndpoint]) => {
|
||||
logger.info(
|
||||
"Creating preferred transport based on: ",
|
||||
"customUrl: ",
|
||||
customUrl,
|
||||
"delayId: ",
|
||||
delayId,
|
||||
"forceEndpoint: ",
|
||||
forceEndpoint,
|
||||
);
|
||||
return from(
|
||||
makeTransport(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
customUrl,
|
||||
forceEndpoint,
|
||||
delayId ?? undefined,
|
||||
const preferredTransport$ =
|
||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||
// When using sticky events (we need to use the new endpoint).
|
||||
customLivekitUrl.value$.pipe(
|
||||
switchMap((customUrl) =>
|
||||
startWith<LocalTransportWithSFUConfig | null>(null)(
|
||||
// Fetch the SFU config, and repeat this asynchronously for every
|
||||
// change in delay ID.
|
||||
delayId$.pipe(
|
||||
switchMap(async (delayId) => {
|
||||
logger.info(
|
||||
"Creating preferred transport based on: ",
|
||||
"customUrl: ",
|
||||
customUrl,
|
||||
"delayId: ",
|
||||
delayId,
|
||||
"forceJwtEndpoint: ",
|
||||
forceJwtEndpoint,
|
||||
);
|
||||
return makeTransport(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
customUrl,
|
||||
forceJwtEndpoint,
|
||||
delayId ?? undefined,
|
||||
);
|
||||
}),
|
||||
// We deliberately hide any changes to the SFU config because we
|
||||
// do not actually want the app to reconnect whenever the JWT
|
||||
// token changes due to us delegating a new delayed event. The
|
||||
// initial SFU config for the transport is all the app needs.
|
||||
distinctUntilChanged((prev, next) =>
|
||||
areLivekitTransportsEqual(prev.transport, next.transport),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The chosen transport we should advertise in our MatrixRTC membership.
|
||||
*/
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
useOldestMember$,
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$,
|
||||
]).pipe(
|
||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => {
|
||||
return useOldestMember
|
||||
? (oldestMemberTransport ?? preferredTransport)
|
||||
: preferredTransport;
|
||||
}),
|
||||
distinctUntilChanged((t1, t2) => {
|
||||
logger.info(
|
||||
"Local Transport Update from:",
|
||||
t1?.transport.livekit_service_url,
|
||||
" to ",
|
||||
t2?.transport.livekit_service_url,
|
||||
);
|
||||
return areLivekitTransportsEqual(
|
||||
t1?.transport ?? null,
|
||||
t2?.transport ?? null,
|
||||
);
|
||||
}),
|
||||
if (useOldestMember) {
|
||||
// --- Oldest member mode ---
|
||||
return {
|
||||
// Never update the transport that we advertise in our membership. Just
|
||||
// take the first valid oldest member or preferred transport that we learn
|
||||
// about, and stick with that. This avoids unnecessary SFU hops and room
|
||||
// state changes.
|
||||
advertised$: scope.behavior(
|
||||
merge(
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$.pipe(map((t) => t?.transport ?? null)),
|
||||
).pipe(
|
||||
first((t) => t !== null),
|
||||
tap((t) =>
|
||||
logger.info(`Advertise transport: ${t.livekit_service_url}`),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Publish on the transport used by the oldest member.
|
||||
active$: scope.behavior(
|
||||
oldestMemberTransport$.pipe(
|
||||
switchMap((transport) => {
|
||||
// Oldest member not available (or invalid SFU config).
|
||||
if (transport === null) return of(null);
|
||||
// Oldest member available: fetch the SFU config.
|
||||
const fetchOldestMemberTransport =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => ({
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
});
|
||||
return from(fetchOldestMemberTransport()).pipe(startWith(null));
|
||||
}),
|
||||
tap((t) =>
|
||||
logger.info(
|
||||
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Multi-SFU mode ---
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
map((t) => t?.transport ?? null),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
),
|
||||
);
|
||||
active$: preferredTransport$,
|
||||
};
|
||||
};
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
@@ -288,18 +344,6 @@ async function makeTransport(
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: url,
|
||||
// WARNING PLS READ ME!!!
|
||||
// This looks unintuitive especially considering that `sfuConfig.livekitAlias` exists.
|
||||
// Why do we not use: `livekit_alias: sfuConfig.livekitAlias`
|
||||
//
|
||||
// - This is going to be used for sending our state event transport (focus_preferred)
|
||||
// - In sticky events it is expected to NOT send this field at all. The transport is only the `type`, `livekit_service_url`
|
||||
// - If we set it to the hased alias we get from the jwt, we will end up using the hashed alias as the body.roomId field
|
||||
// in v0.16.0. (It will use oldest member transport. It is using the transport.livekit_alias as the body.roomId)
|
||||
//
|
||||
// TLDR this is a temporal field that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything)
|
||||
// It is just named poorly: It was intetended to be the actual alias. But now we do pseudonymys ids so we use a hashed alias.
|
||||
livekit_alias: roomId,
|
||||
},
|
||||
sfuConfig,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
trackProcessorSync,
|
||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import { observeTrackReference$ } from "../../MediaViewModel.ts";
|
||||
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { type IOpenIDToken } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
Connection,
|
||||
@@ -51,8 +51,9 @@ let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
||||
let localParticipantEventEmiter: EventEmitter;
|
||||
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const livekitFocus: LivekitTransport = {
|
||||
livekit_alias: "!roomID:example.org",
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
|
||||
const livekitFocus: LivekitTransportConfig = {
|
||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -112,6 +113,7 @@ function setupTest(): void {
|
||||
function setupRemoteConnection(): Connection {
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -154,6 +156,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -170,6 +173,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -221,6 +225,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -279,6 +284,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Room as LivekitRoom,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -49,9 +49,11 @@ export interface ConnectionOpts {
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** The media transport to connect to. */
|
||||
transport: LivekitTransport;
|
||||
transport: LivekitTransportConfig;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
client: OpenIDClientParts;
|
||||
/** The room ID this connection is associated with. */
|
||||
roomId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
scope: ObservableScope;
|
||||
|
||||
@@ -102,7 +104,7 @@ export class Connection {
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: LivekitTransport;
|
||||
public readonly transport: LivekitTransportConfig;
|
||||
|
||||
public readonly livekitRoom: LivekitRoom;
|
||||
|
||||
@@ -131,6 +133,47 @@ export class Connection {
|
||||
* */
|
||||
protected stopped = false;
|
||||
|
||||
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
||||
private readonly client: OpenIDClientParts;
|
||||
private readonly roomId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
private readonly existingSFUConfig?: SFUConfig;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.existingSFUConfig = opts.existingSFUConfig;
|
||||
this.roomId = opts.roomId;
|
||||
this.logger = logger.getChild(
|
||||
"[Connection " + opts.transport.livekit_service_url + "]",
|
||||
);
|
||||
this.logger.info(
|
||||
`constructor: ${opts.transport.livekit_service_url} roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
||||
);
|
||||
const { transport, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.transport = transport;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the connection.
|
||||
*
|
||||
@@ -231,7 +274,7 @@ export class Connection {
|
||||
this.client,
|
||||
this.ownMembershipIdentity,
|
||||
this.transport.livekit_service_url,
|
||||
this.transport.livekit_alias,
|
||||
this.roomId,
|
||||
// dont pass any custom opts for the subscribe only connections
|
||||
{},
|
||||
this.logger,
|
||||
@@ -256,42 +299,4 @@ export class Connection {
|
||||
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
private readonly client: OpenIDClientParts;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
private readonly existingSFUConfig?: SFUConfig;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.existingSFUConfig = opts.existingSFUConfig;
|
||||
this.logger = logger.getChild(
|
||||
"[Connection " + opts.transport.livekit_service_url + "]",
|
||||
);
|
||||
this.logger.info(
|
||||
`constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
||||
);
|
||||
const { transport, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.transport = transport;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
// imported as inline to support worker when loaded from a cdn (cross domain)
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Connection } from "./Connection.ts";
|
||||
@@ -33,7 +33,7 @@ import { defaultLiveKitOptions } from "../../../livekit/options.ts";
|
||||
export interface ConnectionFactory {
|
||||
createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -47,6 +47,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
* Creates a ConnectionFactory for LiveKit connections.
|
||||
*
|
||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||
* @param roomId - The current room ID.
|
||||
* @param devices - Used for video/audio out/in capture options.
|
||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
|
||||
@@ -57,6 +58,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
*/
|
||||
public constructor(
|
||||
private client: OpenIDClientParts,
|
||||
private readonly roomId: string,
|
||||
private devices: MediaDevices,
|
||||
private processorState$: Behavior<ProcessorState>,
|
||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||
@@ -95,7 +97,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
*/
|
||||
public createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -103,6 +105,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
return new Connection(
|
||||
{
|
||||
existingSFUConfig: sfuConfig,
|
||||
roomId: this.roomId,
|
||||
transport,
|
||||
client: this.client,
|
||||
scope: scope,
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
@@ -24,16 +24,14 @@ import { constant, type Behavior } from "../../Behavior.ts";
|
||||
|
||||
// Some test constants
|
||||
|
||||
const TRANSPORT_1: LivekitTransport = {
|
||||
const TRANSPORT_1: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
livekit_alias: "!alias:example.org",
|
||||
};
|
||||
|
||||
const TRANSPORT_2: LivekitTransport = {
|
||||
const TRANSPORT_2: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
livekit_alias: "!alias:sample.com",
|
||||
};
|
||||
|
||||
let fakeConnectionFactory: ConnectionFactory;
|
||||
@@ -49,7 +47,7 @@ beforeEach(() => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
const mockConnection = {
|
||||
transport,
|
||||
remoteParticipants$: new BehaviorSubject([]),
|
||||
@@ -209,15 +207,15 @@ describe("connectionManagerData$ stream", () => {
|
||||
// Used in test to control fake connections' remoteParticipants$ streams
|
||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||
|
||||
function keyForTransport(transport: LivekitTransport): string {
|
||||
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
||||
function keyForTransport(transport: LivekitTransportConfig): string {
|
||||
return `${transport.livekit_service_url}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeRemoteParticipantsStreams = new Map();
|
||||
|
||||
function getRemoteParticipantsFor(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
return (
|
||||
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||
@@ -229,7 +227,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||
RemoteParticipant[]
|
||||
>([]);
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
@@ -42,8 +42,10 @@ export class ConnectionManagerData {
|
||||
}
|
||||
}
|
||||
|
||||
private getKey(transport: LivekitTransport): string {
|
||||
return transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||
private getKey(transport: LivekitTransportConfig): string {
|
||||
// This is enough as a key because the ConnectionManager is already scoped by room.
|
||||
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
|
||||
return transport.livekit_service_url;
|
||||
}
|
||||
|
||||
public getConnections(): Connection[] {
|
||||
@@ -51,15 +53,15 @@ export class ConnectionManagerData {
|
||||
}
|
||||
|
||||
public getConnectionForTransport(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): Connection | null {
|
||||
return this.store.get(this.getKey(transport))?.connection ?? null;
|
||||
}
|
||||
|
||||
public getParticipantsForTransport(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): RemoteParticipant[] {
|
||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||
const key = this.getKey(transport);
|
||||
const existing = this.store.get(key);
|
||||
if (existing) {
|
||||
return existing.participants;
|
||||
@@ -72,7 +74,7 @@ interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
|
||||
logger: Logger;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
@@ -88,7 +90,7 @@ export interface IConnectionManager {
|
||||
* @param props - Configuration object
|
||||
* @param props.scope - The observable scope used by this object
|
||||
* @param props.connectionFactory - Used to create new connections
|
||||
* @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$)
|
||||
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
|
||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||
* @param props.logger - The logger to use.
|
||||
@@ -123,7 +125,7 @@ export function createConnectionManager$({
|
||||
* externally this is modified via `registerTransports()`.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<
|
||||
Epoch<(LivekitTransport | LocalTransportWithSFUConfig)[]>
|
||||
Epoch<(LivekitTransportConfig | LocalTransportWithSFUConfig)[]>
|
||||
> = scope.behavior(
|
||||
combineLatest([remoteTransports$, localTransport$]).pipe(
|
||||
// Combine local and remote transports into one transport array
|
||||
@@ -160,44 +162,40 @@ export function createConnectionManager$({
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
generateItemsWithEpoch(
|
||||
"ConnectionManager connections$",
|
||||
function* (transports) {
|
||||
for (const transportWithOrWithoutSfuConfig of transports) {
|
||||
if (
|
||||
isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig)
|
||||
) {
|
||||
// This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
|
||||
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
|
||||
for (const transport of transports) {
|
||||
if (isLocalTransportWithSFUConfig(transport)) {
|
||||
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
|
||||
yield {
|
||||
keys: [
|
||||
transport.livekit_service_url,
|
||||
transport.livekit_alias,
|
||||
sfuConfig,
|
||||
transport.transport.livekit_service_url,
|
||||
transport.sfuConfig,
|
||||
],
|
||||
data: undefined,
|
||||
};
|
||||
} else {
|
||||
const transport = transportWithOrWithoutSfuConfig;
|
||||
yield {
|
||||
keys: [
|
||||
transport.livekit_service_url,
|
||||
transport.livekit_alias,
|
||||
undefined as undefined | SFUConfig,
|
||||
undefined as SFUConfig | undefined,
|
||||
],
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, _data$, serviceUrl, alias, sfuConfig) => {
|
||||
(scope, _data$, serviceUrl, sfuConfig) => {
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
{
|
||||
type: "livekit",
|
||||
livekit_service_url: serviceUrl,
|
||||
livekit_alias: alias,
|
||||
},
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
|
||||
// I would like it if connections always fetched the SFUConfig by themselves.
|
||||
sfuConfig,
|
||||
);
|
||||
// Start the connection immediately
|
||||
@@ -254,7 +252,7 @@ export function createConnectionManager$({
|
||||
return { connectionManagerData$ };
|
||||
}
|
||||
|
||||
function removeDuplicateTransports<T extends LivekitTransport>(
|
||||
function removeDuplicateTransports<T extends LivekitTransportConfig>(
|
||||
transports: T[],
|
||||
): T[] {
|
||||
return transports.reduce((acc, transport) => {
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
@@ -105,6 +106,7 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map<never, never>()),
|
||||
|
||||
@@ -7,11 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
type LivekitTransport,
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, filter, map } from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type IConnectionManager } from "./ConnectionManager";
|
||||
@@ -19,8 +18,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope";
|
||||
import { type Connection } from "./Connection";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
||||
|
||||
interface LocalTaggedParticipant {
|
||||
type: "local";
|
||||
value$: Behavior<LocalParticipant | null>;
|
||||
@@ -62,7 +59,7 @@ export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
>;
|
||||
connectionManager: IConnectionManager;
|
||||
}
|
||||
@@ -94,9 +91,10 @@ export function createMatrixLivekitMembers$({
|
||||
),
|
||||
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
|
||||
generateItemsWithEpoch(
|
||||
"MatrixLivekitMembers",
|
||||
// Generator function.
|
||||
// creates an array of `{key, data}[]`
|
||||
// Each change in the keys (new key, missing key) will result in a call to the factory function.
|
||||
// Each change in the keys (new key) will result in a call to the factory function.
|
||||
function* ([membershipsWithTransport, managerData]) {
|
||||
for (const { membership, transport } of membershipsWithTransport) {
|
||||
const participants = transport
|
||||
@@ -111,26 +109,23 @@ export function createMatrixLivekitMembers$({
|
||||
: null;
|
||||
|
||||
yield {
|
||||
// This could also just be the memberId without the other fields.
|
||||
// In theory we should never have the same memberId for different userIds (they are UUIDs)
|
||||
// This still makes us resilient agains someone who intentionally tries to use the same memberId.
|
||||
// If they want to do this they would now need to also use the same sender which is impossible.
|
||||
// This could just be the backend identity without the other keys.
|
||||
// The user ID, device ID, and member ID are included however so
|
||||
// they show up in debug logs.
|
||||
keys: [
|
||||
membership.userId,
|
||||
membership.deviceId,
|
||||
membership.memberId,
|
||||
membership.rtcBackendIdentity,
|
||||
],
|
||||
data: { membership, participant, connection },
|
||||
};
|
||||
}
|
||||
},
|
||||
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
||||
(scope, data$, userId, deviceId, memberId) => {
|
||||
logger.debug(
|
||||
`Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity},keys userId:deviceId:memberId ${userId}:${deviceId}:${memberId}`,
|
||||
);
|
||||
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
|
||||
(scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => {
|
||||
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||
// will only get called once per `participantId, userId` pair.
|
||||
// will only get called once per backend identity.
|
||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||
return {
|
||||
userId,
|
||||
@@ -147,18 +142,12 @@ export function createMatrixLivekitMembers$({
|
||||
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
||||
|
||||
// TODO add this to the JS-SDK
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransport>(
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransportConfig>(
|
||||
t1: T | null,
|
||||
t2: T | null,
|
||||
): boolean {
|
||||
if (t1 && t2)
|
||||
return (
|
||||
t1.livekit_service_url === t2.livekit_service_url &&
|
||||
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)
|
||||
// It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation)
|
||||
// Also LivekitTransport is planned to become a `ConnectionIdentifier` which moves this equal somewhere else.
|
||||
t1.livekit_alias === t2.livekit_alias
|
||||
);
|
||||
if (!t1 && !t2) return true;
|
||||
return false;
|
||||
if (t1 && t2) {
|
||||
return t1.livekit_service_url === t2.livekit_service_url;
|
||||
}
|
||||
return !t1 && !t2;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import EventEmitter from "events";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
@@ -71,6 +71,7 @@ beforeEach(() => {
|
||||
|
||||
ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
@@ -148,7 +149,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
a: expect.toSatisfy((co) =>
|
||||
areLivekitTransportsEqual(
|
||||
co.transport,
|
||||
bobMembership.transports[0]! as LivekitTransport,
|
||||
bobMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -185,7 +186,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
carlMembership.transports[0]! as LivekitTransport,
|
||||
carlMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
).toBe(true);
|
||||
return true;
|
||||
@@ -215,7 +216,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
daveMembership.transports[0]! as LivekitTransport,
|
||||
daveMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
).toBe(true);
|
||||
return true;
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AudioSource,
|
||||
type VideoSource,
|
||||
type TrackReference,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
LocalTrack,
|
||||
LocalVideoTrack,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
Track,
|
||||
TrackEvent,
|
||||
facingModeFromLocalTrack,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
RemoteTrack,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
type Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
filter,
|
||||
fromEvent,
|
||||
interval,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
distinctUntilChanged,
|
||||
} from "rxjs";
|
||||
|
||||
import { alwaysShowSelf } from "../settings/settings";
|
||||
import { showConnectionStats } from "../settings/settings";
|
||||
import { accumulate } from "../utils/observable";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { platform } from "../Platform";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
type: "inbound-rtp" | "outbound-rtp",
|
||||
): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
if (
|
||||
!track ||
|
||||
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const report = await track.getRTCStatsReport();
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const v of report.values()) {
|
||||
if (v.type === type) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeInboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeOutboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
||||
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function observeRemoteTrackReceivingOkay$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<boolean | undefined> {
|
||||
let lastStats: {
|
||||
framesDecoded: number | undefined;
|
||||
framesDropped: number | undefined;
|
||||
framesReceived: number | undefined;
|
||||
} = {
|
||||
framesDecoded: undefined,
|
||||
framesDropped: undefined,
|
||||
framesReceived: undefined,
|
||||
};
|
||||
|
||||
return observeInboundRtpStreamStats$(participant, source).pipe(
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
const { framesDecoded, framesDropped, framesReceived } = stats;
|
||||
return {
|
||||
framesDecoded,
|
||||
framesDropped,
|
||||
framesReceived,
|
||||
};
|
||||
}),
|
||||
filter((newStats) => !!newStats),
|
||||
map((newStats): boolean | undefined => {
|
||||
const oldStats = lastStats;
|
||||
lastStats = newStats;
|
||||
if (
|
||||
typeof newStats.framesReceived === "number" &&
|
||||
typeof oldStats.framesReceived === "number" &&
|
||||
typeof newStats.framesDecoded === "number" &&
|
||||
typeof oldStats.framesDecoded === "number"
|
||||
) {
|
||||
const framesReceivedDelta =
|
||||
newStats.framesReceived - oldStats.framesReceived;
|
||||
const framesDecodedDelta =
|
||||
newStats.framesDecoded - oldStats.framesDecoded;
|
||||
|
||||
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||
|
||||
if (framesReceivedDelta > 0) {
|
||||
return framesDecodedDelta > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// no change
|
||||
return undefined;
|
||||
}),
|
||||
filter((x) => typeof x === "boolean"),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function encryptionErrorObservable$(
|
||||
room$: Behavior<LivekitRoom | undefined>,
|
||||
participant: Participant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
criteria: string,
|
||||
): Observable<boolean> {
|
||||
return room$.pipe(
|
||||
switchMap((room) => {
|
||||
if (room === undefined) return of(false);
|
||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||
map((e) => {
|
||||
const [err] = e;
|
||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return (
|
||||
// Ideally we would pull the participant identity from the field on the error.
|
||||
// However, it gets lost in the serialization process between workers.
|
||||
// So, instead we do a string match
|
||||
(err?.message.includes(participant.identity) &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||
return !!err?.message.includes(criteria);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
|
||||
export enum EncryptionStatus {
|
||||
Connecting,
|
||||
Okay,
|
||||
KeyMissing,
|
||||
KeyInvalid,
|
||||
PasswordInvalid,
|
||||
}
|
||||
|
||||
abstract class BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video$: Behavior<TrackReference | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning$: Behavior<boolean>;
|
||||
|
||||
public readonly encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
|
||||
/**
|
||||
* Whether this media corresponds to the local participant.
|
||||
*/
|
||||
public abstract readonly local: boolean;
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReference | undefined> {
|
||||
return this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
protected readonly scope: ObservableScope,
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
public readonly id: string,
|
||||
/**
|
||||
* The Matrix user to which this media belongs.
|
||||
*/
|
||||
public readonly userId: string,
|
||||
public readonly rtcBackendIdentity: string,
|
||||
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
||||
// livekit.
|
||||
protected readonly participant$: Observable<
|
||||
LocalParticipant | RemoteParticipant | null
|
||||
>,
|
||||
|
||||
encryptionSystem: EncryptionSystem,
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
public readonly focusUrl$: Behavior<string | undefined>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
) {
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
this.unencryptedWarning$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
),
|
||||
);
|
||||
|
||||
this.encryptionStatus$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
export type UserMediaViewModel =
|
||||
| LocalUserMediaViewModel
|
||||
| RemoteUserMediaViewModel;
|
||||
|
||||
/**
|
||||
* Some participant's user media.
|
||||
*/
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
private readonly _speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
// Getter backed by a private field so that subclasses can override it
|
||||
public get speaking$(): Behavior<boolean> {
|
||||
return this._speaking$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
|
||||
private readonly _videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
// Getter backed by a private field so that subclasses can override it
|
||||
public get videoEnabled$(): Behavior<boolean> {
|
||||
return this._videoEnabled$;
|
||||
}
|
||||
|
||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||
/**
|
||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||
*/
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
);
|
||||
|
||||
const media$ = this.scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
);
|
||||
this._videoEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
);
|
||||
}
|
||||
|
||||
public toggleFitContain(): void {
|
||||
this._cropVideo$.next(!this._cropVideo$.value);
|
||||
}
|
||||
|
||||
public get local(): boolean {
|
||||
return this instanceof LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
public abstract get audioStreamStats$(): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
public abstract get videoStreamStats$(): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local participant's user media.
|
||||
*/
|
||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether to show this tile in a highly visible location near the start of
|
||||
* the grid.
|
||||
*/
|
||||
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||
|
||||
/**
|
||||
* Callback for switching between the front and back cameras.
|
||||
*/
|
||||
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||
this.scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: this.videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
this.mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Behavior<LocalParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
}
|
||||
|
||||
public audioStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeOutboundRtpStreamStats$(p, Track.Source.Microphone);
|
||||
}),
|
||||
);
|
||||
|
||||
public videoStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||
* could be because either we or the remote party are still connecting.
|
||||
*/
|
||||
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
|
||||
combineLatest(
|
||||
[this.livekitRoom$, this.participant$],
|
||||
(livekitRoom, participant) =>
|
||||
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||
// any transport and so we shouldn't expect a participant. (They might
|
||||
// be a subscribe-only bot for example.)
|
||||
livekitRoom !== undefined && participant === null,
|
||||
),
|
||||
);
|
||||
|
||||
// This private field is used to override the value from the superclass
|
||||
private __speaking$: Behavior<boolean>;
|
||||
public get speaking$(): Behavior<boolean> {
|
||||
return this.__speaking$;
|
||||
}
|
||||
|
||||
private readonly locallyMutedToggle$ = new Subject<void>();
|
||||
private readonly localVolumeAdjustment$ = new Subject<number>();
|
||||
private readonly localVolumeCommit$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume$ = this.scope.behavior<number>(
|
||||
merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
// This private field is used to override the value from the superclass
|
||||
private __videoEnabled$: Behavior<boolean>;
|
||||
public get videoEnabled$(): Behavior<boolean> {
|
||||
return this.__videoEnabled$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted$ = this.scope.behavior<boolean>(
|
||||
this.localVolume$.pipe(map((volume) => volume === 0)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<RemoteParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
|
||||
this.__speaking$ = this.scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : super.speaking$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.__videoEnabled$ = this.scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : super.videoEnabled$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync the local volume with LiveKit
|
||||
combineLatest([
|
||||
participant$,
|
||||
// The local volume, taking into account whether we're supposed to pretend
|
||||
// that the audio stream is disconnected (since we don't necessarily want
|
||||
// that to modify the UI state).
|
||||
this.pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)),
|
||||
this.scope.bind(),
|
||||
),
|
||||
]).subscribe(([p, volume]) => p?.setVolume(volume));
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this.locallyMutedToggle$.next();
|
||||
}
|
||||
|
||||
public setLocalVolume(value: number): void {
|
||||
this.localVolumeAdjustment$.next(value);
|
||||
}
|
||||
|
||||
public commitLocalVolume(): void {
|
||||
this.localVolumeCommit$.next();
|
||||
}
|
||||
|
||||
public audioStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeInboundRtpStreamStats$(p, Track.Source.Microphone);
|
||||
}),
|
||||
);
|
||||
|
||||
public videoStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether this screen share's video should be displayed.
|
||||
*/
|
||||
public readonly videoEnabled$ = this.scope.behavior(
|
||||
this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.ScreenShare,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { constant } from "./Behavior";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { flushPromises, mockMediaDevices } from "../utils/test";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { platform } from "../Platform.ts";
|
||||
import { type PipLayout, type PipLayoutMedia } from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
|
||||
@@ -16,7 +17,11 @@ export function pipLayout(
|
||||
prevTiles: TileStore,
|
||||
): [PipLayout, TileStore] {
|
||||
const update = prevTiles.from(0);
|
||||
update.registerSpotlight(media.spotlight, true);
|
||||
// Dont maximise in pip on EW since we want the rounded corners and the footer
|
||||
update.registerSpotlight(
|
||||
media.spotlight,
|
||||
platform === "desktop" ? false : true,
|
||||
);
|
||||
const tiles = update.build();
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,55 +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 { of } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||
import type { Behavior } from "./Behavior.ts";
|
||||
|
||||
/**
|
||||
* A screen share media item to be presented in a tile. This is a thin wrapper
|
||||
* around ScreenShareViewModel which essentially just establishes an
|
||||
* ObservableScope for behaviors that the view model depends on.
|
||||
*/
|
||||
export class ScreenShare {
|
||||
public readonly vm: ScreenShareViewModel;
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
) {
|
||||
this.vm = new ScreenShareViewModel(
|
||||
this.scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
of(participant),
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
pretendToBeDisconnected$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
participant.isLocal,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
isLivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { fromEvent } from "rxjs";
|
||||
|
||||
@@ -27,19 +27,26 @@ export const membershipsAndTransports$ = (
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
): {
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
>;
|
||||
transports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
transports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
} => {
|
||||
/**
|
||||
* Lists the transports used by ourselves, plus all other MatrixRTC session
|
||||
* members. For completeness this also lists the preferred transport and
|
||||
* whether we are in multi-SFU mode or sticky events mode (because
|
||||
* advertisedTransport$ wants to read them at the same time, and bundling data
|
||||
* together when it might change together is what you have to do in RxJS to
|
||||
* avoid reading inconsistent state or observing too many changes.)
|
||||
* members.
|
||||
* For completeness this also lists the preferred transport and
|
||||
* whether we are in multi-SFU mode or sticky events mode.
|
||||
* `advertisedTransport$` reads these values together, so bundling them avoids inconsistent state or
|
||||
* excessive updates when using RxJS.
|
||||
*/
|
||||
const membershipsWithTransport$ = scope.behavior(
|
||||
const membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{
|
||||
membership: CallMembership;
|
||||
transport: LivekitTransportConfig | undefined;
|
||||
}[]
|
||||
>
|
||||
> = scope.behavior(
|
||||
memberships$.pipe(
|
||||
mapEpoch((memberships) => {
|
||||
return memberships.map((membership) => {
|
||||
@@ -47,14 +54,16 @@ export const membershipsAndTransports$ = (
|
||||
const transport = membership.getTransport(oldestMembership);
|
||||
return {
|
||||
membership,
|
||||
transport: isLivekitTransport(transport) ? transport : undefined,
|
||||
transport: isLivekitTransportConfig(transport)
|
||||
? transport
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const transports$ = scope.behavior(
|
||||
const transports$: Behavior<Epoch<LivekitTransportConfig[]>> = scope.behavior(
|
||||
membershipsWithTransport$.pipe(
|
||||
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
||||
),
|
||||
|
||||
@@ -8,10 +8,11 @@ Please see LICENSE in the repository root for full details.
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
|
||||
import { fillGaps } from "../utils/iter";
|
||||
import { debugTileLayout } from "../settings/settings";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
|
||||
function debugEntries(entries: GridTileData[]): string[] {
|
||||
return entries.map((e) => e.media.displayName$.value);
|
||||
|
||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
|
||||
@@ -1,209 +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 { combineLatest, map, type Observable, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel.ts";
|
||||
import type { Behavior } from "./Behavior.ts";
|
||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||
import type { MediaDevices } from "./MediaDevices.ts";
|
||||
import type { ReactionOption } from "../reactions";
|
||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||
import { generateItems } from "../utils/observable.ts";
|
||||
import { ScreenShare } from "./ScreenShare.ts";
|
||||
import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||
|
||||
/**
|
||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||
*/
|
||||
enum SortingBin {
|
||||
/**
|
||||
* Yourself, when the "always show self" option is on.
|
||||
*/
|
||||
SelfAlwaysShown,
|
||||
/**
|
||||
* Participants that are sharing their screen.
|
||||
*/
|
||||
Presenters,
|
||||
/**
|
||||
* Participants that have been speaking recently.
|
||||
*/
|
||||
Speakers,
|
||||
/**
|
||||
* Participants that have their hand raised.
|
||||
*/
|
||||
HandRaised,
|
||||
/**
|
||||
* Participants with video.
|
||||
*/
|
||||
Video,
|
||||
/**
|
||||
* Participants not sharing any video.
|
||||
*/
|
||||
NoVideo,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is off.
|
||||
*/
|
||||
SelfNotAlwaysShown,
|
||||
}
|
||||
|
||||
/**
|
||||
* A user media item to be presented in a tile. This is a thin wrapper around
|
||||
* UserMediaViewModel which additionally determines the media item's sorting bin
|
||||
* for inclusion in the call layout and tracks associated screen shares.
|
||||
*/
|
||||
export class UserMedia {
|
||||
public readonly vm: UserMediaViewModel =
|
||||
this.participant.type === "local"
|
||||
? new LocalUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
this.participant.value$,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.mediaDevices,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
this.scope.behavior(this.handRaised$),
|
||||
this.scope.behavior(this.reaction$),
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
this.participant.value$,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
this.scope.behavior(this.handRaised$),
|
||||
this.scope.behavior(this.reaction$),
|
||||
);
|
||||
|
||||
private readonly speaker$ = this.scope.behavior(
|
||||
observeSpeaker$(this.vm.speaking$),
|
||||
);
|
||||
|
||||
// TypeScript needs this widening of the type to happen in a separate statement
|
||||
private readonly participant$: Behavior<
|
||||
LocalParticipant | RemoteParticipant | null
|
||||
> = this.participant.value$;
|
||||
|
||||
/**
|
||||
* All screen share media associated with this user media.
|
||||
*/
|
||||
public readonly screenShares$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? of([])
|
||||
: observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
function* (p) {
|
||||
if (p.isScreenShareEnabled)
|
||||
yield {
|
||||
keys: ["screen-share"],
|
||||
data: undefined,
|
||||
};
|
||||
},
|
||||
(scope, _data$, key) =>
|
||||
new ScreenShare(
|
||||
scope,
|
||||
`${this.id}:${key}`,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
p,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
private readonly presenter$ = this.scope.behavior(
|
||||
this.screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which sorting bin the media item should be placed in.
|
||||
*/
|
||||
// This is exposed here rather than by UserMediaViewModel because it's only
|
||||
// relevant to the layout algorithms; the MediaView component should be
|
||||
// ignorant of this value.
|
||||
public readonly bin$ = combineLatest(
|
||||
[
|
||||
this.speaker$,
|
||||
this.presenter$,
|
||||
this.vm.videoEnabled$,
|
||||
this.vm.handRaised$,
|
||||
this.vm instanceof LocalUserMediaViewModel
|
||||
? this.vm.alwaysShow$
|
||||
: of(false),
|
||||
],
|
||||
(speaker, presenter, video, handRaised, alwaysShow) => {
|
||||
if (this.vm.local)
|
||||
return alwaysShow
|
||||
? SortingBin.SelfAlwaysShown
|
||||
: SortingBin.SelfNotAlwaysShown;
|
||||
else if (presenter) return SortingBin.Presenters;
|
||||
else if (speaker) return SortingBin.Speakers;
|
||||
else if (handRaised) return SortingBin.HandRaised;
|
||||
else if (video) return SortingBin.Video;
|
||||
else return SortingBin.NoVideo;
|
||||
},
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
public readonly id: string,
|
||||
private readonly userId: string,
|
||||
private readonly rtcBackendIdentity: string,
|
||||
private readonly participant: TaggedParticipant,
|
||||
private readonly encryptionSystem: EncryptionSystem,
|
||||
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
private readonly focusUrl$: Behavior<string | undefined>,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
private readonly displayName$: Behavior<string>,
|
||||
private readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
private readonly handRaised$: Observable<Date | null>,
|
||||
private readonly reaction$: Observable<ReactionOption | null>,
|
||||
) {}
|
||||
}
|
||||
101
src/state/VolumeControls.ts
Normal file
101
src/state/VolumeControls.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 Element Software Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, merge, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { accumulate } from "../utils/observable";
|
||||
|
||||
/**
|
||||
* Controls for audio playback volume.
|
||||
*/
|
||||
export interface VolumeControls {
|
||||
/**
|
||||
* The volume to which the audio is set, as a scalar multiplier.
|
||||
*/
|
||||
playbackVolume$: Behavior<number>;
|
||||
/**
|
||||
* Whether playback of this audio is disabled.
|
||||
*/
|
||||
playbackMuted$: Behavior<boolean>;
|
||||
togglePlaybackMuted: () => void;
|
||||
adjustPlaybackVolume: (value: number) => void;
|
||||
commitPlaybackVolume: () => void;
|
||||
}
|
||||
|
||||
interface VolumeControlsInputs {
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
/**
|
||||
* The callback to run to notify the module performing audio playback of the
|
||||
* requested volume.
|
||||
*/
|
||||
sink$: Behavior<(volume: number) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a set of controls for audio playback volume and syncs this with the
|
||||
* audio playback module for the duration of the scope.
|
||||
*/
|
||||
export function createVolumeControls(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, sink$ }: VolumeControlsInputs,
|
||||
): VolumeControls {
|
||||
const toggleMuted$ = new Subject<"toggle mute">();
|
||||
const adjustVolume$ = new Subject<number>();
|
||||
const commitVolume$ = new Subject<"commit">();
|
||||
|
||||
const playbackVolume$ = scope.behavior<number>(
|
||||
merge(toggleMuted$, adjustVolume$, commitVolume$).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync the requested volume with the audio playback module
|
||||
combineLatest([
|
||||
sink$,
|
||||
// The playback volume, taking into account whether we're supposed to
|
||||
// pretend that the audio stream is disconnected (since we don't necessarily
|
||||
// want that to modify the UI state).
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) => (disconnected ? of(0) : playbackVolume$)),
|
||||
),
|
||||
])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([sink, volume]) => sink(volume));
|
||||
|
||||
return {
|
||||
playbackVolume$,
|
||||
playbackMuted$: scope.behavior<boolean>(
|
||||
playbackVolume$.pipe(map((volume) => volume === 0)),
|
||||
),
|
||||
togglePlaybackMuted: () => toggleMuted$.next("toggle mute"),
|
||||
adjustPlaybackVolume: (value: number) => adjustVolume$.next(value),
|
||||
commitPlaybackVolume: () => commitVolume$.next("commit"),
|
||||
};
|
||||
}
|
||||
@@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import {
|
||||
type MediaViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -40,8 +40,8 @@ export interface SpotlightExpandedLayoutMedia {
|
||||
|
||||
export interface OneOnOneLayoutMedia {
|
||||
type: "one-on-one";
|
||||
local: UserMediaViewModel;
|
||||
remote: UserMediaViewModel;
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayoutMedia {
|
||||
|
||||
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface LocalScreenShareViewModel extends BaseScreenShareViewModel {
|
||||
local: true;
|
||||
}
|
||||
|
||||
export interface LocalScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
}
|
||||
|
||||
export function createLocalScreenShare(
|
||||
scope: ObservableScope,
|
||||
inputs: LocalScreenShareInputs,
|
||||
): LocalScreenShareViewModel {
|
||||
return { ...createBaseScreenShare(scope, inputs), local: true };
|
||||
}
|
||||
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { alwaysShowSelf } from "../../settings/settings";
|
||||
import { platform } from "../../Platform";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
|
||||
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
local: true;
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
mirror$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether to show this tile in a highly visible location near the start of
|
||||
* the grid.
|
||||
*/
|
||||
alwaysShow$: Behavior<boolean>;
|
||||
setAlwaysShow: (value: boolean) => void;
|
||||
switchCamera$: Behavior<(() => void) | null>;
|
||||
}
|
||||
|
||||
export interface LocalUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
> {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
mediaDevices: MediaDevices;
|
||||
}
|
||||
|
||||
export function createLocalUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ mediaDevices, ...inputs }: LocalUserMediaInputs,
|
||||
): LocalUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "outbound-rtp",
|
||||
});
|
||||
|
||||
/**
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
const videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
baseUserMedia.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
local: true,
|
||||
mirror$: scope.behavior(
|
||||
videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
),
|
||||
alwaysShow$: alwaysShowSelf.value$,
|
||||
setAlwaysShow: alwaysShowSelf.setValue,
|
||||
switchCamera$: scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
198
src/state/media/MediaItem.ts
Normal file
198
src/state/media/MediaItem.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Software Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
import type { Behavior } from "../Behavior.ts";
|
||||
import type { MediaDevices } from "../MediaDevices.ts";
|
||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||
import { generateItems } from "../../utils/observable.ts";
|
||||
import { type TaggedParticipant } from "../CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel.ts";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts";
|
||||
import {
|
||||
createLocalUserMedia,
|
||||
type LocalUserMediaInputs,
|
||||
} from "./LocalUserMediaViewModel.ts";
|
||||
import {
|
||||
createRemoteUserMedia,
|
||||
type RemoteUserMediaInputs,
|
||||
} from "./RemoteUserMediaViewModel.ts";
|
||||
import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts";
|
||||
import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts";
|
||||
|
||||
/**
|
||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||
*/
|
||||
enum SortingBin {
|
||||
/**
|
||||
* Yourself, when the "always show self" option is on.
|
||||
*/
|
||||
SelfAlwaysShown,
|
||||
/**
|
||||
* Participants that are sharing their screen.
|
||||
*/
|
||||
Presenters,
|
||||
/**
|
||||
* Participants that have been speaking recently.
|
||||
*/
|
||||
Speakers,
|
||||
/**
|
||||
* Participants that have their hand raised.
|
||||
*/
|
||||
HandRaised,
|
||||
/**
|
||||
* Participants with video.
|
||||
*/
|
||||
Video,
|
||||
/**
|
||||
* Participants not sharing any video.
|
||||
*/
|
||||
NoVideo,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is off.
|
||||
*/
|
||||
SelfNotAlwaysShown,
|
||||
}
|
||||
|
||||
/**
|
||||
* A user media item to be presented in a tile. This is a thin wrapper around
|
||||
* UserMediaViewModel which additionally carries data relevant to the tile
|
||||
* layout algorithms (data which the MediaView component should be ignorant of).
|
||||
*/
|
||||
export type WrappedUserMediaViewModel = UserMediaViewModel & {
|
||||
/**
|
||||
* All screen share media associated with this user media.
|
||||
*/
|
||||
screenShares$: Behavior<ScreenShareViewModel[]>;
|
||||
/**
|
||||
* Which sorting bin the media item should be placed in.
|
||||
*/
|
||||
bin$: Behavior<SortingBin>;
|
||||
};
|
||||
|
||||
interface WrappedUserMediaInputs extends Omit<
|
||||
LocalUserMediaInputs & RemoteUserMediaInputs,
|
||||
"participant$"
|
||||
> {
|
||||
participant: TaggedParticipant;
|
||||
mediaDevices: MediaDevices;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createWrappedUserMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant,
|
||||
mediaDevices,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs
|
||||
}: WrappedUserMediaInputs,
|
||||
): WrappedUserMediaViewModel {
|
||||
const userMedia =
|
||||
participant.type === "local"
|
||||
? createLocalUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
mediaDevices,
|
||||
...inputs,
|
||||
})
|
||||
: createRemoteUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs,
|
||||
});
|
||||
|
||||
// TypeScript needs this widening of the type to happen in a separate statement
|
||||
const participant$: Behavior<LocalParticipant | RemoteParticipant | null> =
|
||||
participant.value$;
|
||||
|
||||
const screenShares$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? of([])
|
||||
: observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
`${inputs.id} screenShares$`,
|
||||
function* (p) {
|
||||
if (p.isScreenShareEnabled)
|
||||
yield {
|
||||
keys: ["screen-share"],
|
||||
data: undefined,
|
||||
};
|
||||
},
|
||||
(scope, _data$, key) => {
|
||||
const id = `${inputs.id}:${key}`;
|
||||
return participant.type === "local"
|
||||
? createLocalScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
})
|
||||
: createRemoteScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const speaker$ = scope.behavior(observeSpeaker$(userMedia.speaking$));
|
||||
const presenter$ = scope.behavior(
|
||||
screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
|
||||
);
|
||||
|
||||
return {
|
||||
...userMedia,
|
||||
screenShares$,
|
||||
bin$: scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
speaker$,
|
||||
presenter$,
|
||||
userMedia.videoEnabled$,
|
||||
userMedia.handRaised$,
|
||||
userMedia.local ? userMedia.alwaysShow$ : of<boolean | null>(null),
|
||||
],
|
||||
(speaker, presenter, video, handRaised, alwaysShow) => {
|
||||
if (alwaysShow !== null)
|
||||
return alwaysShow
|
||||
? SortingBin.SelfAlwaysShown
|
||||
: SortingBin.SelfNotAlwaysShown;
|
||||
else if (presenter) return SortingBin.Presenters;
|
||||
else if (speaker) return SortingBin.Speakers;
|
||||
else if (handRaised) return SortingBin.HandRaised;
|
||||
else if (video) return SortingBin.Video;
|
||||
else return SortingBin.NoVideo;
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel;
|
||||
@@ -9,6 +9,7 @@ import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
@@ -17,13 +18,13 @@ import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
mockLocalMedia,
|
||||
mockRemoteMedia,
|
||||
withTestScheduler,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { constant } from "./Behavior";
|
||||
mockRemoteScreenShare,
|
||||
} from "../../utils/test";
|
||||
import { constant } from "../Behavior";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
new (): MediaStreamTrack;
|
||||
@@ -35,7 +36,7 @@ global.MediaStream = class {} as unknown as {
|
||||
};
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
vi.mock("../../Platform", () => ({
|
||||
get platform(): string {
|
||||
return platformMock();
|
||||
},
|
||||
@@ -45,7 +46,7 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
@@ -54,33 +55,33 @@ test("control a participant's volume", () => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.setLocalVolume(0.6);
|
||||
vm.setLocalVolume(0.8);
|
||||
vm.commitLocalVolume();
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.setLocalVolume(0.2);
|
||||
vm.setLocalVolume(0);
|
||||
vm.commitLocalVolume();
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
@@ -92,23 +93,75 @@ test("control a participant's volume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
test("control a participant's screen share volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteScreenShare(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
b: () => vm.toggleFitContain(),
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.6,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.2,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.cropVideo$).toBe("abc", {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
d: 0.8,
|
||||
e: 0.2,
|
||||
f: 0,
|
||||
g: 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
const vm1 = createLocalMedia(
|
||||
const vm1 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
@@ -120,7 +173,7 @@ test("local media remembers whether it should always be shown", () => {
|
||||
});
|
||||
|
||||
// Next local media should start out *not* always shown
|
||||
const vm2 = createLocalMedia(
|
||||
const vm2 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
@@ -166,7 +219,7 @@ test("switch cameras", async () => {
|
||||
|
||||
const selectVideoInput = vi.fn();
|
||||
|
||||
const vm = createLocalMedia(
|
||||
const vm = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({
|
||||
@@ -184,7 +237,7 @@ test("switch cameras", async () => {
|
||||
);
|
||||
|
||||
// Switch to back camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
@@ -195,7 +248,7 @@ test("switch cameras", async () => {
|
||||
expect(deviceId).toBe("back camera");
|
||||
|
||||
// Switch to front camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
await waitFor(() => {
|
||||
@@ -206,17 +259,17 @@ test("switch cameras", async () => {
|
||||
});
|
||||
|
||||
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, null); // null participant
|
||||
expect(vm.waitingForMedia$.value).toBe(true);
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when participant is connected", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
expect(vm.waitingForMedia$.value).toBe(false);
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when participant is connected with no publications", () => {
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({
|
||||
@@ -228,7 +281,7 @@ test("remote media is not in waiting state when participant is connected with no
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
44
src/state/media/MediaViewModel.ts
Normal file
44
src/state/media/MediaViewModel.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||
|
||||
/**
|
||||
* A participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* Properties which are common to all MediaViewModels.
|
||||
*/
|
||||
export interface BaseMediaViewModel {
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The Matrix user to which this media belongs.
|
||||
*/
|
||||
userId: string;
|
||||
displayName$: Behavior<string>;
|
||||
mxcAvatarUrl$: Behavior<string | undefined>;
|
||||
}
|
||||
|
||||
export type BaseMediaInputs = BaseMediaViewModel;
|
||||
|
||||
// All this function does is strip out superfluous data from the input object
|
||||
export function createBaseMedia({
|
||||
id,
|
||||
userId,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
}: BaseMediaInputs): BaseMediaViewModel {
|
||||
return { id, userId, displayName$, mxcAvatarUrl$ };
|
||||
}
|
||||
272
src/state/media/MemberMediaViewModel.ts
Normal file
272
src/state/media/MemberMediaViewModel.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
type Participant,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
type AudioSource,
|
||||
roomEventSelector,
|
||||
type TrackReference,
|
||||
type VideoSource,
|
||||
} from "@livekit/components-core";
|
||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
|
||||
// TODO: Encryption status is kinda broken and thus unused right now. Remove?
|
||||
export enum EncryptionStatus {
|
||||
Connecting,
|
||||
Okay,
|
||||
KeyMissing,
|
||||
KeyInvalid,
|
||||
PasswordInvalid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Media belonging to an active member of the RTC session.
|
||||
*/
|
||||
export interface MemberMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
video$: Behavior<TrackReference | undefined>;
|
||||
/**
|
||||
* The URL of the LiveKit focus on which this member should be publishing.
|
||||
* Exposed for debugging.
|
||||
*/
|
||||
focusUrl$: Behavior<string | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
unencryptedWarning$: Behavior<boolean>;
|
||||
encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
}
|
||||
|
||||
export interface MemberMediaInputs extends BaseMediaViewModel {
|
||||
participant$: Behavior<LocalParticipant | RemoteParticipant | null>;
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>;
|
||||
audioSource: AudioSource;
|
||||
videoSource: VideoSource;
|
||||
focusUrl$: Behavior<string | undefined>;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
export function createMemberMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant$,
|
||||
livekitRoom$,
|
||||
audioSource,
|
||||
videoSource,
|
||||
focusUrl$,
|
||||
encryptionSystem,
|
||||
...inputs
|
||||
}: MemberMediaInputs,
|
||||
): MemberMediaViewModel {
|
||||
const trackBehavior$ = (
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReference | undefined> =>
|
||||
scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const audio$ = trackBehavior$(audioSource);
|
||||
const video$ = trackBehavior$(videoSource);
|
||||
|
||||
return {
|
||||
...createBaseMedia(inputs),
|
||||
video$,
|
||||
focusUrl$,
|
||||
unencryptedWarning$: scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
),
|
||||
),
|
||||
encryptionStatus$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function encryptionErrorObservable$(
|
||||
room$: Behavior<LivekitRoom | undefined>,
|
||||
participant: Participant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
criteria: string,
|
||||
): Observable<boolean> {
|
||||
return room$.pipe(
|
||||
switchMap((room) => {
|
||||
if (room === undefined) return of(false);
|
||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||
map((e) => {
|
||||
const [err] = e;
|
||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return (
|
||||
// Ideally we would pull the participant identity from the field on the error.
|
||||
// However, it gets lost in the serialization process between workers.
|
||||
// So, instead we do a string match
|
||||
(err?.message.includes(participant.identity) &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||
return !!err?.message.includes(criteria);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
|
||||
function observeRemoteTrackReceivingOkay$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<boolean | undefined> {
|
||||
let lastStats: {
|
||||
framesDecoded: number | undefined;
|
||||
framesDropped: number | undefined;
|
||||
framesReceived: number | undefined;
|
||||
} = {
|
||||
framesDecoded: undefined,
|
||||
framesDropped: undefined,
|
||||
framesReceived: undefined,
|
||||
};
|
||||
|
||||
return observeInboundRtpStreamStats$(participant, source).pipe(
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
const { framesDecoded, framesDropped, framesReceived } = stats;
|
||||
return {
|
||||
framesDecoded,
|
||||
framesDropped,
|
||||
framesReceived,
|
||||
};
|
||||
}),
|
||||
filter((newStats) => !!newStats),
|
||||
map((newStats): boolean | undefined => {
|
||||
const oldStats = lastStats;
|
||||
lastStats = newStats;
|
||||
if (
|
||||
typeof newStats.framesReceived === "number" &&
|
||||
typeof oldStats.framesReceived === "number" &&
|
||||
typeof newStats.framesDecoded === "number" &&
|
||||
typeof oldStats.framesDecoded === "number"
|
||||
) {
|
||||
const framesReceivedDelta =
|
||||
newStats.framesReceived - oldStats.framesReceived;
|
||||
const framesDecodedDelta =
|
||||
newStats.framesDecoded - oldStats.framesDecoded;
|
||||
|
||||
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||
|
||||
if (framesReceivedDelta > 0) {
|
||||
return framesDecodedDelta > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// no change
|
||||
return undefined;
|
||||
}),
|
||||
filter((x) => typeof x === "boolean"),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
72
src/state/media/RemoteScreenShareViewModel.ts
Normal file
72
src/state/media/RemoteScreenShareViewModel.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track, type RemoteParticipant } from "livekit-client";
|
||||
import { map, of, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
export interface RemoteScreenShareViewModel
|
||||
extends BaseScreenShareViewModel, VolumeControls {
|
||||
local: false;
|
||||
/**
|
||||
* Whether this screen share's video should be displayed.
|
||||
*/
|
||||
videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this screen share should be considered to have an audio track.
|
||||
*/
|
||||
audioEnabled$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createRemoteScreenShare(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
|
||||
): RemoteScreenShareViewModel {
|
||||
return {
|
||||
...createBaseScreenShare(scope, inputs),
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
map(
|
||||
(p) => (volume) =>
|
||||
p?.setVolume(volume, Track.Source.ScreenShareAudio),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
local: false,
|
||||
videoEnabled$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
||||
: of(null),
|
||||
),
|
||||
map(Boolean),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface RemoteUserMediaViewModel
|
||||
extends BaseUserMediaViewModel, VolumeControls {
|
||||
local: false;
|
||||
/**
|
||||
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||
* could be because either we or the remote party are still connecting.
|
||||
*/
|
||||
waitingForMedia$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
> {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createRemoteUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
|
||||
): RemoteUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "inbound-rtp",
|
||||
});
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||
),
|
||||
}),
|
||||
local: false,
|
||||
speaking$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : baseUserMedia.speaking$,
|
||||
),
|
||||
),
|
||||
),
|
||||
videoEnabled$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : baseUserMedia.videoEnabled$,
|
||||
),
|
||||
),
|
||||
),
|
||||
waitingForMedia$: scope.behavior(
|
||||
combineLatest(
|
||||
[inputs.livekitRoom$, inputs.participant$],
|
||||
(livekitRoom, participant) =>
|
||||
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||
// any transport and so we shouldn't expect a participant. (They might
|
||||
// be a subscribe-only bot for example.)
|
||||
livekitRoom !== undefined && participant === null,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
51
src/state/media/ScreenShareViewModel.ts
Normal file
51
src/state/media/ScreenShareViewModel.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track } from "livekit-client";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
type MemberMediaInputs,
|
||||
type MemberMediaViewModel,
|
||||
} from "./MemberMediaViewModel";
|
||||
import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel";
|
||||
|
||||
/**
|
||||
* A participant's screen share media.
|
||||
*/
|
||||
export type ScreenShareViewModel =
|
||||
| LocalScreenShareViewModel
|
||||
| RemoteScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* Properties which are common to all ScreenShareViewModels.
|
||||
*/
|
||||
export interface BaseScreenShareViewModel extends MemberMediaViewModel {
|
||||
type: "screen share";
|
||||
}
|
||||
|
||||
export type BaseScreenShareInputs = Omit<
|
||||
MemberMediaInputs,
|
||||
"audioSource" | "videoSource"
|
||||
>;
|
||||
|
||||
export function createBaseScreenShare(
|
||||
scope: ObservableScope,
|
||||
inputs: BaseScreenShareInputs,
|
||||
): BaseScreenShareViewModel {
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.ScreenShareAudio,
|
||||
videoSource: Track.Source.ScreenShare,
|
||||
}),
|
||||
type: "screen share",
|
||||
};
|
||||
}
|
||||
164
src/state/media/UserMediaViewModel.ts
Normal file
164
src/state/media/UserMediaViewModel.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { ParticipantEvent, Track } from "livekit-client";
|
||||
|
||||
import { type ReactionOption } from "../../reactions";
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
type MemberMediaInputs,
|
||||
type MemberMediaViewModel,
|
||||
} from "./MemberMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { showConnectionStats } from "../../settings/settings";
|
||||
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts";
|
||||
|
||||
/**
|
||||
* A participant's user media (i.e. their microphone and camera feed).
|
||||
*/
|
||||
export type UserMediaViewModel =
|
||||
| LocalUserMediaViewModel
|
||||
| RemoteUserMediaViewModel;
|
||||
|
||||
export interface BaseUserMediaViewModel extends MemberMediaViewModel {
|
||||
type: "user";
|
||||
speaking$: Behavior<boolean>;
|
||||
audioEnabled$: Behavior<boolean>;
|
||||
videoEnabled$: Behavior<boolean>;
|
||||
videoFit$: Behavior<"cover" | "contain">;
|
||||
toggleCropVideo: () => void;
|
||||
/**
|
||||
* The expected identity of the LiveKit participant. Exposed for debugging.
|
||||
*/
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
audioStreamStats$: Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
videoStreamStats$: Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
/**
|
||||
* Set the target dimensions of the HTML element (final dimension after anim).
|
||||
* This can be used to determine the best video fit (fit to frame / keep ratio).
|
||||
* @param targetWidth - The target width of the HTML element displaying the video.
|
||||
* @param targetHeight - The target height of the HTML element displaying the video.
|
||||
*/
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number) => void;
|
||||
}
|
||||
|
||||
export interface BaseUserMediaInputs extends Omit<
|
||||
MemberMediaInputs,
|
||||
"audioSource" | "videoSource"
|
||||
> {
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
statsType: "inbound-rtp" | "outbound-rtp";
|
||||
}
|
||||
|
||||
export function createBaseUserMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
statsType,
|
||||
...inputs
|
||||
}: BaseUserMediaInputs,
|
||||
): BaseUserMediaViewModel {
|
||||
const { participant$ } = inputs;
|
||||
const media$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
const toggleCropVideo$ = new Subject<void>();
|
||||
|
||||
// The target size of the video element, used to determine the best video fit.
|
||||
// The target size is the final size of the HTML element after any animations have completed.
|
||||
const targetSize$ = new BehaviorSubject<
|
||||
{ width: number; height: number } | undefined
|
||||
>(undefined);
|
||||
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.Microphone,
|
||||
videoSource: Track.Source.Camera,
|
||||
}),
|
||||
type: "user",
|
||||
speaking$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
),
|
||||
videoEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
),
|
||||
videoFit$: videoFit$(
|
||||
scope,
|
||||
videoSizeFromParticipant$(participant$),
|
||||
targetSize$,
|
||||
),
|
||||
toggleCropVideo: () => toggleCropVideo$.next(),
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
audioStreamStats$: combineLatest([
|
||||
participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
//
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeRtpStreamStats$(p, Track.Source.Microphone, statsType);
|
||||
}),
|
||||
),
|
||||
videoStreamStats$: combineLatest([
|
||||
participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
||||
}),
|
||||
),
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number): void => {
|
||||
targetSize$.next({ width: targetWidth, height: targetHeight });
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/state/media/observeRtpStreamStats.ts
Normal file
78
src/state/media/observeRtpStreamStats.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalTrack,
|
||||
type Participant,
|
||||
RemoteTrack,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
interval,
|
||||
type Observable,
|
||||
startWith,
|
||||
switchMap,
|
||||
map,
|
||||
} from "rxjs";
|
||||
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
export function observeRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
type: "inbound-rtp" | "outbound-rtp",
|
||||
): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
if (
|
||||
!track ||
|
||||
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const report = await track.getRTCStatsReport();
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const v of report.values()) {
|
||||
if (v.type === type) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeInboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeOutboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
||||
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
import { observeSpeaker$ } from "./observeSpeaker";
|
||||
|
||||
const yesNo = {
|
||||
28
src/state/observeTrackReference.ts
Normal file
28
src/state/observeTrackReference.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
observeParticipantMedia,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import { type Participant, type Track } from "livekit-client";
|
||||
import { distinctUntilChanged, map, type Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* Reactively reads a participant's track reference for a given media source.
|
||||
*/
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { GridTile } from "./GridTile";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
createRemoteMedia,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
@@ -29,7 +29,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
VolumeOffIcon,
|
||||
VisibilityOnIcon,
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
VolumeOffSolidIcon,
|
||||
SwitchCameraSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
@@ -39,11 +39,6 @@ import {
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
type UserMediaViewModel,
|
||||
LocalUserMediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { Slider } from "../Slider";
|
||||
import { MediaView } from "./MediaView";
|
||||
import { useLatest } from "../useLatest";
|
||||
@@ -51,6 +46,9 @@ import { type GridTileViewModel } from "../state/TileViewModel";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -68,7 +66,7 @@ interface TileProps {
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
playbackMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
@@ -79,7 +77,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
playbackMuted,
|
||||
waitingForMedia,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
@@ -89,6 +87,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
focusable,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
...props
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
@@ -105,24 +105,25 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
|
||||
const rtcBackendIdentity = vm.rtcBackendIdentity;
|
||||
const handRaised = useBehavior(vm.handRaised$);
|
||||
const reaction = useBehavior(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
// Whenever bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||
}
|
||||
}, [targetWidth, targetHeight, vm]);
|
||||
|
||||
const AudioIcon = playbackMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
const audioIconLabel = playbackMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
@@ -132,12 +133,10 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{/*
|
||||
No additional menu item (used to be the manual fit to frame.
|
||||
Placeholder for future menu items that should be placed here.
|
||||
*/}
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
@@ -156,7 +155,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
videoFit={videoFit}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
@@ -166,7 +165,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
data-muted={playbackMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
@@ -202,6 +201,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
rtcBackendIdentity={rtcBackendIdentity}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -245,7 +246,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
playbackMuted={false}
|
||||
mirror={mirror}
|
||||
primaryButton={
|
||||
switchCamera === null ? undefined : (
|
||||
@@ -295,36 +296,31 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||
const localVolume = useBehavior(vm.localVolume$);
|
||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||
const onSelectMute = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const onChangeLocalVolume = useCallback(
|
||||
(v: number) => vm.setLocalVolume(v),
|
||||
[vm],
|
||||
);
|
||||
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
|
||||
|
||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
waitingForMedia={waitingForMedia}
|
||||
locallyMuted={locallyMuted}
|
||||
playbackMuted={playbackMuted}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
<>
|
||||
<ToggleMenuItem
|
||||
Icon={MicOffIcon}
|
||||
label={t("video_tile.mute_for_me")}
|
||||
checked={locallyMuted}
|
||||
checked={playbackMuted}
|
||||
onSelect={onSelectMute}
|
||||
/>
|
||||
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
||||
@@ -332,9 +328,9 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={t("video_tile.volume")}
|
||||
value={localVolume}
|
||||
onValueChange={onChangeLocalVolume}
|
||||
onValueCommit={onCommitLocalVolume}
|
||||
value={playbackVolume}
|
||||
onValueChange={vm.adjustPlaybackVolume}
|
||||
onValueCommit={vm.commitPlaybackVolume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
@@ -374,7 +370,7 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
const displayName = useBehavior(media.displayName$);
|
||||
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
if (media.local) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TrackInfo } from "@livekit/protocol";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
import { EncryptionStatus } from "../state/MediaViewModel";
|
||||
import { EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||
import { mockLocalParticipant } from "../utils/test";
|
||||
|
||||
describe("MediaView", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user