mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
303 Commits
toger5/pla
...
reactions-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0842b2b472 | ||
|
|
fae3c4195f | ||
|
|
208184909e | ||
|
|
aea5815dab | ||
|
|
40dacd523b | ||
|
|
44958db514 | ||
|
|
330584ac31 | ||
|
|
2bca491b0c | ||
|
|
9764f6f5de | ||
|
|
69bcdc1bea | ||
|
|
27b42ca641 | ||
|
|
417164cc11 | ||
|
|
2a5acc0207 | ||
|
|
08006d640a | ||
|
|
58d377c3f7 | ||
|
|
6c8219a26b | ||
|
|
efd1b42da0 | ||
|
|
31b35463fc | ||
|
|
89c4485f2d | ||
|
|
3c2c189830 | ||
|
|
2c34e681a1 | ||
|
|
48226795de | ||
|
|
3503f8177d | ||
|
|
9fe35ba822 | ||
|
|
a84419c8a4 | ||
|
|
a89d231858 | ||
|
|
6d99450d81 | ||
|
|
47e14ff574 | ||
|
|
55eea7a968 | ||
|
|
121160e835 | ||
|
|
47cb01ab6f | ||
|
|
3d286e8ab4 | ||
|
|
b1f84a34a8 | ||
|
|
95eb8943af | ||
|
|
b5be123953 | ||
|
|
90bfaecd13 | ||
|
|
4cecdda955 | ||
|
|
869bab8253 | ||
|
|
23f846a308 | ||
|
|
6dcb470162 | ||
|
|
c5c154c99b | ||
|
|
fd08489afb | ||
|
|
67d7ec6703 | ||
|
|
a2005febd5 | ||
|
|
5e337a5399 | ||
|
|
e0a8e84df4 | ||
|
|
99ba23bc96 | ||
|
|
f7488a0474 | ||
|
|
bb7b2d1060 | ||
|
|
b5870c9e78 | ||
|
|
1fce220965 | ||
|
|
cd3c8f001f | ||
|
|
f316ece404 | ||
|
|
ce1cd9aefb | ||
|
|
d5ec3c342c | ||
|
|
c9242148f9 | ||
|
|
0dabb43e0f | ||
|
|
8cee4df46e | ||
|
|
6b7467ce6d | ||
|
|
c9eab59161 | ||
|
|
4cf75c8eeb | ||
|
|
2de151d94d | ||
|
|
3389fb5a34 | ||
|
|
421c49e7ba | ||
|
|
1eabb0f0aa | ||
|
|
8f38ecac2e | ||
|
|
40fdef89eb | ||
|
|
2ab5e07f73 | ||
|
|
09092e97c8 | ||
|
|
cb6bef3849 | ||
|
|
4d491be4a9 | ||
|
|
aad160a715 | ||
|
|
fc61a36d4a | ||
|
|
1bc2abb84f | ||
|
|
4f518819d3 | ||
|
|
b40feae7da | ||
|
|
e15761d8ee | ||
|
|
c60ed50a9d | ||
|
|
6dcd883fc3 | ||
|
|
c0d60b2c29 | ||
|
|
c8b8d350d5 | ||
|
|
9c2b2d4780 | ||
|
|
396225a900 | ||
|
|
c4ec52ae15 | ||
|
|
4be2bc7560 | ||
|
|
385ab0a0ed | ||
|
|
4d69565312 | ||
|
|
fa844446b6 | ||
|
|
9dfade68ee | ||
|
|
32ea8f522c | ||
|
|
6d14f1d06f | ||
|
|
997cc9bb27 | ||
|
|
fa74aaa81e | ||
|
|
af807489f9 | ||
|
|
5cf5227eaa | ||
|
|
78240c2ec8 | ||
|
|
bf8bf80417 | ||
|
|
7da9bca08c | ||
|
|
b198721969 | ||
|
|
8ecb1b3dbf | ||
|
|
748c8e6d0d | ||
|
|
40ef16a55f | ||
|
|
07ad8374a9 | ||
|
|
a0d5c79999 | ||
|
|
3e171d9639 | ||
|
|
413329cd26 | ||
|
|
6b8f6e9405 | ||
|
|
af54b39698 | ||
|
|
c05d223133 | ||
|
|
c7f25feb66 | ||
|
|
a20edca9a1 | ||
|
|
d00ff78d65 | ||
|
|
839c4dd738 | ||
|
|
41f7b643fb | ||
|
|
3a9d394529 | ||
|
|
c9557e91d5 | ||
|
|
1e400bc550 | ||
|
|
6485da8fff | ||
|
|
54bef07b3b | ||
|
|
273eedd256 | ||
|
|
38382539ad | ||
|
|
8db1c4c370 | ||
|
|
c98d03e2e1 | ||
|
|
143b560b20 | ||
|
|
699e31f59a | ||
|
|
3da762ab36 | ||
|
|
efcde9edfe | ||
|
|
ca3837f44e | ||
|
|
467675ff40 | ||
|
|
5a612fea91 | ||
|
|
17ec488f0b | ||
|
|
8f418ce9fd | ||
|
|
4c7fad30c0 | ||
|
|
4922249f41 | ||
|
|
06c15ded37 | ||
|
|
885a523e91 | ||
|
|
313b8285d9 | ||
|
|
d00e1ca983 | ||
|
|
513477d280 | ||
|
|
273fff20bd | ||
|
|
005b965fba | ||
|
|
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 | ||
|
|
3eb88ec4a8 | ||
|
|
745772f672 | ||
|
|
486c0b884b | ||
|
|
9f323d21a8 | ||
|
|
a72aae0b8d | ||
|
|
b1ceeda9ee | ||
|
|
ed8cee9f58 | ||
|
|
3a5b9dd519 | ||
|
|
a985281b46 | ||
|
|
a7008d37c6 | ||
|
|
94bcb1bb27 | ||
|
|
36a1aa3c59 | ||
|
|
b4ca55f7df | ||
|
|
a102b3f4aa | ||
|
|
d8802aa079 | ||
|
|
4833d4b867 | ||
|
|
abb80e4e87 | ||
|
|
2c3365a401 | ||
|
|
2817362e15 | ||
|
|
95b730e93f | ||
|
|
c2eea67b35 | ||
|
|
202e06a16d | ||
|
|
e278752942 | ||
|
|
89c878cf3f | ||
|
|
f41175444b | ||
|
|
102664482b | ||
|
|
d5df9e65dd | ||
|
|
f7255b3697 | ||
|
|
3f8b3ba3f1 | ||
|
|
a99a413b88 | ||
|
|
83df152608 | ||
|
|
c376d9e5c1 | ||
|
|
f09e2c8b55 | ||
|
|
f5c31626a6 | ||
|
|
5a48820927 | ||
|
|
971d16d243 | ||
|
|
9b40d7420a | ||
|
|
f71e483080 | ||
|
|
f7590a33d7 | ||
|
|
c74accb906 | ||
|
|
b49411abfa | ||
|
|
dbdcde2489 | ||
|
|
636f737cd3 | ||
|
|
93c9225e8a | ||
|
|
582d1d8495 | ||
|
|
eb60473510 | ||
|
|
c35b960627 | ||
|
|
df2f503a05 | ||
|
|
c20b206ab2 | ||
|
|
5ceb140d13 | ||
|
|
ed4517703f | ||
|
|
bd3e91738e |
@@ -15,6 +15,7 @@ module.exports = {
|
||||
"plugin:matrix-org/typescript",
|
||||
"prettier",
|
||||
"plugin:rxjs/recommended",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
|
||||
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 }}
|
||||
|
||||
10
.github/workflows/changelog-label.yml
vendored
10
.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]
|
||||
types: [labeled, unlabeled, opened, synchronize]
|
||||
|
||||
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: |
|
||||
|
||||
89
.github/workflows/publish-embedded-packages.yaml
vendored
89
.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,12 +92,12 @@ 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
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -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,12 +288,16 @@ 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
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
10
.github/workflows/publish.yaml
vendored
10
.github/workflows/publish.yaml
vendored
@@ -38,11 +38,11 @@ jobs:
|
||||
name: build-output-full
|
||||
path: ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner --transform "s/dist/${{ 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
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -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: |
|
||||
@@ -69,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
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@75cd11691c0faa626561e295848008c8a7dddffe # 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ yarn-error.log
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
8
.storybook/main.ts
Normal file
8
.storybook/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-docs"],
|
||||
framework: "@storybook/react-vite",
|
||||
};
|
||||
export default config;
|
||||
24
.storybook/manager.ts
Normal file
24
.storybook/manager.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from "storybook/theming";
|
||||
import { addons } from "storybook/manager-api";
|
||||
|
||||
addons.setConfig({
|
||||
theme: create({
|
||||
base: "light",
|
||||
colorPrimary: "#1b1d22",
|
||||
colorSecondary: "#0467dd",
|
||||
|
||||
// Typography
|
||||
fontBase: '"Inter", sans-serif',
|
||||
fontCode: '"Inconsolata", monospace',
|
||||
|
||||
// Text colors
|
||||
textColor: "#1b1d22",
|
||||
appBg: "#ffffff",
|
||||
barBg: "#ffffff",
|
||||
|
||||
brandTitle: "Element Call",
|
||||
brandUrl: "https://element.io/",
|
||||
brandImage: "/src/icons/Logo.svg",
|
||||
brandTarget: "_self",
|
||||
}),
|
||||
});
|
||||
49
.storybook/preview.tsx
Normal file
49
.storybook/preview.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import "../src/index.css";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en"],
|
||||
// We embed the translations, so that it never needs to fetch
|
||||
resources: {
|
||||
en: {
|
||||
translation: EN,
|
||||
},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
})
|
||||
.catch((e) => logger.warn("Failed to init i18n for stories", e));
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipProvider>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
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",
|
||||
@@ -249,12 +249,14 @@
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"call_ended": "Call ended",
|
||||
"calling": "Calling…",
|
||||
"camera_starting": "Video loading...",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
}
|
||||
|
||||
40
package.json
40
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 .",
|
||||
@@ -32,7 +33,9 @@
|
||||
"test:playwright": "playwright test",
|
||||
"test:playwright:open": "yarn test:playwright --ui",
|
||||
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
|
||||
"links:disable": "mv .links.yaml .links.disabled.yaml"
|
||||
"links:disable": "mv .links.yaml .links.disabled.yaml",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
@@ -42,12 +45,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",
|
||||
@@ -56,6 +59,8 @@
|
||||
"@react-spring/web": "^10.0.0",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/vite-plugin": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.3.3",
|
||||
"@storybook/react-vite": "^10.3.3",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@testing-library/dom": "^10.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -75,17 +80,17 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-design-tokens": "^9.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",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-deprecate": "^0.8.2",
|
||||
"eslint-plugin-deprecate": "^0.9.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
@@ -93,6 +98,7 @@
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-rxjs": "^5.0.3",
|
||||
"eslint-plugin-storybook": "^10.3.3",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"fetch-mock": "11.1.5",
|
||||
"global-jsdom": "^26.0.0",
|
||||
@@ -100,12 +106,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#develop",
|
||||
"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,27 +123,33 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^16.0.0 <16.1.0",
|
||||
"react-i18next": "^16.0.0 <16.7.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.42.1",
|
||||
"storybook": "^10.3.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^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"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
|
||||
test("Sign up a new account, then login, then logout", async ({ browser }) => {
|
||||
const userId = `test_user-id_${Date.now()}`;
|
||||
|
||||
@@ -69,12 +71,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
// ========
|
||||
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||
// ========
|
||||
await creatorPage.getByTestId("home_callName").click();
|
||||
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||
await creatorPage.getByTestId("home_displayName").click();
|
||||
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||
await creatorPage.getByTestId("home_go").click();
|
||||
await expect(creatorPage.locator("video")).toBeVisible();
|
||||
await SpaHelpers.createCall(creatorPage, "Inviter", "Welcome");
|
||||
|
||||
// join
|
||||
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||
@@ -82,19 +79,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// Get the invite link
|
||||
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await creatorPage.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(creatorPage);
|
||||
|
||||
// ========
|
||||
// ACT: The other user use the invite link to join the call as a guest
|
||||
@@ -103,13 +88,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
|
||||
await guestPage.goto(inviteLink);
|
||||
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await guestPage.getByTestId("joincall_joincall").click();
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
await guestPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink);
|
||||
|
||||
// ========
|
||||
// ASSERT: check that there are two members in the call
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -100,8 +100,16 @@ mobileTest(
|
||||
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||
{ id: "headphones", name: "Headphones" },
|
||||
]);
|
||||
window.controls.setAudioDevice("earpiece");
|
||||
});
|
||||
|
||||
// Open settings to select earpiece
|
||||
await guestPage.getByRole("button", { name: "Settings" }).click();
|
||||
await guestPage.getByText("Handset", { exact: true }).click();
|
||||
|
||||
// dismiss settings
|
||||
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
|
||||
|
||||
await guestPage.pause();
|
||||
await expect(
|
||||
guestPage.getByRole("heading", { name: "Handset Mode" }),
|
||||
).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();
|
||||
});
|
||||
|
||||
135
playwright/spa-call-sticky.spec.ts
Normal file
135
playwright/spa-call-sticky.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
expect,
|
||||
type Page,
|
||||
test,
|
||||
type Request,
|
||||
type Browser,
|
||||
} from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
let androlHasSentStickyEvent = false;
|
||||
|
||||
await interceptEventSend(
|
||||
page,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
(req) => {
|
||||
androlHasSentStickyEvent =
|
||||
androlHasSentStickyEvent || isStickySend(req.url());
|
||||
},
|
||||
);
|
||||
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// Other
|
||||
const guestInviteeContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
await interceptEventSend(
|
||||
guestPage,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
(req) => {
|
||||
pevaraHasSentStickyEvent =
|
||||
pevaraHasSentStickyEvent || isStickySend(req.url());
|
||||
},
|
||||
);
|
||||
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
inviteLink,
|
||||
"Pevara",
|
||||
"2_0",
|
||||
);
|
||||
// Assert both sides have sent sticky membership events
|
||||
expect(androlHasSentStickyEvent).toEqual(true);
|
||||
expect(pevaraHasSentStickyEvent).toEqual(true);
|
||||
|
||||
return { guestPage };
|
||||
}
|
||||
|
||||
test("One to One call using matrix rtc 2.0 aka sticky events", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
});
|
||||
|
||||
// This issue occurs when a member leave but does not clean up their sticky event.
|
||||
// If they rejoin they will use a new stickye key (stickyKey = member.id = UUID())
|
||||
// We end up with two memberships with the same user and device id. This previously
|
||||
// was a impossible case since that would be the same state event. Now its possible.
|
||||
// We need to ALWAYS key by userId, deviceId and member.id. This test checks that.
|
||||
test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
await guestPage.reload();
|
||||
await expect(guestPage.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Check if rejoining with the same browser context (device) breaks EC.
|
||||
// This has happened on versions that do not consider the member.id as part of the key for a media tile.
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3);
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
|
||||
});
|
||||
|
||||
function isStickySend(url: string): boolean {
|
||||
return !!new URL(url).searchParams.get(
|
||||
"org.matrix.msc4354.sticky_duration_ms",
|
||||
);
|
||||
}
|
||||
|
||||
async function interceptEventSend(
|
||||
page: Page,
|
||||
eventType: string,
|
||||
callback: (request: Request) => void,
|
||||
): Promise<void> {
|
||||
await page.route(
|
||||
`**/_matrix/client/v3/rooms/**/send/${eventType}/**`,
|
||||
async (route, req) => {
|
||||
callback(req);
|
||||
return route.continue();
|
||||
},
|
||||
);
|
||||
}
|
||||
148
playwright/spa-helpers.ts
Normal file
148
playwright/spa-helpers.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import { type RtcMode } from "./widget/test-helpers.ts";
|
||||
|
||||
/**
|
||||
* Create and join a call from the SPA home page.
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
* @param userName - The display name to use for the call
|
||||
* @param callName - The name of the call to create
|
||||
* @param autoJoin - Whether to automatically join the call after creating it
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function createCall(
|
||||
page: Page,
|
||||
userName: string,
|
||||
callName: string,
|
||||
autoJoin: boolean = false,
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill(callName);
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill(userName);
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
await expect(page.locator("video")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
if (autoJoin) {
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the invite link for the current call.
|
||||
*/
|
||||
async function getCallInviteLink(page: Page): Promise<string> {
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await page.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await page.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
return inviteLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a call from an invitation link.
|
||||
* @param page - The Playwright page object
|
||||
* @param inviteLink - The invite link to join
|
||||
* @param displayName - The display name to use when joining the call
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function joinCallFromInviteLink(
|
||||
page: Page,
|
||||
inviteLink: string,
|
||||
displayName: string = "Invitee",
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.goto(inviteLink);
|
||||
await page.getByTestId("joincall_displayName").fill(displayName);
|
||||
await expect(page.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await page.getByTestId("joincall_joincall").click();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.getByRole("radio", { name: "Spotlight" }).check();
|
||||
}
|
||||
|
||||
async function setRtcModeFromSettings(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Preferences" }).click();
|
||||
await page.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
|
||||
|
||||
// Move to Developer tab now
|
||||
await page.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await page.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
await page.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
await page.getByText("Compatibility: state events").click();
|
||||
}
|
||||
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await page.locator("video").all();
|
||||
expect(videoElements.length).toBe(count);
|
||||
|
||||
const blockDisplayCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(count);
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
92
playwright/widget/federation-oldest-membership-bug.spec.ts
Normal file
92
playwright/widget/federation-oldest-membership-bug.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
widgetTest(
|
||||
"Bug new joiner was not publishing on correct SFU",
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"This is a bug in the old widget, not a browser problem.",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
// 2 users in federation
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
// Florian creates a room and invites Timo to it
|
||||
const roomName = "Call Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
// Timo joins the room
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
// Ensure we are in legacy mode (should be the default)
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
// Let timo create a call
|
||||
await TestHelpers.startCallInCurrentRoom(timo.page, false);
|
||||
await TestHelpers.joinCallFromLobby(timo.page);
|
||||
|
||||
// We want to simulate that the oldest membership authentication is way slower than
|
||||
// the preffered auth.
|
||||
// In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian
|
||||
await florian.page.route(
|
||||
"**/matrix-rtc.othersite.m.localhost/livekit/jwt/**",
|
||||
async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
// Florian joins the call
|
||||
await expect(florian.page.getByTestId("join-call-button")).toBeVisible();
|
||||
await florian.page.getByTestId("join-call-button").click();
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
await florian.page.waitForTimeout(3000);
|
||||
await timo.page.waitForTimeout(3000);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -27,9 +27,6 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
// ALWAYS SKIPT THE TEST SINCE IT IS EXPECTED TO FAIL.
|
||||
// confirmed locally that its failing without: https://github.com/element-hq/element-call/pull/3675
|
||||
test.skip(true);
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
|
||||
@@ -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).not.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");
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
// Skip test, including Fixtures
|
||||
widgetTest.skip(
|
||||
@@ -20,19 +21,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await expect(
|
||||
brooks.page.getByRole("button", { name: "Video call" }),
|
||||
).toBeVisible();
|
||||
await brooks.page.getByRole("button", { name: "Video call" }).click();
|
||||
|
||||
await expect(
|
||||
brooks.page.getByRole("menuitem", { name: "Legacy Call" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
brooks.page.getByRole("menuitem", { name: "Element Call" }),
|
||||
).toBeVisible();
|
||||
|
||||
await brooks.page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
|
||||
await expect(
|
||||
brooks.page
|
||||
@@ -56,11 +45,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
).toBeVisible();
|
||||
|
||||
// Join from the other side
|
||||
await expect(whistler.page.getByText("Video call started")).toBeVisible();
|
||||
await expect(
|
||||
whistler.page.getByRole("button", { name: "Join" }),
|
||||
).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
await TestHelpers.joinCallInCurrentRoom(whistler.page);
|
||||
|
||||
// Currently disabled due to recent Element Web is bypassing Lobby
|
||||
// await expect(
|
||||
|
||||
@@ -152,6 +152,22 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
|
||||
const toast = page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByText("Back up your chats");
|
||||
|
||||
try {
|
||||
await expect(toast).toBeVisible({ timeout: 700 });
|
||||
await page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
} catch {
|
||||
// toast not visible, continue as normal
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -167,6 +183,7 @@ export class TestHelpers {
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible();
|
||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
||||
|
||||
// Invite users if any
|
||||
if (andInvite.length > 0) {
|
||||
@@ -201,6 +218,7 @@ export class TestHelpers {
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("heading", { name: roomName }),
|
||||
).toBeVisible();
|
||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,9 +235,11 @@ export class TestHelpers {
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the settings to set the RTC mode.
|
||||
* then closes the settings modal.
|
||||
@@ -276,4 +296,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,12 @@ widgetTest(
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing overlay, let's check for that
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
@@ -51,34 +54,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 +95,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(
|
||||
@@ -125,9 +127,12 @@ widgetTest(
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing overlay, let's check for that
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
@@ -142,34 +147,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 +182,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(
|
||||
@@ -216,9 +214,12 @@ widgetTest(
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing overlay, let's check for that
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
12
src/@types/mdx.d.ts
vendored
Normal file
12
src/@types/mdx.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
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 { JSX as ReactJSX } from "react";
|
||||
|
||||
declare module "mdx/types.js" {
|
||||
export import JSX = ReactJSX;
|
||||
}
|
||||
@@ -9,14 +9,18 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import { widget } from "./widget";
|
||||
|
||||
const TestComponent: FC<
|
||||
PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }>
|
||||
> = ({ client, children, supportsThumbnails }) => {
|
||||
PropsWithChildren<{
|
||||
client: MatrixClient;
|
||||
}>
|
||||
> = ({ client, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
@@ -24,7 +28,6 @@ const TestComponent: FC<
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
thumbnails: supportsThumbnails ?? true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
@@ -40,6 +43,12 @@ const TestComponent: FC<
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("./widget", () => ({
|
||||
widget: {
|
||||
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -73,36 +82,7 @@ test("should just render a placeholder when the user has no avatar", () => {
|
||||
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test("should just render a placeholder when thumbnails are not supported", () => {
|
||||
const client = vi.mocked<MatrixClient>({
|
||||
getAccessToken: () => "my-access-token",
|
||||
mxcUrlToHttp: () => vi.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
vi.spyOn(client, "mxcUrlToHttp");
|
||||
const member = mockMatrixRoomMember(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
getMxcAvatarUrl: () => "mxc://example.org/alice-avatar",
|
||||
},
|
||||
);
|
||||
const displayName = "Alice";
|
||||
render(
|
||||
<TestComponent client={client} supportsThumbnails={false}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
size={96}
|
||||
src={member.getMxcAvatarUrl()}
|
||||
/>
|
||||
</TestComponent>,
|
||||
);
|
||||
const element = screen.getByRole("img", { name: "@alice:example.org" });
|
||||
expect(element.tagName).toEqual("SPAN");
|
||||
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test("should attempt to fetch authenticated media", async () => {
|
||||
test("should attempt to fetch authenticated media from the server", async () => {
|
||||
const expectedAuthUrl = "http://example.org/media/alice-avatar";
|
||||
const expectedObjectURL = "my-object-url";
|
||||
const accessToken = "my-access-token";
|
||||
@@ -154,3 +134,80 @@ test("should attempt to fetch authenticated media", async () => {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
});
|
||||
|
||||
test("should attempt to use widget API if running as a widget", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedObjectURL = "my-object-url";
|
||||
const theBlob = new Blob([]);
|
||||
|
||||
// vitest doesn't have a implementation of create/revokeObjectURL, so we need
|
||||
// to delete the property. It's a bit odd, but it works.
|
||||
Reflect.deleteProperty(global.window.URL, "createObjectURL");
|
||||
globalThis.URL.createObjectURL = vi.fn().mockReturnValue(expectedObjectURL);
|
||||
Reflect.deleteProperty(global.window.URL, "revokeObjectURL");
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
const client = vi.mocked<MatrixClient>({
|
||||
getAccessToken: () => undefined,
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
||||
const member = mockMatrixRoomMember(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
getMxcAvatarUrl: () => expectedMXCUrl,
|
||||
},
|
||||
);
|
||||
const displayName = "Alice";
|
||||
render(
|
||||
<TestComponent client={client}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
size={96}
|
||||
src={member.getMxcAvatarUrl()}
|
||||
/>
|
||||
</TestComponent>,
|
||||
);
|
||||
|
||||
// Fetch is asynchronous, so wait for this to resolve.
|
||||
await vi.waitUntil(() =>
|
||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||
);
|
||||
|
||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
test("Supports download files as base64", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
|
||||
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
|
||||
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
|
||||
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
|
||||
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
|
||||
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
|
||||
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
|
||||
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
|
||||
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
|
||||
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
|
||||
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
|
||||
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
|
||||
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
|
||||
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
|
||||
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
|
||||
"YII=";
|
||||
const mockWidgetAPI = {
|
||||
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
|
||||
if (contentUri !== expectedMXCUrl) {
|
||||
return Promise.reject(new Error("Unexpected content URI"));
|
||||
}
|
||||
return { file: expectedBase64 };
|
||||
}),
|
||||
} as unknown as WidgetApi;
|
||||
|
||||
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
} from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { widget } from "./widget";
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
@@ -78,50 +80,54 @@ export const Avatar: FC<Props> = ({
|
||||
const sizePx = useMemo(
|
||||
() =>
|
||||
Object.values(Size).includes(size as Size)
|
||||
? sizes.get(size as Size)
|
||||
? sizes.get(size as Size)!
|
||||
: (size as number),
|
||||
[size],
|
||||
);
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (clientState?.state !== "valid") {
|
||||
return;
|
||||
}
|
||||
const { authenticated, supportedFeatures } = clientState;
|
||||
const client = authenticated?.client;
|
||||
|
||||
if (!client || !src || !sizePx || !supportedFeatures.thumbnails) {
|
||||
if (!src) {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = client.getAccessToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const resolveSrc = getAvatarUrl(client, src, sizePx);
|
||||
if (!resolveSrc) {
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (widget?.api) {
|
||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | undefined;
|
||||
fetch(resolveSrc, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then(async (req) => req.blob())
|
||||
let stale = false;
|
||||
blob
|
||||
.then((blob) => {
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setAvatarUrl(objectUrl);
|
||||
})
|
||||
.catch((ex) => {
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
setAvatarUrl(undefined);
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
stale = true;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
@@ -140,3 +146,50 @@ export const Avatar: FC<Props> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
async function getAvatarFromServer(
|
||||
client: MatrixClient,
|
||||
src: string,
|
||||
sizePx: number,
|
||||
): Promise<Blob> {
|
||||
const httpSrc = getAvatarUrl(client, src, sizePx);
|
||||
if (!httpSrc) {
|
||||
throw new Error("Failed to get http avatar URL");
|
||||
}
|
||||
|
||||
const token = client.getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
const request = await fetch(httpSrc, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await request.blob();
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
// export for testing
|
||||
export async function getAvatarFromWidgetAPI(
|
||||
api: WidgetApi,
|
||||
src: string,
|
||||
): Promise<Blob> {
|
||||
const response = await api.downloadFile(src);
|
||||
const file = response.file;
|
||||
|
||||
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
|
||||
if (file instanceof Blob) {
|
||||
return file;
|
||||
} else if (typeof file === "string") {
|
||||
// it is a base64 string
|
||||
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
throw new Error(
|
||||
"Downloaded file format is not supported: " + typeof file + "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export type ValidClientState = {
|
||||
disconnected: boolean;
|
||||
supportedFeatures: {
|
||||
reactions: boolean;
|
||||
thumbnails: boolean;
|
||||
};
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
};
|
||||
@@ -249,7 +248,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
@@ -275,7 +273,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
thumbnails: supportsThumbnails,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
@@ -286,7 +283,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
setClient,
|
||||
isDisconnected,
|
||||
supportsReactions,
|
||||
supportsThumbnails,
|
||||
]);
|
||||
|
||||
const onSync = useCallback(
|
||||
@@ -312,8 +308,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
}
|
||||
|
||||
if (initClientState.widgetApi) {
|
||||
// There is currently no widget API for authenticated media thumbnails.
|
||||
setSupportsThumbnails(false);
|
||||
const reactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
);
|
||||
@@ -335,7 +329,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
}
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
setSupportsThumbnails(true);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export abstract class TranslatedError extends Error {
|
||||
messageKey: ParseKeys<DefaultNamespace, TOptions>,
|
||||
translationFn: TFunction<DefaultNamespace>,
|
||||
) {
|
||||
super(translationFn(messageKey, { lng: "en" } as TOptions));
|
||||
super(translationFn(messageKey, { lng: "en" }));
|
||||
this.translatedMessage = translationFn(messageKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -14,7 +14,7 @@ exports[`AppBar > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_0_"
|
||||
class="_icon-button_1pz9o_8"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -33,12 +33,38 @@ export interface Controls {
|
||||
showNativeOutputDevicePicker?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Audio device when using the controlled audio output mode (mobile).
|
||||
*/
|
||||
export interface OutputDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
/**
|
||||
* `forEarpiece` in an iOS only flag, that will be set on the default speaker device.
|
||||
* The default speaker device will be used for the earpiece mode by
|
||||
* using a stereo pan and reducing the volume significantly. (in combination this is similar to a dedicated earpiece mode)
|
||||
* - on iOS this is true if output is routed to speaker.
|
||||
* In that case then ElementCalls manually appends an earpiece device with id `EARPIECE_CONFIG_ID` and `{ type: "earpiece" }`
|
||||
* - on Android this is unused.
|
||||
*/
|
||||
forEarpiece?: boolean;
|
||||
/**
|
||||
* Is the device the OS earpiece audio configuration?
|
||||
* - on iOS always undefined
|
||||
* - on Android true for the `TYPE_BUILTIN_EARPIECE`
|
||||
*/
|
||||
isEarpiece?: boolean;
|
||||
/**
|
||||
* Is the device the OS default speaker:
|
||||
* - on iOS always true if output is routed to speaker. In other case iOS on declare a `dummy` id device.
|
||||
* - on Android true for the `TYPE_BUILTIN_SPEAKER`
|
||||
*/
|
||||
isSpeaker?: boolean;
|
||||
/**
|
||||
* Is the device the OS default external headset (bluetooth):
|
||||
* - on iOS always undefined.
|
||||
* - on Android true for the `TYPE_BLUETOOTH_SCO`
|
||||
*/
|
||||
isExternalHeadset?: boolean;
|
||||
}
|
||||
|
||||
@@ -47,8 +73,16 @@ export interface OutputDevice {
|
||||
*/
|
||||
export const setPipEnabled$ = new Subject<boolean>();
|
||||
|
||||
/**
|
||||
* Stores the list of available controlled audio output devices.
|
||||
* This is set when the native code calls `setAvailableAudioDevices` with the list of available audio output devices.
|
||||
*/
|
||||
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
|
||||
|
||||
/**
|
||||
* Stores the current audio output device id.
|
||||
* This is set when the native code calls `setAudioDevice`
|
||||
*/
|
||||
export const outputDevice$ = new Subject<string>();
|
||||
|
||||
/**
|
||||
@@ -80,16 +114,41 @@ window.controls = {
|
||||
setPipEnabled$.next(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reverse engineered:
|
||||
*
|
||||
* - on iOS:
|
||||
* This always a list of one thing. If current route output is speaker it returns
|
||||
* the single `{"id":"Speaker","name":"Speaker","forEarpiece":true,"isSpeaker":true}` Notice that EC will
|
||||
* also manually add a virtual earpiece device with id `EARPIECE_CONFIG_ID` and `{ type: "earpiece" }`.
|
||||
* If the route output is not speaker then it will be `{id: 'dummy', name: 'dummy'}`
|
||||
*
|
||||
*
|
||||
* - on Android:
|
||||
* This is a list of all available output audio devices. The `id` is the Android AudioDeviceInfo.getId()
|
||||
* and the `name` is based the Android AudioDeviceInfo.productName (mapped to static strings for known types)
|
||||
* The `isEarpiece`, `isSpeaker` and `isExternalHeadset` are set based on the Android AudioDeviceInfo.type
|
||||
* matching the corresponding types for earpiece, speaker and bluetooth headset.
|
||||
*/
|
||||
setAvailableAudioDevices(devices: OutputDevice[]): void {
|
||||
logger.info("setAvailableAudioDevices called from native:", devices);
|
||||
logger.info(
|
||||
"[MediaDevices controls] setAvailableAudioDevices called from native:",
|
||||
devices,
|
||||
);
|
||||
availableOutputDevices$.next(devices);
|
||||
},
|
||||
setAudioDevice(id: string): void {
|
||||
logger.info("setAudioDevice called from native", id);
|
||||
logger.info(
|
||||
"[MediaDevices controls] setAudioDevice called from native",
|
||||
id,
|
||||
);
|
||||
outputDevice$.next(id);
|
||||
},
|
||||
setAudioEnabled(enabled: boolean): void {
|
||||
logger.info("setAudioEnabled called from native:", enabled);
|
||||
logger.info(
|
||||
"[MediaDevices controls] setAudioEnabled called from native:",
|
||||
enabled,
|
||||
);
|
||||
if (!setAudioEnabled$.observed)
|
||||
throw new Error(
|
||||
"Output controls are disabled. No setAudioEnabled$ observer",
|
||||
|
||||
@@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
id={model.remote.id}
|
||||
model={model.remote}
|
||||
id={model.spotlight.id}
|
||||
model={model.spotlight}
|
||||
className={styles.container}
|
||||
style={{ width: tileWidth, height: tileHeight }}
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.local.id}
|
||||
model={model.local}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.tile.draggable {
|
||||
cursor: grab;
|
||||
box-shadow: var(--big-drop-shadow);
|
||||
}
|
||||
|
||||
.tile.draggable:active {
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="white"/>
|
||||
</svg>
|
||||
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 298 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="white"/>
|
||||
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 620 B After Width: | Height: | Size: 628 B |
@@ -43,6 +43,7 @@ layer(compound);
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--background-gradient: url("graphics/backgroundGradient.svg");
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
25
src/input/StarRatingInput.stories.tsx
Normal file
25
src/input/StarRatingInput.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
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 { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StarRatingInput } from "./StarRatingInput";
|
||||
|
||||
const meta = {
|
||||
component: StarRatingInput,
|
||||
} satisfies Meta<typeof StarRatingInput>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
starCount: 5,
|
||||
onChange: fn(),
|
||||
},
|
||||
};
|
||||
@@ -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,6 +78,7 @@ export function LivekitRoomAudioRenderer({
|
||||
.filter((ref) => {
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
prefixedLogger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
|
||||
@@ -19,12 +19,14 @@ import fetchMock from "fetch-mock";
|
||||
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||
import { testJWTToken } from "../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../utils/test";
|
||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||
|
||||
const sfuUrl = "https://sfu.example.org";
|
||||
|
||||
describe("getSFUConfigWithOpenID", () => {
|
||||
let matrixClient: MockedObject<OpenIDClientParts>;
|
||||
beforeEach(() => {
|
||||
fetchMock.catch(404);
|
||||
matrixClient = {
|
||||
getOpenIdToken: vitest.fn(),
|
||||
getDeviceId: vitest.fn(),
|
||||
@@ -71,9 +73,10 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toEqual(
|
||||
new Error("SFU Config fetch failed with status code 500"),
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
return;
|
||||
@@ -106,8 +109,9 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toEqual(
|
||||
new Error("SFU Config fetch failed with status code 500"),
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
}
|
||||
@@ -160,8 +164,9 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toEqual(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toEqual(
|
||||
new Error("SFU Config fetch failed with status code 500"),
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
retryNetworkOperation,
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
@@ -70,6 +66,7 @@ export type OpenIDClientParts = Pick<
|
||||
MatrixClient,
|
||||
"getOpenIdToken" | "getDeviceId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Gets a bearer token from the homeserver and then use it to authenticate
|
||||
* to the matrix RTC backend in order to get acces to the SFU.
|
||||
@@ -78,8 +75,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.
|
||||
@@ -113,9 +110,6 @@ export async function getSFUConfigWithOpenID(
|
||||
);
|
||||
}
|
||||
logger?.debug("Got openID token", openIdToken);
|
||||
|
||||
logger?.info(`Trying to get JWT for focus ${serviceUrl}...`);
|
||||
|
||||
let sfuConfig: { url: string; jwt: string } | undefined;
|
||||
|
||||
const tryBothJwtEndpoints = opts?.forceJwtEndpoint === undefined; // This is for SFUs where we do not publish.
|
||||
@@ -127,7 +121,10 @@ export async function getSFUConfigWithOpenID(
|
||||
// if we can use both or if we are forced to use the new one.
|
||||
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
|
||||
try {
|
||||
sfuConfig = await getLiveKitJWTWithDelayDelegation(
|
||||
logger?.info(
|
||||
`Trying to get JWT with delegation for focus ${serviceUrl}...`,
|
||||
);
|
||||
const sfuConfig = await getLiveKitJWTWithDelayDelegation(
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
@@ -135,32 +132,24 @@ export async function getSFUConfigWithOpenID(
|
||||
opts?.delayEndpointBaseUrl,
|
||||
opts?.delayId,
|
||||
);
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (e) {
|
||||
if (e instanceof NotSupportedError) {
|
||||
logger?.warn(
|
||||
`Failed fetching jwt with matrix 2.0 endpoint (retry with legacy) Not supported`,
|
||||
e,
|
||||
);
|
||||
sfuConfig = undefined;
|
||||
} else {
|
||||
logger?.warn(
|
||||
`Failed fetching jwt with matrix 2.0 endpoint other issues ->`,
|
||||
`(not going to try with legacy endpoint: forceOldJwtEndpoint is set to false, we did not get a not supported error from the sfu)`,
|
||||
e,
|
||||
);
|
||||
// Make this throw a hard error in case we force the matrix2.0 endpoint.
|
||||
if (forceMatrix2Jwt)
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
// NEVER get bejond this point if we forceMatrix2 and it failed!
|
||||
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
|
||||
// Make this throw a hard error in case we force the matrix2.0 endpoint.
|
||||
if (forceMatrix2Jwt) {
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED
|
||||
// here we either have a sfuConfig or we alredy exited because of `if (forceMatrix2) throw ...`
|
||||
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
|
||||
// The only case we can get into this condition is, if `forceMatrix2` is `false`
|
||||
if (sfuConfig === undefined) {
|
||||
try {
|
||||
logger?.info(
|
||||
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
|
||||
);
|
||||
sfuConfig = await getLiveKitJWT(
|
||||
membership.deviceId,
|
||||
serviceUrl,
|
||||
@@ -168,15 +157,19 @@ export async function getSFUConfigWithOpenID(
|
||||
openIdToken,
|
||||
);
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (ex) {
|
||||
throw new FailToGetOpenIdToken(
|
||||
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sfuConfig) {
|
||||
throw new Error("No `sfuConfig` after trying with old and new endpoints");
|
||||
}
|
||||
|
||||
// Pull the details from the JWT
|
||||
function extractFullConfigFromToken(sfuConfig: {
|
||||
url: string;
|
||||
jwt: string;
|
||||
}): SFUConfig {
|
||||
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||
// TODO: Prefer Uint8Array.fromBase64 when widely available
|
||||
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||
return {
|
||||
jwt: sfuConfig.jwt,
|
||||
@@ -188,16 +181,15 @@ export async function getSFUConfigWithOpenID(
|
||||
livekitIdentity: payload.sub,
|
||||
};
|
||||
}
|
||||
const RETRIES = 4;
|
||||
|
||||
async function getLiveKitJWT(
|
||||
deviceId: string,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
let res: Response | undefined;
|
||||
await retryNetworkOperation(RETRIES, async () => {
|
||||
res = await fetch(livekitServiceURL + "/sfu/get", {
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
return await fetch(livekitServiceURL + "/sfu/get", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -210,11 +202,7 @@ async function getLiveKitJWT(
|
||||
}),
|
||||
});
|
||||
});
|
||||
if (!res) {
|
||||
throw new Error(
|
||||
`Network error while connecting to jwt service after ${RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("SFU Config fetch failed with status code " + res.status);
|
||||
}
|
||||
@@ -261,10 +249,8 @@ export async function getLiveKitJWTWithDelayDelegation(
|
||||
};
|
||||
}
|
||||
|
||||
let res: Response | undefined;
|
||||
|
||||
await retryNetworkOperation(RETRIES, async () => {
|
||||
res = await fetch(livekitServiceURL + "/get_token", {
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
return await fetch(livekitServiceURL + "/get_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -273,11 +259,6 @@ export async function getLiveKitJWTWithDelayDelegation(
|
||||
});
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new Error(
|
||||
`Network error while connecting to jwt service after ${RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = "SFU Config fetch failed with status code " + res.status;
|
||||
if (res.status === 404) {
|
||||
|
||||
@@ -67,6 +67,6 @@ Initializer.initBeforeReact()
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to initialize app", e);
|
||||
logger.error(`Failed to initialize app ${e.message}`, e);
|
||||
root.render(e.message);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { IconButton, 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,
|
||||
@@ -33,7 +33,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
@@ -45,7 +44,7 @@ import {
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import { widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -99,8 +98,6 @@ import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { Avatar, Size as AvatarSize } from "../Avatar";
|
||||
import waitingStyles from "./WaitingForJoin.module.css";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||
@@ -108,11 +105,10 @@ import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { useLatest } from "../useLatest.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState"
|
||||
@@ -227,8 +223,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const { showControls } = useUrlParams();
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
// Call pickup state and display names are needed for waiting overlay/sounds
|
||||
const callPickupState = useBehavior(vm.callPickupState$);
|
||||
|
||||
// Preload a waiting and decline sounds
|
||||
const pickupPhaseSoundCache = useInitial(async () => {
|
||||
@@ -242,6 +236,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
latencyHint: "interactive",
|
||||
muted: muteAllAudio,
|
||||
});
|
||||
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
|
||||
|
||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||
@@ -260,6 +255,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const ringing = useBehavior(vm.ringing$);
|
||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
@@ -274,7 +270,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
|
||||
const ringOverlay = useBehavior(vm.ringOverlay$);
|
||||
const fatalCallError = useBehavior(vm.fatalError$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
if (fatalCallError) {
|
||||
@@ -282,93 +277,36 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
throw fatalCallError;
|
||||
}
|
||||
|
||||
// We need to set the proper timings on the animation based upon the sound length.
|
||||
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
||||
useEffect((): (() => void) => {
|
||||
// The CSS animation includes the delay, so we must double the length of the sound.
|
||||
window.document.body.style.setProperty(
|
||||
"--call-ring-duration-s",
|
||||
`${ringDuration * 2}s`,
|
||||
);
|
||||
window.document.body.style.setProperty(
|
||||
"--call-ring-delay-s",
|
||||
`${ringDuration}s`,
|
||||
);
|
||||
// Remove properties when we unload.
|
||||
return () => {
|
||||
window.document.body.style.removeProperty("--call-ring-duration-s");
|
||||
window.document.body.style.removeProperty("--call-ring-delay-s");
|
||||
};
|
||||
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
|
||||
|
||||
// When waiting for pickup, loop a waiting sound
|
||||
// While ringing, loop the ringtone
|
||||
useEffect((): void | (() => void) => {
|
||||
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
|
||||
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
|
||||
return () => {
|
||||
void endSound().catch((e) => {
|
||||
logger.error("Failed to stop ringing sound", e);
|
||||
});
|
||||
};
|
||||
}, [callPickupState, pickupPhaseAudio, ringDuration]);
|
||||
const audio = latestPickupPhaseAudio.current;
|
||||
if (ringing && audio) {
|
||||
const endSound = audio.playSoundLooping(
|
||||
"waiting",
|
||||
audio.soundDuration["waiting"] ?? 1,
|
||||
);
|
||||
return () => {
|
||||
void endSound().catch((e) => {
|
||||
logger.error("Failed to stop ringing sound", e);
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [ringing, latestPickupPhaseAudio]);
|
||||
|
||||
// Waiting UI overlay
|
||||
const waitingOverlay: JSX.Element | null = useMemo(() => {
|
||||
return ringOverlay ? (
|
||||
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
|
||||
<div
|
||||
className={classNames(overlayStyles.content, waitingStyles.content)}
|
||||
>
|
||||
<div className={waitingStyles.pulse}>
|
||||
<Avatar
|
||||
id={ringOverlay.idForAvatar}
|
||||
name={ringOverlay.name}
|
||||
src={ringOverlay.avatarMxc}
|
||||
size={AvatarSize.XL}
|
||||
/>
|
||||
</div>
|
||||
<Text size="md" className={waitingStyles.text}>
|
||||
{ringOverlay.text}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}, [ringOverlay]);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
// 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],
|
||||
@@ -435,46 +373,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
[vm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport
|
||||
.send(
|
||||
gridMode === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error("Failed to send layout change to widget API", e);
|
||||
});
|
||||
}, [gridMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
useAppBarSecondaryButton(
|
||||
useMemo(() => {
|
||||
if (audioOutputSwitcher === null) return null;
|
||||
@@ -647,8 +545,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}
|
||||
@@ -703,20 +601,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"
|
||||
/>,
|
||||
@@ -724,11 +623,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"
|
||||
/>,
|
||||
);
|
||||
@@ -736,30 +635,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"
|
||||
/>,
|
||||
);
|
||||
@@ -792,7 +691,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -801,12 +699,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}
|
||||
>
|
||||
@@ -826,7 +725,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{reconnectingToast}
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{waitingOverlay}
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
<>
|
||||
|
||||
25
src/room/LayoutToggle.stories.tsx
Normal file
25
src/room/LayoutToggle.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
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 { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
|
||||
const meta = {
|
||||
component: LayoutToggle,
|
||||
} satisfies Meta<typeof LayoutToggle>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -18,19 +18,13 @@ import styles from "./LayoutToggle.module.css";
|
||||
|
||||
export type Layout = "spotlight" | "grid";
|
||||
|
||||
interface Props {
|
||||
type 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");
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
position: relative;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.pulse::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border-radius: 9999px;
|
||||
border: 12px solid rgba(255, 255, 255, 0.6);
|
||||
animation: pulse var(--call-ring-duration-s) ease-out infinite;
|
||||
animation-delay: 1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
50.01% {
|
||||
transform: scale(0);
|
||||
}
|
||||
85% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@@ -128,24 +128,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.644 1.792-1.792-.483-3.519-3.123-.034-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.4 11.4 0 0 0 3.206 3.54q.457.33.948.614l.762-.761a2 2 0 0 1 .774-.486c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.361.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.645 1.792-1.791-.483-3.52-3.123-.033-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.3 11.3 0 0 0 1.806 2.348 11.4 11.4 0 0 0 2.348 1.806l.762-.762a2 2 0 0 1 .774-.485c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.36.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
@@ -285,14 +272,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 +296,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 +341,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>
|
||||
@@ -316,6 +349,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
})}
|
||||
</h4>
|
||||
<p>LivekitAlias: {livekitRoom.livekitAlias}</p>
|
||||
<p>connectionState (wont hot reload): {livekitRoom.room.state}</p>
|
||||
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}(
|
||||
|
||||
@@ -213,6 +213,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
env={import.meta.env}
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
roomId={roomId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -234,12 +234,12 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_1qhtc_10"
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
checked=""
|
||||
class="_input_1qhtc_18"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_8_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
@@ -247,7 +247,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
value="legacy"
|
||||
/>
|
||||
<div
|
||||
class="_ui_1qhtc_19"
|
||||
class="_ui_1ug7n_19"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,11 +275,11 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_1qhtc_10"
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
class="_input_1qhtc_18"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_a_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
@@ -287,7 +287,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
value="compatibility"
|
||||
/>
|
||||
<div
|
||||
class="_ui_1qhtc_19"
|
||||
class="_ui_1ug7n_19"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,11 +315,11 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_1qhtc_10"
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
class="_input_1qhtc_18"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_c_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
@@ -327,7 +327,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
value="matrix_2_0"
|
||||
/>
|
||||
<div
|
||||
class="_ui_1qhtc_19"
|
||||
class="_ui_1ug7n_19"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,6 +359,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
LivekitAlias:
|
||||
TestAlias
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
ws-url:
|
||||
wss://local-sfu.example.org/
|
||||
@@ -401,6 +404,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
LivekitAlias:
|
||||
TestAlias2
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
LiveKit Server Info
|
||||
(
|
||||
|
||||
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 => {
|
||||
|
||||
@@ -78,7 +78,6 @@ function renderWithMockClient(
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
thumbnails: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
|
||||
563
src/state/AndroidControlledAudioOutput.test.ts
Normal file
563
src/state/AndroidControlledAudioOutput.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
Copyright 2026 Element Corp.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { it, vi, expect, beforeEach, afterEach, describe } from "vitest";
|
||||
import { firstValueFrom, of, Subject, take, toArray } from "rxjs";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||
import type { Controls, OutputDevice } from "../controls";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
// All the following device types are real device types that have been observed in the wild on Android devices,
|
||||
// gathered from logs.
|
||||
// There are no BT Speakers because they are currently filtered out by EXA (native layer)
|
||||
|
||||
// A device type describing the speaker system (i.e. a mono speaker or stereo speakers) built in a device.
|
||||
const SPEAKER_DEVICE: OutputDevice = {
|
||||
id: "3",
|
||||
name: "Built-in speaker",
|
||||
isEarpiece: false,
|
||||
isSpeaker: true,
|
||||
isExternalHeadset: false,
|
||||
};
|
||||
|
||||
// A device type describing the attached earphone speaker.
|
||||
const EARPIECE_DEVICE: OutputDevice = {
|
||||
id: "2",
|
||||
name: "Built-in earpiece",
|
||||
isEarpiece: true,
|
||||
isSpeaker: false,
|
||||
isExternalHeadset: false,
|
||||
};
|
||||
|
||||
// A device type describing a Bluetooth device typically used for telephony
|
||||
const BT_HEADSET_DEVICE: OutputDevice = {
|
||||
id: "2226",
|
||||
name: "Bluetooth - OpenMove by Shokz",
|
||||
isEarpiece: false,
|
||||
isSpeaker: false,
|
||||
isExternalHeadset: true,
|
||||
};
|
||||
|
||||
// A device type describing a USB audio headset.
|
||||
const USB_HEADSET_DEVICE: OutputDevice = {
|
||||
id: "29440",
|
||||
name: "USB headset - USB-Audio - AB13X USB Audio",
|
||||
isEarpiece: false,
|
||||
isSpeaker: false,
|
||||
isExternalHeadset: false,
|
||||
};
|
||||
|
||||
// A device type describing a headset, which is the combination of a headphones and microphone
|
||||
const WIRED_HEADSET_DEVICE: OutputDevice = {
|
||||
id: "54509",
|
||||
name: "Wired headset - 23117RA68G",
|
||||
isEarpiece: false,
|
||||
isSpeaker: false,
|
||||
isExternalHeadset: false,
|
||||
};
|
||||
|
||||
// A device type describing a pair of wired headphones
|
||||
const WIRED_HEADPHONE_DEVICE: OutputDevice = {
|
||||
id: "679",
|
||||
name: "Wired headphones - TB02",
|
||||
isEarpiece: false,
|
||||
isSpeaker: false,
|
||||
isExternalHeadset: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The base device list that is always present on Android devices.
|
||||
* This list is ordered by the OS, the speaker is listed before the earpiece.
|
||||
*/
|
||||
const BASE_DEVICE_LIST = [SPEAKER_DEVICE, EARPIECE_DEVICE];
|
||||
|
||||
const BT_HEADSET_BASE_DEVICE_LIST = [BT_HEADSET_DEVICE, ...BASE_DEVICE_LIST];
|
||||
|
||||
const WIRED_HEADSET_BASE_DEVICE_LIST = [
|
||||
WIRED_HEADSET_DEVICE,
|
||||
...BASE_DEVICE_LIST,
|
||||
];
|
||||
|
||||
/**
|
||||
* A full device list containing all the observed device types in the wild on Android devices.
|
||||
* Ordered as they would be ordered by the OS.
|
||||
*/
|
||||
const FULL_DEVICE_LIST = [
|
||||
BT_HEADSET_DEVICE,
|
||||
USB_HEADSET_DEVICE,
|
||||
WIRED_HEADSET_DEVICE,
|
||||
WIRED_HEADPHONE_DEVICE,
|
||||
...BASE_DEVICE_LIST,
|
||||
];
|
||||
|
||||
let testScope: ObservableScope;
|
||||
let mockControls: Controls;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
mockControls = {
|
||||
onAudioDeviceSelect: vi.fn(),
|
||||
onOutputDeviceSelect: vi.fn(),
|
||||
} as unknown as Controls;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
describe("Default selection", () => {
|
||||
it("Default to speaker for video calls", async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"video",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
const emissions = await firstValueFrom(
|
||||
controlledAudioOutput.selected$.pipe(take(1), toArray()),
|
||||
);
|
||||
|
||||
expect(emissions).toEqual([
|
||||
{ id: SPEAKER_DEVICE.id, virtualEarpiece: false },
|
||||
]);
|
||||
|
||||
[
|
||||
mockControls.onAudioDeviceSelect,
|
||||
mockControls.onOutputDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("Default to earpiece for audio calls for base config", async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
const emissions = await firstValueFrom(
|
||||
controlledAudioOutput.selected$.pipe(take(1), toArray()),
|
||||
);
|
||||
|
||||
expect(emissions).toEqual([
|
||||
{ id: EARPIECE_DEVICE.id, virtualEarpiece: false },
|
||||
]);
|
||||
|
||||
[
|
||||
mockControls.onAudioDeviceSelect,
|
||||
mockControls.onOutputDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(EARPIECE_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BT_HEADSET_BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
callIntent,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
const emissions = await firstValueFrom(
|
||||
controlledAudioOutput.selected$.pipe(take(1), toArray()),
|
||||
);
|
||||
|
||||
expect(emissions).toEqual([
|
||||
{ id: BT_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
]);
|
||||
|
||||
[
|
||||
mockControls.onAudioDeviceSelect,
|
||||
mockControls.onOutputDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(WIRED_HEADSET_BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
callIntent,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
const emissions = await firstValueFrom(
|
||||
controlledAudioOutput.selected$.pipe(take(1), toArray()),
|
||||
);
|
||||
|
||||
expect(emissions).toEqual([
|
||||
{ id: WIRED_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
]);
|
||||
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledExactlyOnceWith(
|
||||
WIRED_HEADSET_DEVICE.id,
|
||||
);
|
||||
expect(mockControls.onOutputDeviceSelect).toHaveBeenCalledExactlyOnceWith(
|
||||
WIRED_HEADSET_DEVICE.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test mappings", () => {
|
||||
it("Should map output device to correct AudioDeviceLabel", async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(FULL_DEVICE_LIST),
|
||||
testScope,
|
||||
undefined,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
const availableDevices = await firstValueFrom(
|
||||
controlledAudioOutput.available$.pipe(take(1)),
|
||||
);
|
||||
|
||||
expect(availableDevices).toEqual(
|
||||
new Map([
|
||||
[BT_HEADSET_DEVICE.id, { type: "name", name: BT_HEADSET_DEVICE.name }],
|
||||
[
|
||||
USB_HEADSET_DEVICE.id,
|
||||
{ type: "name", name: USB_HEADSET_DEVICE.name },
|
||||
],
|
||||
[
|
||||
WIRED_HEADSET_DEVICE.id,
|
||||
{ type: "name", name: WIRED_HEADSET_DEVICE.name },
|
||||
],
|
||||
[
|
||||
WIRED_HEADPHONE_DEVICE.id,
|
||||
{ type: "name", name: WIRED_HEADPHONE_DEVICE.name },
|
||||
],
|
||||
[SPEAKER_DEVICE.id, { type: "speaker" }],
|
||||
[EARPIECE_DEVICE.id, { type: "earpiece" }],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test select a device", () => {
|
||||
it(`Switch to correct device `, () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable, flush }) => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
cold("a", { a: FULL_DEVICE_LIST }),
|
||||
testScope,
|
||||
undefined,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
schedule("-abc", {
|
||||
a: () => controlledAudioOutput.select(EARPIECE_DEVICE.id),
|
||||
b: () => controlledAudioOutput.select(USB_HEADSET_DEVICE.id),
|
||||
c: () => controlledAudioOutput.select(SPEAKER_DEVICE.id),
|
||||
});
|
||||
|
||||
expectObservable(controlledAudioOutput.selected$).toBe("abcd", {
|
||||
// virtualEarpiece is always false on android.
|
||||
// Initially the BT_HEADSET is selected.
|
||||
a: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
b: { id: EARPIECE_DEVICE.id, virtualEarpiece: false },
|
||||
c: { id: USB_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
d: { id: SPEAKER_DEVICE.id, virtualEarpiece: false },
|
||||
});
|
||||
|
||||
flush();
|
||||
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(4);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(1, BT_HEADSET_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(2, EARPIECE_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(3, USB_HEADSET_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(4, SPEAKER_DEVICE.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it(`manually switch then a bt headset is added`, () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable, flush }) => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
cold("a--b", {
|
||||
a: BASE_DEVICE_LIST,
|
||||
b: BT_HEADSET_BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
// Default was earpiece (audio call), let's switch to speaker
|
||||
schedule("-a--", {
|
||||
a: () => controlledAudioOutput.select(SPEAKER_DEVICE.id),
|
||||
});
|
||||
|
||||
expectObservable(controlledAudioOutput.selected$).toBe("ab-c", {
|
||||
// virtualEarpiece is always false on android.
|
||||
// Initially the BT_HEADSET is selected.
|
||||
a: { id: EARPIECE_DEVICE.id, virtualEarpiece: false },
|
||||
b: { id: SPEAKER_DEVICE.id, virtualEarpiece: false },
|
||||
c: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
});
|
||||
|
||||
flush();
|
||||
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(1, EARPIECE_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(2, SPEAKER_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(3, BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it(`Go back to the previously selected after the auto-switch device goes away`, () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable, flush }) => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
cold("a--b-c", {
|
||||
a: BASE_DEVICE_LIST,
|
||||
b: BT_HEADSET_BASE_DEVICE_LIST,
|
||||
c: BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
// Default was earpiece (audio call), let's switch to speaker
|
||||
schedule("-a---", {
|
||||
a: () => controlledAudioOutput.select(SPEAKER_DEVICE.id),
|
||||
});
|
||||
|
||||
expectObservable(controlledAudioOutput.selected$).toBe("ab-c-d", {
|
||||
// virtualEarpiece is always false on android.
|
||||
// Initially the BT_HEADSET is selected.
|
||||
a: { id: EARPIECE_DEVICE.id, virtualEarpiece: false },
|
||||
b: { id: SPEAKER_DEVICE.id, virtualEarpiece: false },
|
||||
c: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false },
|
||||
d: { id: SPEAKER_DEVICE.id, virtualEarpiece: false },
|
||||
});
|
||||
|
||||
flush();
|
||||
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(4);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(1, EARPIECE_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(2, SPEAKER_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(3, BT_HEADSET_DEVICE.id);
|
||||
expect(mockFn).toHaveBeenNthCalledWith(4, SPEAKER_DEVICE.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Available device changes", () => {
|
||||
let availableSource$: Subject<OutputDevice[]>;
|
||||
|
||||
const createAudioControlledOutput = (
|
||||
intent: RTCCallIntent,
|
||||
): AndroidControlledAudioOutput => {
|
||||
return new AndroidControlledAudioOutput(
|
||||
availableSource$,
|
||||
testScope,
|
||||
intent,
|
||||
mockControls,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
availableSource$ = new Subject<OutputDevice[]>();
|
||||
});
|
||||
|
||||
it("When a BT headset is added, control should switch to use it", () => {
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
// Initially speaker would be selected
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id);
|
||||
});
|
||||
|
||||
// Emit a new device list with a BT device, the control should switch to it
|
||||
availableSource$.next([BT_HEADSET_DEVICE, ...BASE_DEVICE_LIST]);
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockFn).toHaveBeenLastCalledWith(BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.'
|
||||
it.skip("When a wired headset is added, control should switch to use it", async () => {
|
||||
const controlledAudioOutput = createAudioControlledOutput("video");
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
|
||||
await firstValueFrom(controlledAudioOutput.selected$.pipe(take(1)));
|
||||
// Initially speaker would be selected
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id);
|
||||
});
|
||||
|
||||
// Emit a new device list with a wired headset, the control should switch to it
|
||||
availableSource$.next([WIRED_HEADPHONE_DEVICE, ...BASE_DEVICE_LIST]);
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockFn).toHaveBeenLastCalledWith(WIRED_HEADPHONE_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
|
||||
createAudioControlledOutput("audio");
|
||||
|
||||
// Emit the BT headset device list, the BT headset should be selected
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
// Initially speaker would be selected
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
|
||||
// Emit a new device list without the BT headset, the control should switch to the earpiece for
|
||||
// audio calls
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockFn).toHaveBeenLastCalledWith(EARPIECE_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
// Initially bt headset would be selected
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
|
||||
// Emit a new device list without the BT headset, the control should switch to speaker for video call
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockFn).toHaveBeenLastCalledWith(SPEAKER_DEVICE.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("Do not repeatidly set the same device", () => {
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
// Initially bt headset would be selected
|
||||
[
|
||||
mockControls.onOutputDeviceSelect,
|
||||
mockControls.onAudioDeviceSelect,
|
||||
].forEach((mockFn) => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scope management", () => {
|
||||
it("Should stop emitting when scope ends", () => {
|
||||
const aScope = new ObservableScope();
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
aScope,
|
||||
undefined,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce();
|
||||
|
||||
aScope.end();
|
||||
|
||||
controlledAudioOutput.select(EARPIECE_DEVICE.id);
|
||||
|
||||
expect(mockControls.onAudioDeviceSelect).not.toHaveBeenCalledTimes(2);
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Should stop updating when scope ends", () => {
|
||||
const aScope = new ObservableScope();
|
||||
const availableSource$ = new Subject<OutputDevice[]>();
|
||||
new AndroidControlledAudioOutput(
|
||||
availableSource$,
|
||||
aScope,
|
||||
undefined,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce();
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledWith(
|
||||
BT_HEADSET_DEVICE.id,
|
||||
);
|
||||
|
||||
aScope.end();
|
||||
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
|
||||
expect(mockControls.onAudioDeviceSelect).not.toHaveBeenCalledTimes(2);
|
||||
// Should have been called only once with the initial BT_HEADSET_DEVICE.id
|
||||
expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
360
src/state/AndroidControlledAudioOutput.ts
Normal file
360
src/state/AndroidControlledAudioOutput.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
Copyright 2026 Element Corp.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
scan,
|
||||
startWith,
|
||||
Subject,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
type MediaDevice,
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "./MediaDevices.ts";
|
||||
import type { ObservableScope } from "./ObservableScope.ts";
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Controls, type OutputDevice } from "../controls.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
type ControllerState = {
|
||||
/**
|
||||
* The list of available output devices, ordered by preference order (most preferred first).
|
||||
*/
|
||||
devices: OutputDevice[];
|
||||
/**
|
||||
* Explicit user preference for the selected device.
|
||||
*/
|
||||
preferredDeviceId: string | undefined;
|
||||
/**
|
||||
* The effective selected device, always valid against available devices.
|
||||
*/
|
||||
selectedDeviceId: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The possible actions that can be performed on the controller,
|
||||
* either by the user or by the system.
|
||||
*/
|
||||
type ControllerAction =
|
||||
| { type: "selectDevice"; deviceId: string | undefined }
|
||||
| { type: "deviceUpdated"; devices: OutputDevice[] };
|
||||
/**
|
||||
* The implementation of the audio output media device for Android when using the controlled audio output mode.
|
||||
*
|
||||
* In this mode, the hosting application (e.g. Element Mobile) is responsible for providing the list of available audio output devices.
|
||||
* There are some android specific logic compared to others:
|
||||
* - AndroidControlledAudioOutput is the only one responsible for selecting the best output device.
|
||||
* - On android, we don't listen to the selected device from native code (control.setAudioDevice).
|
||||
* - If a new device is added or removed, this controller will determine the new selected device based
|
||||
* on the available devices (that is ordered by preference order) and the user's selection (if any).
|
||||
*
|
||||
* Given the differences in how the native code is handling the audio routing on Android compared to iOS,
|
||||
* we have this separate implementation. It allows us to have proper testing and avoid side effects
|
||||
* from platform specific logic breaking the other platform's implementation.
|
||||
*/
|
||||
export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
AudioOutputDeviceLabel,
|
||||
SelectedAudioOutputDevice
|
||||
> {
|
||||
private logger = rootLogger.getChild(
|
||||
"[MediaDevices AndroidControlledAudioOutput]",
|
||||
);
|
||||
|
||||
/**
|
||||
* STATE stream: the current state of the controller, including the list of available devices and the selected device.
|
||||
*/
|
||||
private readonly controllerState$: Behavior<ControllerState>;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public readonly available$: Behavior<Map<string, AudioOutputDeviceLabel>>;
|
||||
|
||||
/**
|
||||
* Effective selected device, always valid against available devices.
|
||||
*
|
||||
* On android, we don't listen to the selected device from native code (control.setAudioDevice).
|
||||
* Instead, we determine the selected device ourselves based on the available devices and the user's selection (if any).
|
||||
*/
|
||||
public readonly selected$: Behavior<SelectedAudioOutputDevice | undefined>;
|
||||
|
||||
// COMMAND stream: user asks to select a device
|
||||
private readonly selectDeviceCommand$ = new Subject<string | undefined>();
|
||||
|
||||
public select(id: string): void {
|
||||
this.logger.info(`select device: ${id}`);
|
||||
this.selectDeviceCommand$.next(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of AndroidControlledAudioOutput.
|
||||
*
|
||||
* @constructor
|
||||
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
|
||||
* @param scope - The ObservableScope to create the Behaviors in.
|
||||
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly controlledDevices$: Observable<OutputDevice[]>,
|
||||
private readonly scope: ObservableScope,
|
||||
private initialIntent: RTCCallIntent | undefined = undefined,
|
||||
controls: Controls,
|
||||
) {
|
||||
this.controllerState$ = this.startObservingState$();
|
||||
|
||||
this.selected$ = this.effectiveSelectionFromState$(this.controllerState$);
|
||||
|
||||
this.available$ = scope.behavior(
|
||||
this.controllerState$.pipe(
|
||||
map((state) => {
|
||||
this.logger.info("available devices updated:", state.devices);
|
||||
|
||||
return new Map<string, AudioOutputDeviceLabel>(
|
||||
state.devices.map((outputDevice) => {
|
||||
return [outputDevice.id, mapDeviceToLabel(outputDevice)];
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Effect 1: notify host when effective selection changes
|
||||
this.selected$
|
||||
// It is a behavior so it has built-in distinct until change
|
||||
.pipe(scope.bind())
|
||||
.subscribe((device) => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
if (device !== undefined) {
|
||||
this.logger.info("onAudioDeviceSelect called:", device);
|
||||
controls.onAudioDeviceSelect?.(device.id);
|
||||
// Also invoke the deprecated callback for backward compatibility
|
||||
// TODO: it appears that on Android the hosting application is only using the deprecated callback (onOutputDeviceSelect)
|
||||
// and not the new one (onAudioDeviceSelect), we should clean this up and only have one callback for audio device selection.
|
||||
controls.onOutputDeviceSelect?.(device.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startObservingState$(): Behavior<ControllerState> {
|
||||
const initialState: ControllerState = {
|
||||
devices: [],
|
||||
preferredDeviceId: undefined,
|
||||
selectedDeviceId: undefined,
|
||||
};
|
||||
|
||||
// Merge the two possible inputs observable as a single
|
||||
// stream of actions that will update the state of the controller.
|
||||
const actions$: Observable<ControllerAction> = merge(
|
||||
this.controlledDevices$.pipe(
|
||||
map(
|
||||
(devices) =>
|
||||
({ type: "deviceUpdated", devices }) satisfies ControllerAction,
|
||||
),
|
||||
),
|
||||
this.selectDeviceCommand$.pipe(
|
||||
map(
|
||||
(deviceId) =>
|
||||
({ type: "selectDevice", deviceId }) satisfies ControllerAction,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const initialAction: ControllerAction = {
|
||||
type: "deviceUpdated",
|
||||
devices: [],
|
||||
};
|
||||
|
||||
return this.scope.behavior(
|
||||
actions$.pipe(
|
||||
startWith(initialAction),
|
||||
scan((state, action): ControllerState => {
|
||||
switch (action.type) {
|
||||
case "deviceUpdated": {
|
||||
const chosenDevice = this.chooseEffectiveSelection({
|
||||
previousDevices: state.devices,
|
||||
availableDevices: action.devices,
|
||||
currentSelectedId: state.selectedDeviceId,
|
||||
preferredDeviceId: state.preferredDeviceId,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
devices: action.devices,
|
||||
selectedDeviceId: chosenDevice,
|
||||
};
|
||||
}
|
||||
case "selectDevice": {
|
||||
const chosenDevice = this.chooseEffectiveSelection({
|
||||
previousDevices: state.devices,
|
||||
availableDevices: state.devices,
|
||||
currentSelectedId: state.selectedDeviceId,
|
||||
preferredDeviceId: action.deviceId,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
preferredDeviceId: action.deviceId,
|
||||
selectedDeviceId: chosenDevice,
|
||||
};
|
||||
}
|
||||
}
|
||||
}, initialState),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private effectiveSelectionFromState$(
|
||||
state$: Observable<ControllerState>,
|
||||
): Behavior<SelectedAudioOutputDevice | undefined> {
|
||||
return this.scope.behavior(
|
||||
state$
|
||||
.pipe(
|
||||
map((state) => {
|
||||
if (state.selectedDeviceId) {
|
||||
return {
|
||||
id: state.selectedDeviceId,
|
||||
/** This is an iOS thing, always false for android*/
|
||||
virtualEarpiece: false,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id),
|
||||
)
|
||||
.pipe(
|
||||
tap((selected) => {
|
||||
this.logger.debug(`selected device: ${selected?.id}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private chooseEffectiveSelection(args: {
|
||||
previousDevices: OutputDevice[];
|
||||
availableDevices: OutputDevice[];
|
||||
currentSelectedId: string | undefined;
|
||||
preferredDeviceId: string | undefined;
|
||||
}): string | undefined {
|
||||
const {
|
||||
previousDevices,
|
||||
availableDevices,
|
||||
currentSelectedId,
|
||||
preferredDeviceId,
|
||||
} = args;
|
||||
|
||||
this.logger.debug(`chooseEffectiveSelection with args:`, args);
|
||||
|
||||
// Take preferredDeviceId in priority or default to the last effective selection.
|
||||
const activeSelectedDeviceId = preferredDeviceId || currentSelectedId;
|
||||
const isAvailable = availableDevices.some(
|
||||
(device) => device.id === activeSelectedDeviceId,
|
||||
);
|
||||
|
||||
// If there is no current device, or it is not available anymore,
|
||||
// choose the default device selection logic.
|
||||
if (activeSelectedDeviceId === undefined || !isAvailable) {
|
||||
this.logger.debug(
|
||||
`No current device or it is not available, using default selection logic.`,
|
||||
);
|
||||
// use the default selection logic
|
||||
return this.chooseDefaultDeviceId(availableDevices);
|
||||
}
|
||||
|
||||
// Is there a new added device?
|
||||
// If a device is added, we might want to switch to it if it's more preferred than the currently selected device.
|
||||
const newDeviceWasAdded = availableDevices.some(
|
||||
(device) => !previousDevices.some((d) => d.id === device.id),
|
||||
);
|
||||
|
||||
if (newDeviceWasAdded) {
|
||||
// TODO only want to check from the added device, not all devices.?
|
||||
// check if the currently selected device is the most preferred one, if not switch to the most preferred one.
|
||||
const mostPreferredDevice = availableDevices[0];
|
||||
this.logger.debug(
|
||||
`A new device was added, checking if we should switch to it.`,
|
||||
mostPreferredDevice,
|
||||
);
|
||||
if (mostPreferredDevice.id !== activeSelectedDeviceId) {
|
||||
// Given this is automatic switching, we want to be careful and only switch to a more private device
|
||||
// (e.g. from speaker to a BT headset) but not switch from a more private device to a less private one
|
||||
// (e.g. from a BT headset to the speaker), as that can be disruptive for the user if it happens unexpectedly.
|
||||
if (mostPreferredDevice.isExternalHeadset == true) {
|
||||
this.logger.info(
|
||||
`The currently selected device ${mostPreferredDevice.id} is not the most preferred one, switching to the most preferred one ${activeSelectedDeviceId} instead.`,
|
||||
);
|
||||
// Let's switch as it is a more private device.
|
||||
return mostPreferredDevice.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no changes
|
||||
return activeSelectedDeviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logic for the default is different based on the call type.
|
||||
* For example for a voice call we want to default to the earpiece if it's available,
|
||||
* but for a video call we want to default to the speaker.
|
||||
* If the user is using a BT headset we want to default to that, as it's likely what they want to use for both video and voice calls.
|
||||
*
|
||||
* @param available the available audio output devices to choose from, keyed by their id, sorted by likelihood of it being used for communication.
|
||||
*
|
||||
*/
|
||||
private chooseDefaultDeviceId(available: OutputDevice[]): string | undefined {
|
||||
this.logger.debug(
|
||||
`Android routing logic intent: ${this.initialIntent} finding best default...`,
|
||||
);
|
||||
if (this.initialIntent === "audio") {
|
||||
const systemProposed = available[0];
|
||||
// If no headset is connected, android will route to the speaker by default,
|
||||
// but for a voice call we want to route to the earpiece instead,
|
||||
// so override the system proposed routing in that case.
|
||||
if (systemProposed?.isSpeaker == true) {
|
||||
// search for the earpiece
|
||||
const earpieceDevice = available.find(
|
||||
(device) => device.isEarpiece == true,
|
||||
);
|
||||
if (earpieceDevice) {
|
||||
this.logger.debug(
|
||||
`Android routing: Switch to earpiece instead of speaker for voice call`,
|
||||
);
|
||||
return earpieceDevice.id;
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`Android routing: no earpiece found, cannot switch, use system proposed routing`,
|
||||
);
|
||||
return systemProposed.id;
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`Android routing: Use system proposed routing `,
|
||||
systemProposed,
|
||||
);
|
||||
return systemProposed?.id;
|
||||
}
|
||||
} else {
|
||||
// Use the system best proposed best routing.
|
||||
return available[0]?.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utilities
|
||||
function mapDeviceToLabel(device: OutputDevice): AudioOutputDeviceLabel {
|
||||
const { name, isEarpiece, isSpeaker } = device;
|
||||
if (isEarpiece) return { type: "earpiece" };
|
||||
else if (isSpeaker) return { type: "speaker" };
|
||||
else return { type: "name", name };
|
||||
}
|
||||
193
src/state/AudioOutput.test.ts
Normal file
193
src/state/AudioOutput.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
Copyright 2026 Element Corp.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, vi, it } from "vitest";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { AudioOutput } from "./MediaDevices";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BT_SPEAKER = {
|
||||
deviceId: "f9fc8f5f94578fe3abd89e086c1e78c08477aa564dd9e917950f0e7ebb37a6a2",
|
||||
kind: "audiooutput",
|
||||
label: "JBL (Bluetooth)",
|
||||
groupId: "309a5c086cd8eb885a164046db6ec834c349be01d86448d02c1a5279456ff9e4",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const BUILT_IN_SPEAKER = {
|
||||
deviceId: "acdbb8546ea6fa85ba2d861e9bcc0e71810d03bbaf6d1712c69e8d9c0c6c2e0a",
|
||||
kind: "audiooutput",
|
||||
label: "MacBook Speakers (Built-in)",
|
||||
groupId: "08a5a3a486473aaa898eb81cda3113f3e21053fb8b84155f4e612fe3f8db5d17",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const BT_HEADSET = {
|
||||
deviceId: "ff8e6edb4ebb512b2b421335bfd14994a5b4c7192b3e84a8696863d83cf46d12",
|
||||
kind: "audiooutput",
|
||||
label: "OpenMove (Bluetooth)",
|
||||
groupId: "c2893c2438c44248368e0533300245c402764991506f42cd73818dc8c3ee9c88",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const AMAC_DEVICE_LIST = [BT_SPEAKER, BUILT_IN_SPEAKER];
|
||||
|
||||
const AMAC_DEVICE_LIST_WITH_DEFAULT = [
|
||||
asDefault(BUILT_IN_SPEAKER),
|
||||
...AMAC_DEVICE_LIST,
|
||||
];
|
||||
|
||||
const AMAC_HS_DEVICE_LIST = [
|
||||
asDefault(BT_HEADSET),
|
||||
BT_SPEAKER,
|
||||
BT_HEADSET,
|
||||
BUILT_IN_SPEAKER,
|
||||
];
|
||||
|
||||
const LAPTOP_SPEAKER = {
|
||||
deviceId: "EcUxTMu8He2wz+3Y8m/u0fy6M92pUk=",
|
||||
kind: "audiooutput",
|
||||
label: "Raptor AVS Speaker",
|
||||
groupId: "kSrdanhpEDLg3vN8z6Z9MJ1EdanB8zI+Q1dxA=",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const MONITOR_SPEAKER = {
|
||||
deviceId: "gBryZdAdC8I/rrJpr9r6R+rZzKkoIK5cpU=",
|
||||
kind: "audiooutput",
|
||||
label: "Raptor AVS HDMI / DisplayPort 1 Output",
|
||||
groupId: "kSrdanhpEDLg3vN8z6Z9MJ1EdanB8zI+Q1dxA=",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const DEVICE_LIST_B = [LAPTOP_SPEAKER, MONITOR_SPEAKER];
|
||||
|
||||
// On chrome, there is an additional synthetic device called "Default - <device name>",
|
||||
// it represents what the OS default is now.
|
||||
function asDefault(device: MediaDeviceInfo): MediaDeviceInfo {
|
||||
return {
|
||||
...device,
|
||||
deviceId: "default",
|
||||
label: `Default - ${device.label}`,
|
||||
};
|
||||
}
|
||||
// When the authorization is not yet granted, every device is still listed
|
||||
// but only with empty/blank labels and ids.
|
||||
// This is a transition state.
|
||||
function toBlankDevice(device: MediaDeviceInfo): MediaDeviceInfo {
|
||||
return {
|
||||
...device,
|
||||
deviceId: "",
|
||||
label: "",
|
||||
groupId: "",
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("AudioOutput Tests", () => {
|
||||
let testScope: ObservableScope;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
it("should select the default audio output device", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("ab", {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
a: AMAC_DEVICE_LIST_WITH_DEFAULT.map(toBlankDevice),
|
||||
b: AMAC_DEVICE_LIST_WITH_DEFAULT,
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("ab", {
|
||||
a: undefined,
|
||||
b: { id: "default", virtualEarpiece: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Select the correct device when requested", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("ab", {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
a: DEVICE_LIST_B.map(toBlankDevice),
|
||||
b: DEVICE_LIST_B,
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
schedule("--abc", {
|
||||
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
|
||||
b: () => audioOutput.select(LAPTOP_SPEAKER.deviceId),
|
||||
c: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
|
||||
});
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||
a: undefined,
|
||||
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Test mappings", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
a: AMAC_HS_DEVICE_LIST,
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
const expectedMappings = new Map([
|
||||
[`default`, { type: "name", name: asDefault(BT_HEADSET).label }],
|
||||
[BT_SPEAKER.deviceId, { type: "name", name: BT_SPEAKER.label }],
|
||||
[BT_HEADSET.deviceId, { type: "name", name: BT_HEADSET.label }],
|
||||
[
|
||||
BUILT_IN_SPEAKER.deviceId,
|
||||
{ type: "name", name: BUILT_IN_SPEAKER.label },
|
||||
],
|
||||
]);
|
||||
|
||||
expectObservable(audioOutput.available$).toBe("a", {
|
||||
a: expectedMappings,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,12 +83,12 @@ 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.
|
||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||
* Then we can conclude if we were the first one to join or not.
|
||||
* This may also be set if we are disconnected.
|
||||
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
||||
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
||||
* The call failed. If desired this can be used as a trigger to exit the call.
|
||||
@@ -127,25 +130,19 @@ export function createCallNotificationLifecycle$({
|
||||
) as Behavior<Epoch<boolean>>;
|
||||
|
||||
/**
|
||||
* Whenever the RTC session tells us that it intends to ring the remote
|
||||
* participant's devices, this emits an Observable tracking the current state of
|
||||
* that ringing process.
|
||||
* The state of the current ringing attempt, if the RTC session is indeed
|
||||
* ringing the remote participant's devices. Otherwise `null`.
|
||||
*/
|
||||
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
|
||||
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
|
||||
// A behavior will emit the latest observable with the running timer to new subscribers.
|
||||
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
|
||||
// `ring$` would not be a behavior.
|
||||
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
|
||||
scope.behavior(
|
||||
sentCallNotification$.pipe(
|
||||
filter(
|
||||
(newAndLegacyEvents) =>
|
||||
(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";
|
||||
@@ -47,9 +46,11 @@ import {
|
||||
} from "../../utils/test.ts";
|
||||
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
||||
import {
|
||||
alice,
|
||||
aliceId,
|
||||
aliceParticipant,
|
||||
aliceRtcMember,
|
||||
aliceUserId,
|
||||
bobId,
|
||||
bobRtcMember,
|
||||
local,
|
||||
@@ -62,6 +63,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()),
|
||||
@@ -138,8 +142,8 @@ export interface SpotlightExpandedLayoutSummary {
|
||||
|
||||
export interface OneOnOneLayoutSummary {
|
||||
type: "one-on-one";
|
||||
local: string;
|
||||
remote: string;
|
||||
spotlight: string;
|
||||
pip: string;
|
||||
}
|
||||
|
||||
export interface PipLayoutSummary {
|
||||
@@ -192,11 +196,11 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "one-on-one":
|
||||
return combineLatest(
|
||||
[l.local.media$, l.remote.media$],
|
||||
(local, remote) => ({
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
local: local.id,
|
||||
remote: remote.id,
|
||||
spotlight: spotlight.id,
|
||||
pip: pip.id,
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
@@ -229,10 +233,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],
|
||||
@@ -539,8 +539,8 @@ describe.each([
|
||||
b: {
|
||||
// In a larger window, expect the normal one-on-one layout
|
||||
type: "one-on-one",
|
||||
local: `${localId}:0`,
|
||||
remote: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
c: {
|
||||
// In a PiP-sized window, we of course expect a PiP layout
|
||||
@@ -750,6 +750,53 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
test("PiP tile in expanded spotlight layout avoids redundantly showing local user", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Switch to spotlight immediately
|
||||
const modeInputMarbles = " s";
|
||||
// And expand the spotlight immediately
|
||||
const expandInputMarbles = " a";
|
||||
// First no one else is in the call, then Alice joins
|
||||
const participantInputMarbles = "ab";
|
||||
// First local user should be in the spotlight, then they appear in PiP
|
||||
// only once Alice has joined
|
||||
const expectedLayoutMarbles = " ab";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
rtcMembers$: behavior(participantInputMarbles, {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
});
|
||||
schedule(expandInputMarbles, {
|
||||
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: [`${localId}:0`],
|
||||
pip: undefined,
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("spotlight remembers whether it's expanded", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
// Start in spotlight mode, then switch to grid and back to spotlight a
|
||||
@@ -842,8 +889,8 @@ describe.each([
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
local: `${localId}:0`,
|
||||
remote: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
@@ -885,8 +932,8 @@ describe.each([
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
local: `${localId}:0`,
|
||||
remote: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
@@ -895,8 +942,8 @@ describe.each([
|
||||
},
|
||||
d: {
|
||||
type: "one-on-one",
|
||||
local: `${localId}:0`,
|
||||
remote: `${daveId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${daveId}:0`,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1089,85 +1136,81 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
it.skip("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => {
|
||||
withTestScheduler(({ schedule, expectObservable, behavior }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
livekitConnectionState$: behavior("d 9ms c", {
|
||||
d: ConnectionState.Disconnected,
|
||||
c: ConnectionState.Connected,
|
||||
}),
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
// Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits)
|
||||
schedule("n", {
|
||||
n: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
test("recipient has placeholder tile while ringing or timed out", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
roomMembers: [alice, local], // Simulate a DM
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
// Fire a ringing notification
|
||||
schedule("n", {
|
||||
n: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "timeout",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
// Should ring for 30ms and then time out
|
||||
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo);
|
||||
// Layout should show placeholder media for the participant we're
|
||||
// ringing the entire time (even once timed out)
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
type: "one-on-one",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ waitForCallPickup: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("ringing -> unknown if we get disconnected", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const connectionState$ = new BehaviorSubject(ConnectionState.Connected);
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior("a 19ms b", {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
}),
|
||||
rtcMembers$: behavior("a 19ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
livekitConnectionState$: connectionState$,
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
// Notify at 5ms so we enter ringing, then get disconnected 5ms later
|
||||
schedule(" 5ms r 5ms d", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif2", 100),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
d: () => {
|
||||
connectionState$.next(ConnectionState.Disconnected);
|
||||
},
|
||||
});
|
||||
test("recipient's placeholder tile is replaced by their real tile once they answer", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
// Alice answers after 20ms
|
||||
rtcMembers$: behavior("a 20ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
roomMembers: [alice, local], // Simulate a DM
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
// Fire a ringing notification
|
||||
schedule("n", {
|
||||
n: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "unknown",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
// Should ring until Alice joins
|
||||
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo);
|
||||
// Layout should show placeholder media for the participant we're
|
||||
// ringing the entire time
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||
a: {
|
||||
type: "one-on-one",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
spotlight: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ waitForCallPickup: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,23 +42,18 @@ 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";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
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";
|
||||
@@ -68,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";
|
||||
@@ -90,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,
|
||||
@@ -102,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$,
|
||||
@@ -135,13 +128,25 @@ import {
|
||||
createSentCallNotification$,
|
||||
} from "./CallNotificationLifecycle.ts";
|
||||
import {
|
||||
createDMMember$,
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||
import {
|
||||
createWrappedUserMedia,
|
||||
type WrappedUserMediaViewModel,
|
||||
} from "../media/WrappedUserMediaViewModel.ts";
|
||||
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
||||
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||
import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts";
|
||||
import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts";
|
||||
import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
@@ -191,7 +196,6 @@ interface LayoutScanState {
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
export type LivekitRoomItem = {
|
||||
livekitRoom: LivekitRoom;
|
||||
participants: string[];
|
||||
@@ -210,21 +214,28 @@ export type LivekitRoomItem = {
|
||||
export interface CallViewModel {
|
||||
// lifecycle
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
||||
callPickupState$: Behavior<
|
||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
||||
>;
|
||||
/**
|
||||
* Whether we are ringing a call recipient.
|
||||
*/
|
||||
ringing$: Behavior<boolean>;
|
||||
/** 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.
|
||||
@@ -273,7 +284,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>;
|
||||
@@ -282,13 +292,6 @@ export interface CallViewModel {
|
||||
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
||||
reactions$: Behavior<Record<string, ReactionOption>>;
|
||||
|
||||
ringOverlay$: Behavior<null | {
|
||||
name: string;
|
||||
/** roomId or userId for the avatar generation. */
|
||||
idForAvatar: string;
|
||||
text: string;
|
||||
avatarMxc?: string;
|
||||
}>;
|
||||
// sounds and events
|
||||
joinSoundEffect$: Observable<void>;
|
||||
leaveSoundEffect$: Observable<void>;
|
||||
@@ -324,10 +327,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.
|
||||
*/
|
||||
@@ -435,37 +434,42 @@ export function createCallViewModel$(
|
||||
memberId: uuidv4(),
|
||||
};
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
) as Observable<string | undefined>
|
||||
).pipe(map((v) => v ?? 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,
|
||||
@@ -480,6 +484,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",
|
||||
@@ -494,12 +499,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(
|
||||
@@ -512,14 +518,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,
|
||||
@@ -529,7 +535,6 @@ export function createCallViewModel$(
|
||||
},
|
||||
createPublisherFactory: (connection: Connection) => {
|
||||
return new Publisher(
|
||||
scope,
|
||||
connection,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
@@ -539,9 +544,9 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
},
|
||||
connectionManager: connectionManager,
|
||||
matrixRTCSession: matrixRTCSession,
|
||||
localTransport$: localTransport$,
|
||||
connectionManager,
|
||||
matrixRTCSession,
|
||||
localTransport$,
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
@@ -600,40 +605,6 @@ export function createCallViewModel$(
|
||||
matrixRoomMembers$,
|
||||
);
|
||||
|
||||
const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom);
|
||||
const noUserToCallInRoom$ = scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(roomMembersMap) =>
|
||||
roomMembersMap.size === 1 && roomMembersMap.get(userId) !== undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const ringOverlay$ = scope.behavior(
|
||||
combineLatest([noUserToCallInRoom$, dmMember$, callPickupState$]).pipe(
|
||||
map(([noUserToCallInRoom, dmMember, callPickupState]) => {
|
||||
// No overlay if not in ringing state
|
||||
if (callPickupState !== "ringing" || noUserToCallInRoom) return null;
|
||||
|
||||
const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name;
|
||||
const id = dmMember ? dmMember.userId : matrixRoom.roomId;
|
||||
const text = dmMember
|
||||
? `Waiting for ${name} to join…`
|
||||
: "Waiting for other participants…";
|
||||
const avatarMxc = dmMember
|
||||
? (dmMember.getMxcAvatarUrl?.() ?? undefined)
|
||||
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
|
||||
return {
|
||||
name: name ?? id,
|
||||
idForAvatar: id,
|
||||
text,
|
||||
avatarMxc,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const allConnections$ = scope.behavior(
|
||||
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
||||
);
|
||||
@@ -703,15 +674,16 @@ 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$,
|
||||
duplicateTiles.value$,
|
||||
]).pipe(
|
||||
// Generate a collection of MediaItems from the list of expected (whether
|
||||
// Generate a collection of user media from the list of expected (whether
|
||||
// present or missing) LiveKit participants.
|
||||
generateItems(
|
||||
"CallViewModel userMedia$",
|
||||
function* ([
|
||||
localMatrixLivekitMember,
|
||||
matrixLivekitMembers,
|
||||
@@ -748,68 +720,100 @@ 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)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
|
||||
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
|
||||
generateItems(
|
||||
"CallViewModel ringingMedia$",
|
||||
function* ([userMedia, roomMembers, callPickupState]) {
|
||||
if (
|
||||
callPickupState === "ringing" ||
|
||||
callPickupState === "timeout" ||
|
||||
callPickupState === "decline"
|
||||
) {
|
||||
for (const member of roomMembers.values()) {
|
||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||
yield {
|
||||
keys: [member.userId],
|
||||
data: callPickupState,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, pickupState$, userId) =>
|
||||
createRingingMedia({
|
||||
id: `ringing:${userId}`,
|
||||
userId,
|
||||
displayName$: scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map((members) => members.get(userId)?.rawDisplayName || userId),
|
||||
),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
pickupState$,
|
||||
muteStates,
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
tap((ringingMedia) => {
|
||||
if (ringingMedia.length > 1)
|
||||
// Warn that UI may do something unexpected in this case
|
||||
logger.warn(
|
||||
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* List of all media items (user media and screen share media) that we want
|
||||
* tiles for.
|
||||
* All screen share media that we want to display.
|
||||
*/
|
||||
const mediaItems$ = scope.behavior<MediaItem[]>(
|
||||
const screenShares$ = scope.behavior<ScreenShareViewModel[]>(
|
||||
userMedia$.pipe(
|
||||
switchMap((userMedia) =>
|
||||
userMedia.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
userMedia.map((m) => m.screenShares$),
|
||||
(...screenShares) => [...userMedia, ...screenShares.flat(1)],
|
||||
(...screenShares) => screenShares.flat(1),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||
*/
|
||||
const screenShares$ = scope.behavior<ScreenShare[]>(
|
||||
mediaItems$.pipe(
|
||||
map((mediaItems) =>
|
||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const joinSoundEffect$ = userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
@@ -869,39 +873,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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -915,71 +919,74 @@ 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),
|
||||
),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0) {
|
||||
return of(screenShares.map((m) => m.vm));
|
||||
}
|
||||
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
/**
|
||||
* Local user media suitable for displaying in a PiP (undefined if not found
|
||||
* or if user prefers to not see themselves).
|
||||
*/
|
||||
const localUserMediaForPip$ = scope.behavior<
|
||||
LocalUserMediaViewModel | undefined
|
||||
>(
|
||||
userMedia$.pipe(
|
||||
switchMap((userMedia) => {
|
||||
const localUserMedia = userMedia.find(
|
||||
(m): m is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
m.type === "user" && m.local,
|
||||
);
|
||||
if (!localUserMedia) return of(undefined);
|
||||
return localUserMedia.alwaysShow$.pipe(
|
||||
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
const pip$ = scope.behavior<UserMediaViewModel | null>(
|
||||
combineLatest([
|
||||
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
|
||||
screenShares$,
|
||||
spotlightSpeaker$,
|
||||
mediaItems$,
|
||||
]).pipe(
|
||||
switchMap(([screenShares, spotlight, mediaItems]) => {
|
||||
if (screenShares.length > 0) {
|
||||
return spotlightSpeaker$;
|
||||
}
|
||||
if (!spotlight || spotlight.local) {
|
||||
return of(null);
|
||||
}
|
||||
const spotlightAndPip$ = scope.behavior<{
|
||||
spotlight: MediaViewModel[];
|
||||
pip$: Observable<UserMediaViewModel | undefined>;
|
||||
}>(
|
||||
ringingMedia$.pipe(
|
||||
switchMap((ringingMedia) => {
|
||||
if (ringingMedia.length > 0)
|
||||
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
||||
|
||||
const localUserMedia = mediaItems.find(
|
||||
(m) => m.vm instanceof LocalUserMediaViewModel,
|
||||
) as UserMedia | undefined;
|
||||
return screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0)
|
||||
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
|
||||
|
||||
const localUserMediaViewModel = localUserMedia?.vm as
|
||||
| LocalUserMediaViewModel
|
||||
| undefined;
|
||||
|
||||
if (!localUserMediaViewModel) {
|
||||
return of(null);
|
||||
}
|
||||
return localUserMediaViewModel.alwaysShow$.pipe(
|
||||
map((alwaysShow) => {
|
||||
if (alwaysShow) {
|
||||
return localUserMediaViewModel;
|
||||
}
|
||||
|
||||
return null;
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => ({
|
||||
spotlight: speaker ? [speaker] : [],
|
||||
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
||||
pip$: localUserMediaForPip$.pipe(
|
||||
map((m) => (m === speaker ? undefined : m)),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1020,8 +1027,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(
|
||||
@@ -1034,7 +1043,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,
|
||||
@@ -1056,28 +1065,61 @@ export function createCallViewModel$(
|
||||
}));
|
||||
|
||||
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
||||
combineLatest([spotlight$, pip$], (spotlight, pip) => ({
|
||||
type: "spotlight-expanded",
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
}));
|
||||
spotlightAndPip$.pipe(
|
||||
switchMap(({ spotlight, pip$ }) =>
|
||||
pip$.pipe(
|
||||
map((pip) => ({
|
||||
type: "spotlight-expanded" as const,
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
||||
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;
|
||||
// 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
|
||||
if (!remote || !local) return null;
|
||||
userMedia$.pipe(
|
||||
switchMap((userMedia) => {
|
||||
if (userMedia.length <= 2) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
return { type: "one-on-one", local, remote };
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(
|
||||
vm,
|
||||
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
);
|
||||
|
||||
if (remote !== undefined)
|
||||
return of({
|
||||
type: "one-on-one" as const,
|
||||
spotlight: remote,
|
||||
pip: local,
|
||||
});
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map((ringingMedia) => {
|
||||
return ringingMedia.length === 1
|
||||
? {
|
||||
type: "one-on-one" as const,
|
||||
spotlight: local,
|
||||
pip: ringingMedia[0],
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1119,7 +1161,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())
|
||||
@@ -1226,7 +1268,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
|
||||
@@ -1277,7 +1319,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);
|
||||
@@ -1488,11 +1530,13 @@ export function createCallViewModel$(
|
||||
|
||||
return {
|
||||
autoLeave$: autoLeave$,
|
||||
callPickupState$: callPickupState$,
|
||||
ringOverlay$: ringOverlay$,
|
||||
ringing$: scope.behavior(
|
||||
callPickupState$.pipe(map((state) => state === "ringing")),
|
||||
),
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
leave: localMembership.requestDisconnect,
|
||||
toggleScreenSharing: toggleScreenSharing,
|
||||
sharingScreen$: sharingScreen$,
|
||||
|
||||
@@ -1532,17 +1576,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}]`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { BehaviorSubject, type Observable, map, of } from "rxjs";
|
||||
import { onTestFinished, vi } from "vitest";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk";
|
||||
import EventEmitter from "events";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
@@ -63,15 +63,10 @@ const carol = local;
|
||||
|
||||
const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" });
|
||||
|
||||
const roomMembers = new Map(
|
||||
[alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map(
|
||||
(p) => [p.userId, p],
|
||||
),
|
||||
);
|
||||
|
||||
export interface CallViewModelInputs {
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
||||
roomMembers: RoomMember[];
|
||||
livekitConnectionState$: Behavior<ConnectionState>;
|
||||
speaking: Map<Participant, Observable<boolean>>;
|
||||
mediaDevices: MediaDevices;
|
||||
@@ -86,6 +81,15 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
{
|
||||
remoteParticipants$ = constant([]),
|
||||
rtcMembers$ = constant([localRtcMember]),
|
||||
roomMembers = [
|
||||
alice,
|
||||
aliceDoppelganger,
|
||||
bob,
|
||||
bobZeroWidthSpace,
|
||||
carol,
|
||||
dave,
|
||||
daveRTL,
|
||||
],
|
||||
livekitConnectionState$: connectionState$ = constant(
|
||||
ConnectionState.Connected,
|
||||
),
|
||||
@@ -127,9 +131,12 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
public getSyncState(): SyncState {
|
||||
return syncState;
|
||||
}
|
||||
public getAccessToken(): string | null {
|
||||
return "a-token";
|
||||
}
|
||||
})() as Partial<MatrixClient> as MatrixClient,
|
||||
getMembers: () => Array.from(roomMembers.values()),
|
||||
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
||||
getMembers: () => roomMembers,
|
||||
getMembersWithMembership: () => roomMembers,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||
rtcMembers$,
|
||||
|
||||
@@ -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";
|
||||
@@ -33,13 +33,22 @@ import {
|
||||
PublishState,
|
||||
TrackState,
|
||||
} from "./LocalMember";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
MatrixRTCTransportMissingError,
|
||||
} from "../../../utils/errors";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
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";
|
||||
import {
|
||||
type LocalTransport,
|
||||
type LocalTransportWithSFUConfig,
|
||||
} from "./LocalTransport";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
@@ -194,8 +203,8 @@ describe("LocalMembership", () => {
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
}),
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: () => {},
|
||||
leaveRoomSession: () => {},
|
||||
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
||||
leaveRoomSession: vi.fn(),
|
||||
} as unknown as MatrixRTCSession,
|
||||
muteStates: mockMuteStates(),
|
||||
trackProcessorState$: constant({
|
||||
@@ -212,12 +221,11 @@ 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,
|
||||
);
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
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 = {
|
||||
@@ -229,11 +237,16 @@ describe("LocalMembership", () => {
|
||||
),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: localTransport$,
|
||||
active$: behavior("a", { a: null }),
|
||||
};
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport$,
|
||||
localTransport$: behavior("a", { a: aLocalTransport }),
|
||||
});
|
||||
localMembership.requestJoinAndPublish();
|
||||
|
||||
@@ -244,22 +257,114 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should not publish to active transport if advertised has errors", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
|
||||
null,
|
||||
);
|
||||
|
||||
// Populate a connection for active
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportBConnected, []);
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([bTransport])),
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$,
|
||||
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
|
||||
};
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
return {} as unknown as Publisher;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport$: behavior("a", { a: aLocalTransport }),
|
||||
});
|
||||
localMembership.requestJoinAndPublish();
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("n-e", {
|
||||
n: TransportState.Waiting,
|
||||
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
|
||||
});
|
||||
|
||||
// Should not have created any publisher
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs if callIntent cannot be updated", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([])),
|
||||
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
|
||||
};
|
||||
async function reject(): Promise<void> {
|
||||
return Promise.reject(new Error("Not connected yet"));
|
||||
}
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockImplementation(reject),
|
||||
leaveRoomSession: vi.fn(),
|
||||
},
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
||||
});
|
||||
const expextedLog =
|
||||
"'not connected yet' while updating the call intent (this is expected on startup)";
|
||||
const internalLogger = vi.spyOn(localMembership.internalLoggerRef, "debug");
|
||||
|
||||
await flushPromises();
|
||||
defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true);
|
||||
expect(internalLogger).toHaveBeenCalledWith(expextedLog);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
const aTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransport,
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const aTransportWithSFUConfig = {
|
||||
transport: aTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
jwt: "foo",
|
||||
livekitAlias: "bar",
|
||||
livekitIdentity: "baz",
|
||||
url: "bro",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
|
||||
const bTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransport,
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const bTransportWithSFUConfig = {
|
||||
transport: bTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
jwt: "foo2",
|
||||
livekitAlias: "bar2",
|
||||
livekitIdentity: "baz2",
|
||||
url: "bro2",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
|
||||
@@ -271,7 +376,7 @@ describe("LocalMembership", () => {
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport.transport,
|
||||
transport: aTransport,
|
||||
} as unknown as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
@@ -280,14 +385,18 @@ describe("LocalMembership", () => {
|
||||
} as unknown as Connection;
|
||||
const connectionTransportBConnected = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: bTransport.transport,
|
||||
transport: bTransport,
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const localTransport$ = new BehaviorSubject(aTransport);
|
||||
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
let seed = 0;
|
||||
@@ -297,6 +406,9 @@ describe("LocalMembership", () => {
|
||||
seed += 1;
|
||||
logger.info(`creating [${a}]`);
|
||||
const p = {
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
stopPublishing: vi.fn().mockImplementation(() => {
|
||||
logger.info(`stopPublishing [${a}]`);
|
||||
}),
|
||||
@@ -320,28 +432,26 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$,
|
||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
||||
});
|
||||
await flushPromises();
|
||||
localTransport$.next(bTransport);
|
||||
activeTransport$.next({
|
||||
...aTransportWithSFUConfig,
|
||||
transport: bTransport,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(2);
|
||||
expect(publishers.length).toBe(2);
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
expect(publishers[1].stopTracks).not.toHaveBeenCalled();
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[1].stopPublishing).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
||||
aTransport.transport,
|
||||
);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(
|
||||
bTransport.transport,
|
||||
);
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
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
|
||||
expect(publishers[1].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[1].stopTracks).toHaveBeenCalled();
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
|
||||
@@ -350,8 +460,6 @@ describe("LocalMembership", () => {
|
||||
it("only start tracks if requested", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const localTransport$ = new BehaviorSubject(aTransport);
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
|
||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||
@@ -359,8 +467,9 @@ describe("LocalMembership", () => {
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const p = {
|
||||
stopPublishing: vi.fn(),
|
||||
stopTracks: vi.fn(),
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
tracks$.next([{}, {}] as LocalTrack[]);
|
||||
return Promise.resolve();
|
||||
@@ -377,6 +486,11 @@ describe("LocalMembership", () => {
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
// connectionManagerData.add(connectionTransportB, []);
|
||||
@@ -386,7 +500,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$,
|
||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||
@@ -395,11 +509,11 @@ describe("LocalMembership", () => {
|
||||
localMembership.startTracks();
|
||||
await flushPromises();
|
||||
expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
|
||||
// expect(localMembership.tracks$.value.length).toBe(2);
|
||||
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
publisherFactory.mockClear();
|
||||
});
|
||||
@@ -409,34 +523,36 @@ describe("LocalMembership", () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
const localTransport$ =
|
||||
|
||||
const activeTransport$ =
|
||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
|
||||
const connectionManagerData$ = new BehaviorSubject(
|
||||
new Epoch(connectionManagerData),
|
||||
);
|
||||
const publishers: Publisher[] = [];
|
||||
|
||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||
const publishing$ = new BehaviorSubject<boolean>(false);
|
||||
const createTrackResolver = Promise.withResolvers<void>();
|
||||
const publishResolver = Promise.withResolvers<void>();
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const p = {
|
||||
stopPublishing: vi.fn(),
|
||||
stopTracks: vi.fn().mockImplementation(() => {
|
||||
logger.info("stopTracks");
|
||||
tracks$.next([]);
|
||||
}),
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
await createTrackResolver.promise;
|
||||
tracks$.next([{}, {}] as LocalTrack[]);
|
||||
}),
|
||||
startPublishing: vi.fn().mockImplementation(async () => {
|
||||
await publishResolver.promise;
|
||||
publishing$.next(true);
|
||||
}),
|
||||
tracks$,
|
||||
publishing$,
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
@@ -455,14 +571,14 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$,
|
||||
},
|
||||
localTransport$,
|
||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual(
|
||||
TransportState.Waiting,
|
||||
);
|
||||
localTransport$.next(aTransport);
|
||||
activeTransport$.next(aTransportWithSFUConfig);
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
@@ -487,7 +603,7 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
(
|
||||
connectionManagerData2.getConnectionForTransport(aTransport.transport)!
|
||||
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next(ConnectionState.LivekitConnected);
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
@@ -536,7 +652,7 @@ describe("LocalMembership", () => {
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
|
||||
expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).not.toHaveBeenCalled();
|
||||
|
||||
expect(localMembership.localMemberState$.isStopped).toBe(false);
|
||||
scope.end();
|
||||
@@ -547,7 +663,7 @@ describe("LocalMembership", () => {
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
});
|
||||
// TODO add tests for matrix local matrix participation.
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { and$ } from "../../../utils/observable.ts";
|
||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
|
||||
import { type LocalTransport } from "./LocalTransport.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
|
||||
export enum TransportState {
|
||||
/** Not even a transport is available to the LocalMembership */
|
||||
@@ -125,9 +127,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<LocalTransport>;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
@@ -146,7 +148,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.
|
||||
@@ -160,7 +162,7 @@ interface Props {
|
||||
export const createLocalMembership$ = ({
|
||||
scope,
|
||||
connectionManager,
|
||||
localTransport$: localTransportCanThrow$,
|
||||
localTransport$,
|
||||
homeserverConnected,
|
||||
createPublisherFactory,
|
||||
joinMatrixRTC,
|
||||
@@ -200,27 +202,48 @@ export const createLocalMembership$ = ({
|
||||
* Fully connected
|
||||
*/
|
||||
connected$: Behavior<boolean>;
|
||||
internalLoggerRef: Logger;
|
||||
} => {
|
||||
const logger = parentLogger.getChild("[LocalMembership]");
|
||||
logger.debug(`Creating local membership..`);
|
||||
|
||||
// We consider error on the transport as fatal.
|
||||
// Whether it is the active transport or the preferred transport.
|
||||
const handleTransportError = (e: unknown): Observable<null> => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error ? e : new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
setTransportError(error);
|
||||
return of(null);
|
||||
};
|
||||
|
||||
// This is the transport that we will advertise in our membership.
|
||||
const advertisedTransport$ = localTransport$.pipe(
|
||||
switchMap((lt) => lt.advertised$),
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
);
|
||||
|
||||
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||
const localTransport$ = scope.behavior(
|
||||
localTransportCanThrow$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error
|
||||
? e
|
||||
: new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
setTransportError(error);
|
||||
return of(null);
|
||||
const activeTransport$ = scope.behavior(
|
||||
localTransport$.pipe(
|
||||
switchMap((lt) => {
|
||||
return combineLatest([lt.active$, lt.advertised$]).pipe(
|
||||
map(([active, advertised]) => {
|
||||
// Our policy is to not publish to another transport if our prefered transport is miss-configured
|
||||
if (advertised == null) return null;
|
||||
|
||||
return active?.transport ?? null;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -228,16 +251,14 @@ export const createLocalMembership$ = ({
|
||||
const localConnection$ = scope.behavior(
|
||||
combineLatest([
|
||||
connectionManager.connectionManagerData$,
|
||||
localTransport$,
|
||||
activeTransport$,
|
||||
]).pipe(
|
||||
map(([{ value: connectionData }, localTransport]) => {
|
||||
if (localTransport === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionData.getConnectionForTransport(
|
||||
localTransport.transport,
|
||||
);
|
||||
return connectionData.getConnectionForTransport(localTransport);
|
||||
}),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
@@ -266,11 +287,11 @@ export const createLocalMembership$ = ({
|
||||
|
||||
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
|
||||
if (error) {
|
||||
// This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other.
|
||||
// Will also occurs if you cancel screen sharing browser prompt.
|
||||
// This is not necessarily fatal, since the user might be able to join without media.
|
||||
// XXX We might want to give some user feedback here to let them know their media is not working.
|
||||
logger.error(`Failed to create local tracks:`, error);
|
||||
setMatrixError(
|
||||
// TODO is it fatal? Do we need to create a new Specialized Error?
|
||||
new UnknownCallError(new Error(`Media device error: ${error}`)),
|
||||
);
|
||||
}
|
||||
});
|
||||
// MATRIX RELATED
|
||||
@@ -310,13 +331,17 @@ export const createLocalMembership$ = ({
|
||||
// - destruct all current streams
|
||||
// - overwrite current publisher
|
||||
scope.reconcile(localConnection$, async (connection) => {
|
||||
logger.info(
|
||||
"reconcile based on new localConnection:",
|
||||
connection?.transport.livekit_service_url,
|
||||
);
|
||||
if (connection !== null) {
|
||||
const publisher = createPublisherFactory(connection);
|
||||
publisher$.next(publisher);
|
||||
|
||||
// Clean-up callback
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
await publisher.stopPublishing();
|
||||
await publisher.stopTracks();
|
||||
await publisher.destroy();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -395,7 +420,7 @@ export const createLocalMembership$ = ({
|
||||
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
|
||||
combineLatest([
|
||||
localConnectionState$,
|
||||
localTransport$,
|
||||
activeTransport$,
|
||||
joinAndPublishRequested$,
|
||||
from(trackStartRequested.promise).pipe(
|
||||
map(() => true),
|
||||
@@ -520,23 +545,32 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
});
|
||||
|
||||
combineLatest([muteStates.video.enabled$, homeserverConnected.combined$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([videoEnabled, connected]) => {
|
||||
if (!connected) return;
|
||||
void matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio");
|
||||
});
|
||||
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
|
||||
void matrixRTCSession
|
||||
.updateCallIntent(videoEnabled ? "video" : "audio")
|
||||
.catch((e) => {
|
||||
if (e instanceof Error && e.message === "Not connected yet") {
|
||||
logger.debug(
|
||||
"'not connected yet' while updating the call intent (this is expected on startup)",
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keep matrix rtc session in sync with localTransport$, connectRequested$
|
||||
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([localTransport$, joinAndPublishRequested$])),
|
||||
scope.behavior(
|
||||
combineLatest([advertisedTransport$, joinAndPublishRequested$]),
|
||||
),
|
||||
async ([transport, shouldConnect]) => {
|
||||
if (!transport) return;
|
||||
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
|
||||
if (!shouldConnect) return;
|
||||
|
||||
try {
|
||||
joinMatrixRTC(transport.transport);
|
||||
joinMatrixRTC(transport);
|
||||
} catch (error) {
|
||||
logger.error("Error entering RTC session", error);
|
||||
if (error instanceof Error)
|
||||
@@ -669,6 +703,7 @@ export const createLocalMembership$ = ({
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
connection$: localConnection$,
|
||||
internalLoggerRef: logger,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -704,7 +739,7 @@ interface EnterRTCSessionOptions {
|
||||
export function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
options: EnterRTCSessionOptions,
|
||||
): void {
|
||||
const { encryptMedia, matrixRTCMode } = options;
|
||||
@@ -722,12 +757,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,29 @@ import {
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
|
||||
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
||||
import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
mockRtcMembership,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import {
|
||||
createLocalTransport$,
|
||||
JwtEndpointVersion,
|
||||
type LocalTransportWithSFUConfig,
|
||||
} from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -34,23 +48,22 @@ describe("LocalTransport", () => {
|
||||
const openIdResponse: openIDSFU.SFUConfig = {
|
||||
url: "https://lk.example.org",
|
||||
jwt: testJWTToken,
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
};
|
||||
|
||||
let scope: ObservableScope;
|
||||
beforeEach(() => (scope = new ObservableScope()));
|
||||
afterEach(() => scope.end());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope,
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
getDomain: () => "",
|
||||
baseUrl: "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
@@ -58,14 +71,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,25 +97,26 @@ 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",
|
||||
// Use empty domain to skip .well-known and use config directly
|
||||
getDomain: () => "",
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
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 +126,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 +142,10 @@ describe("LocalTransport", () => {
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope,
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -138,82 +154,159 @@ describe("LocalTransport", () => {
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
},
|
||||
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 scope = testScope();
|
||||
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([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
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 scope = testScope();
|
||||
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]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// Our own preferred transport should be advertised
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// No transport should be active however (there is still no oldest member)
|
||||
expect(active$.value).toBe(null);
|
||||
|
||||
// Now Bob joins the call and becomes the oldest member
|
||||
memberships$.next([bobMembership]);
|
||||
await flushPromises();
|
||||
// We should still advertise our own preferred transport (to avoid
|
||||
// unnecessary state changes)
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// Bob's transport should become active
|
||||
expect(active$.value?.transport).toBe(bobTransport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,10 +322,10 @@ describe("LocalTransport", () => {
|
||||
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
|
||||
localTransportOpts = {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope,
|
||||
scope: testScope(),
|
||||
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: {
|
||||
@@ -240,6 +333,7 @@ describe("LocalTransport", () => {
|
||||
getDomain: vi.fn().mockReturnValue(""),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
@@ -258,66 +352,111 @@ 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("Should not call _unstable_getRTCTransports in widget mode but use well-known", async () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://do-not-use.lk.example.org" },
|
||||
});
|
||||
|
||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||
|
||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://use-me.jwt.call.example.org",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
localTransportOpts.client.getAccessToken.mockReturnValue(null);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
localTransportOpts.client._unstable_getRTCTransports,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
const expectedTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://use-me.jwt.call.example.org",
|
||||
};
|
||||
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
});
|
||||
|
||||
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 +464,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 +476,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 +509,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$({
|
||||
scope,
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
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: {
|
||||
@@ -389,6 +528,7 @@ describe("LocalTransport", () => {
|
||||
baseUrl: "https://example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
@@ -396,9 +536,94 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => localTransport$.value).toThrow(
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
|
||||
// For simplicity, we'll just use the config livekit
|
||||
customLivekitUrl.setValue("https://lk.example.org");
|
||||
|
||||
const authCallSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
.mockResolvedValue(openIdResponse);
|
||||
|
||||
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
// We want multi-sdu
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: delayId$,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
baseUrl: "https://example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const advertisedValues: LivekitTransportConfig[] = [];
|
||||
const activeValues: LocalTransportWithSFUConfig[] = [];
|
||||
advertised$
|
||||
.pipe(filter((v) => v !== null))
|
||||
.subscribe((t) => advertisedValues.push(t));
|
||||
active$
|
||||
.pipe(filter((v) => v !== null))
|
||||
.subscribe((t) => activeValues.push(t));
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// we have now an active and an advertised
|
||||
expect(advertisedValues.length).toEqual(1);
|
||||
expect(activeValues.length).toEqual(1);
|
||||
expect(advertisedValues[0]!.livekit_service_url).toEqual(
|
||||
"https://lk.example.org",
|
||||
);
|
||||
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
|
||||
"https://lk.example.org",
|
||||
);
|
||||
|
||||
expect(authCallSpy).toHaveBeenCalledTimes(2);
|
||||
// Now emits 3 new delays id
|
||||
delayId$.next("delay_id_1");
|
||||
await flushPromises();
|
||||
delayId$.next("delay_id_2");
|
||||
await flushPromises();
|
||||
delayId$.next("delay_id_3");
|
||||
await flushPromises();
|
||||
|
||||
// No new emissions should've happened, it is the same transport.
|
||||
expect(advertisedValues.length).toEqual(1);
|
||||
expect(activeValues.length).toEqual(1);
|
||||
|
||||
// Still we should have updated the delayID to auth
|
||||
expect(authCallSpy).toHaveBeenCalledTimes(
|
||||
4 * 2 /* 2 calls for each delayId ?? why */,
|
||||
);
|
||||
|
||||
expect(authCallSpy).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
delayId: "delay_id_3",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,19 +7,23 @@ 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 { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
} from "../../../livekit/openIDSFU.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { customLivekitUrl } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
|
||||
@@ -54,12 +59,13 @@ interface Props {
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
> &
|
||||
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 +96,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;
|
||||
}
|
||||
|
||||
export 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,278 +136,283 @@ export const createLocalTransport$ = ({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
useOldestMember$,
|
||||
forceJwtEndpoint$,
|
||||
useOldestMember,
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
|
||||
/**
|
||||
* The transport over which we should be actively publishing our media.
|
||||
* undefined when not joined.
|
||||
*/
|
||||
const oldestMemberTransport$ = scope.behavior(
|
||||
combineLatest([memberships$]).pipe(
|
||||
map(([memberships]) => {
|
||||
const oldestMember = memberships.value[0];
|
||||
const transport = oldestMember?.getTransport(memberships.value[0]);
|
||||
if (!transport) return null;
|
||||
return transport;
|
||||
}),
|
||||
first((t) => t != null && isLivekitTransport(t)),
|
||||
switchMap((transport) => {
|
||||
// Get the open jwt token to connect to the sfu
|
||||
const computeLocalTransportWithSFUConfig =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
||||
return {
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
};
|
||||
};
|
||||
return from(computeLocalTransportWithSFUConfig());
|
||||
}),
|
||||
),
|
||||
null,
|
||||
}: Props): LocalTransport => {
|
||||
// The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
// oldest member has no such transport.
|
||||
const oldestMemberTransport$ = observerOldestMembership$(scope, memberships$);
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
resolvedConfig: Config.get(),
|
||||
wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery),
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
// Get the preferred transport from the current deployment.
|
||||
const discoveredTransport$ = from(
|
||||
transportDiscovery.discoverPreferredTransport(),
|
||||
);
|
||||
|
||||
/**
|
||||
* The transport that we would personally prefer to publish on (if not for the
|
||||
* transport preferences of others, perhaps).
|
||||
*
|
||||
* @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,
|
||||
delayId,
|
||||
forceEndpoint,
|
||||
);
|
||||
return from(
|
||||
makeTransport(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
customUrl,
|
||||
forceEndpoint,
|
||||
delayId ?? undefined,
|
||||
),
|
||||
);
|
||||
const preferredConfig$ = customLivekitUrl.value$
|
||||
.pipe(
|
||||
switchMap((customUrl) => {
|
||||
if (customUrl) {
|
||||
return of({
|
||||
type: "livekit",
|
||||
livekit_service_url: customUrl,
|
||||
} as LivekitTransportConfig);
|
||||
} else {
|
||||
return discoveredTransport$;
|
||||
}
|
||||
}),
|
||||
),
|
||||
null,
|
||||
)
|
||||
.pipe(
|
||||
map((config) => {
|
||||
if (!config) {
|
||||
// Bubbled up from the preferredConfig$ observable.
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
}
|
||||
return config;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
);
|
||||
|
||||
const preferredTransport$ = combineLatest([preferredConfig$, delayId$]).pipe(
|
||||
switchMap(async ([transport, delayId]) => {
|
||||
try {
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
forceJwtEndpoint,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
delayId ?? undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The chosen transport we should advertise in our MatrixRTC membership.
|
||||
*/
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
useOldestMember$,
|
||||
if (useOldestMember) {
|
||||
return observeLocalTransportForOldestMembership(
|
||||
scope,
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$,
|
||||
]).pipe(
|
||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) =>
|
||||
useOldestMember
|
||||
? (oldestMemberTransport ?? preferredTransport)
|
||||
: preferredTransport,
|
||||
),
|
||||
distinctUntilChanged((t1, t2) =>
|
||||
areLivekitTransportsEqual(t1?.transport ?? null, t2?.transport ?? null),
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Multi-SFU mode ---
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
map((t) => t.transport),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
null,
|
||||
),
|
||||
);
|
||||
active$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
// XXX: WORK AROUND due to a reconnection glitch.
|
||||
// To remove when we have a proper way to refresh the delegation event ID without refreshing
|
||||
// the whole credentials.
|
||||
// We deliberately hide any changes to the SFU config because we
|
||||
// do not 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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
/**
|
||||
* Observes the oldest member in the room and returns the transport that it uses if it is a livekit transport.
|
||||
* @param scope - The observable scope.
|
||||
* @param memberships$ - The observable of the call's memberships.'
|
||||
*/
|
||||
function observerOldestMembership$(
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
): Behavior<LivekitTransportConfig | null> {
|
||||
return 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the correct Transport for the current session, including
|
||||
* validating auth against the service to ensure it's correct.
|
||||
* Prefers in order:
|
||||
* Utility to ensure the user can authenticate with the SFU.
|
||||
* We will call `getSFUConfigWithOpenID` once per transport here as it's our
|
||||
* only mechanism of validation. This means we will also ask the
|
||||
* homeserver for a OpenID token a few times. Since OpenID tokens are single
|
||||
* use we don't want to risk any issues by re-using a token.
|
||||
*
|
||||
|
||||
* 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw.
|
||||
* 2. The transports returned via the homeserver.
|
||||
* 3. The transports returned via .well-known.
|
||||
* 4. The transport configured in Element Call's config.
|
||||
* @param transport The transport to authenticate with.
|
||||
* @param forceJwtEndpoint Whether to force the JWT endpoint to be used.
|
||||
* @param membership The identity of the local member.
|
||||
* @param roomId The room ID to use for the JWT.
|
||||
* @param client The client to use for the OpenID token.
|
||||
* @param delayId The delayId to use for the JWT.
|
||||
*
|
||||
* @param client The authenticated Matrix client for the current user
|
||||
* @param membership The membership identity of the user.
|
||||
* @param roomId The ID of the room to be connected to.
|
||||
* @param urlFromDevSettings Override URL provided by the user's local config.
|
||||
* @param forceJwtEndpoint Whether to force a specific JWT endpoint
|
||||
* - `Legacy` / `Matrix_2_0`
|
||||
* - `get_token` / `sfu/get`
|
||||
* - not hashing / hashing the backendIdentity
|
||||
* @param delayId the delay id passed to the jwt service.
|
||||
*
|
||||
* @returns A fully validated transport config.
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
||||
*/
|
||||
async function makeTransport(
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
async function doOpenIdAndJWTFromUrl(
|
||||
transport: LivekitTransportConfig,
|
||||
forceJwtEndpoint: JwtEndpointVersion,
|
||||
membership: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
urlFromDevSettings: string | null,
|
||||
forceJwtEndpoint: JwtEndpointVersion,
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
delayId?: string,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
logger.trace("Searching for a preferred transport");
|
||||
|
||||
async function doOpenIdAndJWTFromUrl(
|
||||
url: string,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
membership,
|
||||
url,
|
||||
roomId,
|
||||
{
|
||||
forceJwtEndpoint: forceJwtEndpoint,
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
delayId,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
// We will call `getSFUConfigWithOpenID` once per transport here as it's our
|
||||
// only mechanism of valiation. This means we will also ask the
|
||||
// homeserver for a OpenID token a few times. Since OpenID tokens are single
|
||||
// use we don't want to risk any issues by re-using a token.
|
||||
//
|
||||
// If the OpenID request were to fail then it's acceptable for us to fail
|
||||
// this function early, as we assume the homeserver has got some problems.
|
||||
|
||||
// DEVTOOL: Highest priority: Load from devtool setting
|
||||
if (urlFromDevSettings !== null) {
|
||||
// Validate that the SFU is up. Otherwise, we want to fail on this
|
||||
// as we don't permit other SFUs.
|
||||
// This will call the jwt/sfu/get endpoint to pre create the livekit room.
|
||||
logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
|
||||
return await doOpenIdAndJWTFromUrl(urlFromDevSettings);
|
||||
}
|
||||
|
||||
async function getFirstUsableTransport(
|
||||
transports: Transport[],
|
||||
): Promise<LocalTransportWithSFUConfig | null> {
|
||||
for (const potentialTransport of transports) {
|
||||
if (isLivekitTransportConfig(potentialTransport)) {
|
||||
try {
|
||||
// This will call the jwt/sfu/get endpoint to pre create the livekit room.
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
potentialTransport.livekit_service_url,
|
||||
);
|
||||
} catch (ex) {
|
||||
// Explictly throw these
|
||||
if (ex instanceof FailToGetOpenIdToken) {
|
||||
throw ex;
|
||||
}
|
||||
if (ex instanceof NoMatrix2AuthorizationService) {
|
||||
throw ex;
|
||||
}
|
||||
logger.debug(
|
||||
`Could not use SFU service "${potentialTransport.livekit_service_url}" as SFU`,
|
||||
ex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
if ("_unstable_getRTCTransports" in client) {
|
||||
try {
|
||||
const selectedTransport = await getFirstUsableTransport(
|
||||
await client._unstable_getRTCTransports(),
|
||||
);
|
||||
if (selectedTransport) {
|
||||
logger.info("Using backend-configured SFU", selectedTransport);
|
||||
return selectedTransport;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex instanceof MatrixError && ex.httpStatus === 404) {
|
||||
// Expected, this is an unstable endpoint and it's not required.
|
||||
logger.debug("Backend does not provide any RTC transports", ex);
|
||||
} else if (ex instanceof FailToGetOpenIdToken) {
|
||||
throw ex;
|
||||
} else {
|
||||
// We got an error that wasn't just missing support for the feature, so log it loudly.
|
||||
logger.error(
|
||||
"Unexpected error fetching RTC transports from backend",
|
||||
ex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available.
|
||||
const domain = client.getDomain();
|
||||
if (domain) {
|
||||
// we use AutoDiscovery instead of relying on the MatrixClient having already
|
||||
// been fully configured and started
|
||||
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
|
||||
FOCI_WK_KEY
|
||||
];
|
||||
const selectedTransport = Array.isArray(wellKnownFoci)
|
||||
? await getFirstUsableTransport(wellKnownFoci)
|
||||
: null;
|
||||
if (selectedTransport) {
|
||||
logger.info("Using .well-known SFU", selectedTransport);
|
||||
return selectedTransport;
|
||||
}
|
||||
}
|
||||
|
||||
// CONFIG: Least prioritized; Load from config file
|
||||
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
||||
if (urlFromConf) {
|
||||
try {
|
||||
// This will call the jwt/sfu/get endpoint to pre create the livekit room.
|
||||
logger.info("Using config SFU", urlFromConf);
|
||||
return await doOpenIdAndJWTFromUrl(urlFromConf);
|
||||
} catch (ex) {
|
||||
if (ex instanceof FailToGetOpenIdToken) {
|
||||
throw ex;
|
||||
}
|
||||
logger.error("Failed to validate config SFU", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// If we do not have returned a transport by now we throw an error
|
||||
throw new MatrixRTCTransportMissingError(domain ?? "");
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
membership,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{
|
||||
forceJwtEndpoint: forceJwtEndpoint,
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
delayId,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
return {
|
||||
transport,
|
||||
sfuConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function observeLocalTransportForOldestMembership(
|
||||
scope: ObservableScope,
|
||||
oldestMemberTransport$: Behavior<LivekitTransportConfig | null>,
|
||||
preferredTransport$: Observable<LocalTransportWithSFUConfig>,
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
): LocalTransport {
|
||||
// Ensure we can authenticate with the SFU.
|
||||
const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe(
|
||||
switchMap((transport) => {
|
||||
// Oldest member not available -we are first- (or invalid SFU config).
|
||||
if (transport === null) return of(null);
|
||||
|
||||
// Whenever there is transport change we want to revert
|
||||
// to no transport while we do the authentication.
|
||||
// So do a from(promise) here to be able to startWith(null)
|
||||
return from(
|
||||
doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
JwtEndpointVersion.Legacy,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
undefined,
|
||||
),
|
||||
).pipe(
|
||||
catchError((e: unknown) => {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}),
|
||||
startWith(null),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- 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(
|
||||
authenticatedOldestMemberTransport$.pipe(
|
||||
map((t) => t?.transport ?? null),
|
||||
),
|
||||
preferredTransport$.pipe(map((t) => t.transport)),
|
||||
).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(
|
||||
authenticatedOldestMemberTransport$.pipe(
|
||||
tap((t) =>
|
||||
logger.info(
|
||||
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
||||
if (
|
||||
e instanceof FailToGetOpenIdToken ||
|
||||
e instanceof NoMatrix2AuthorizationService
|
||||
) {
|
||||
// rethrow as is
|
||||
return e;
|
||||
}
|
||||
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
|
||||
return new FailToGetOpenIdToken(
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user