Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b5f2845d | ||
|
|
afee9eaa26 | ||
|
|
d53ad9a8f3 | ||
|
|
d8634eed3d | ||
|
|
be4b70c1e1 | ||
|
|
e79cded57f | ||
|
|
486430d1f0 | ||
|
|
e6ddf40b1b | ||
|
|
9f521a79f7 | ||
|
|
83784a717a | ||
|
|
0729deee79 | ||
|
|
77c3114cf8 | ||
|
|
82a56c8204 | ||
|
|
b39896d8c6 | ||
|
|
79b3fdb645 | ||
|
|
0f877cd021 | ||
|
|
db2acc75b2 | ||
|
|
a5dbfbf2c1 | ||
|
|
34c7d02de2 | ||
|
|
ca45067158 | ||
|
|
5a6eb7c573 | ||
|
|
5ad2a27a92 | ||
|
|
68daaa45f9 | ||
|
|
c40ea35937 | ||
|
|
d27f433175 | ||
|
|
8a6101cd14 | ||
|
|
4db7c2bc68 | ||
|
|
18740fc686 | ||
|
|
0c39398493 | ||
|
|
949145f04b | ||
|
|
8578dcadf2 | ||
|
|
959db44eca | ||
|
|
a031c0e128 | ||
|
|
591833505f | ||
|
|
f7ad5074d8 | ||
|
|
e0aef74bf5 | ||
|
|
b2378bf899 | ||
|
|
255f6b1814 | ||
|
|
4c491b5363 | ||
|
|
61c808d4cf | ||
|
|
13ef3183e2 | ||
|
|
afd4fdcea2 | ||
|
|
982181ccd4 | ||
|
|
30629ebba2 | ||
|
|
7f6a32d21a | ||
|
|
320ade0a50 | ||
|
|
8c6fee3150 | ||
|
|
5c6acaf915 | ||
|
|
c46549b2b6 | ||
|
|
97a58f6db7 | ||
|
|
b6288579c9 | ||
|
|
44bf987cdc | ||
|
|
a7d55824bb | ||
|
|
8fa038c61f | ||
|
|
869d9b43cb | ||
|
|
974a2fe49b | ||
|
|
022497c8e5 | ||
|
|
207a5f047c | ||
|
|
d3c63f9314 | ||
|
|
f9ef037cea | ||
|
|
8666ffec81 | ||
|
|
4b85879891 | ||
|
|
f376291f50 | ||
|
|
f0f56bf101 | ||
|
|
ba647780e8 | ||
|
|
812ae2ce89 | ||
|
|
09ca3b4dc0 | ||
|
|
86afde8612 | ||
|
|
469f0b5983 | ||
|
|
69d1beaf28 | ||
|
|
7f22f442b1 | ||
|
|
ab0f8fa2e3 | ||
|
|
f5abbb1e5e | ||
|
|
a80c96d187 | ||
|
|
fb0c8fb92b | ||
|
|
438a6c2a42 | ||
|
|
0c8cd0842a | ||
|
|
b39d35d5d1 | ||
|
|
6f00a961c9 | ||
|
|
0ed7853958 | ||
|
|
4acf279f32 | ||
|
|
a7c065f300 | ||
|
|
a0d248065d | ||
|
|
c18008e039 | ||
|
|
0bab898c25 | ||
|
|
53d1f717c9 | ||
|
|
0a69664186 | ||
|
|
d1307e61b9 | ||
|
|
a267eca78d | ||
|
|
51a8d2b718 | ||
|
|
2d76a3780d | ||
|
|
e70818b2da | ||
|
|
03144783ac | ||
|
|
b9ab2fdf1b | ||
|
|
183a1845bb | ||
|
|
e423fc0ace | ||
|
|
40cbe5408e | ||
|
|
2c3e95f401 | ||
|
|
1fdff9bbd4 | ||
|
|
bd7209cd40 | ||
|
|
70fdc68b13 | ||
|
|
f9bed2c2a9 | ||
|
|
33437d9743 | ||
|
|
1f4139ae0a | ||
|
|
b8ad8baf9d | ||
|
|
694048dd7f | ||
|
|
a5088d4ae9 | ||
|
|
d2bdaf7049 | ||
|
|
0aeb68b445 | ||
|
|
f40740edd3 | ||
|
|
cd47b63d29 | ||
|
|
07ce272e9f | ||
|
|
a3c4e3e2a5 | ||
|
|
6b6ad16306 | ||
|
|
0f38445fdd | ||
|
|
b9b53ec251 | ||
|
|
b1a5c8c120 | ||
|
|
b44680149d | ||
|
|
cd0cec32b5 | ||
|
|
2faa9c9d50 | ||
|
|
caab45ee7f | ||
|
|
d3687298e0 | ||
|
|
42612476b8 | ||
|
|
7021ea6a5c | ||
|
|
ff09631546 | ||
|
|
28a3dfef23 | ||
|
|
2b5561a88c | ||
|
|
9dc740c2de | ||
|
|
0b93374e86 | ||
|
|
d36af0cae6 | ||
|
|
7fae106da2 | ||
|
|
a84b692f20 | ||
|
|
86a5c24750 | ||
|
|
ed6f02ac56 | ||
|
|
f701886aa9 | ||
|
|
d298e3438c | ||
|
|
c405b61c66 | ||
|
|
6be67aa145 | ||
|
|
603c658949 | ||
|
|
ebc33e003d | ||
|
|
023ab9fc47 | ||
|
|
417faf795d | ||
|
|
c8fe393fcf | ||
|
|
e6a9555a91 | ||
|
|
fd65baed58 | ||
|
|
d079bee5e0 | ||
|
|
1918478069 | ||
|
|
c6d8d5e137 | ||
|
|
7a4583dcb0 | ||
|
|
929175d826 | ||
|
|
49ce642c2d | ||
|
|
ce14c0f6fe | ||
|
|
1a10b67248 | ||
|
|
eda5f14a19 | ||
|
|
e6683569f8 | ||
|
|
90273c1924 | ||
|
|
10f49d0d84 | ||
|
|
418ee89e0f | ||
|
|
a4a57e5307 | ||
|
|
477eb0034a | ||
|
|
5ec57f04c6 | ||
|
|
69cb17adc8 | ||
|
|
8718a7139b | ||
|
|
94f267b93e | ||
|
|
2151696374 | ||
|
|
2679948dbe | ||
|
|
86e84028e1 | ||
|
|
dea6f05b51 | ||
|
|
ac450443a0 | ||
|
|
2a0375d93f | ||
|
|
56312b2753 | ||
|
|
137867b096 | ||
|
|
0ae62b121e | ||
|
|
70682a7490 | ||
|
|
7fc0f96ca6 | ||
|
|
f50b00e00f | ||
|
|
e9c98a02f0 | ||
|
|
dcb4d10afb | ||
|
|
705f9daf5f | ||
|
|
c5e60744a2 | ||
|
|
3670c36fac | ||
|
|
b5d25f1f2d | ||
|
|
51926cad3d | ||
|
|
2ab909fab1 | ||
|
|
cfe0b4d8e4 | ||
|
|
83795ae4bf | ||
|
|
8a4d4d3144 | ||
|
|
0e7fd791b5 | ||
|
|
96e6ca0c0e | ||
|
|
2e0ad5ca69 | ||
|
|
43f7f9b76c | ||
|
|
224cd53481 | ||
|
|
a5f7921a32 | ||
|
|
b8c8e36449 | ||
|
|
f07a491bd9 | ||
|
|
7fb0eb150d | ||
|
|
6e0a20a213 | ||
|
|
af99d2a60b | ||
|
|
b32d066a76 | ||
|
|
a0f9c55194 | ||
|
|
bb9cbe26b3 | ||
|
|
5284479ece | ||
|
|
be25d77e8b | ||
|
|
46732cf86b | ||
|
|
bfb26ca500 | ||
|
|
4288037f51 | ||
|
|
c52d5dc573 | ||
|
|
d543c1a9c0 | ||
|
|
6cfc2066c9 |
18
.github/workflows/docker.yaml
vendored
18
.github/workflows/docker.yaml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
docker_tags:
|
||||
required: true
|
||||
type: string
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
default: ${{ github.run_id }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -19,18 +23,18 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5f4866a30a54f16a52d2ecb4a3898e9e424939cf
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -38,16 +42,16 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@1294d94f8ee362ab42b6da04c35f4cd03a0e6af7
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@a530e948adbeb357dbca95a7f8845d385edf4438
|
||||
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@7e6f77677b7892794c8852c6e3773c3e9bc3129a
|
||||
uses: docker/build-push-action@1a162644f9a7e87d8f4b053101d1d9a712edc18c # v6.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out test private repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
with:
|
||||
repository: element-hq/static-call-participant
|
||||
ref: refs/heads/main
|
||||
|
||||
6
.github/workflows/element-call.yaml
vendored
6
.github/workflows/element-call.yaml
vendored
@@ -21,9 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist
|
||||
|
||||
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -7,9 +7,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
|
||||
14
.github/workflows/netlify.yaml
vendored
14
.github/workflows/netlify.yaml
vendored
@@ -14,6 +14,10 @@ on:
|
||||
deployment_ref:
|
||||
required: true
|
||||
type: string
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
default: ${{ github.run_id }}
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
@@ -30,7 +34,7 @@ jobs:
|
||||
environment: Netlify
|
||||
steps:
|
||||
- name: 📝 Create Deployment
|
||||
uses: bobheadxi/deployments@v1
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
id: deployment
|
||||
with:
|
||||
step: start
|
||||
@@ -42,10 +46,10 @@ jobs:
|
||||
Exercise caution. Use test accounts.
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output
|
||||
path: webapp
|
||||
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@v3.0
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
@@ -69,7 +73,7 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: 🚦 Update deployment status
|
||||
uses: bobheadxi/deployments@v1
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
if: always()
|
||||
with:
|
||||
step: finish
|
||||
|
||||
4
.github/workflows/pr-deploy.yaml
vendored
4
.github/workflows/pr-deploy.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
steps:
|
||||
- id: prdetails
|
||||
uses: matrix-org/pr-details-action@v1.3
|
||||
uses: matrix-org/pr-details-action@15bde5285d7850ba276cc3bd8a03733e3f24622a # v1.3
|
||||
continue-on-error: true
|
||||
with:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
deployments: write
|
||||
uses: ./.github/workflows/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 }}
|
||||
@@ -43,6 +44,7 @@ jobs:
|
||||
packages: write
|
||||
uses: ./.github/workflows/docker.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=raw,value=pr_${{ needs.prdetails.outputs.pr_number }}
|
||||
|
||||
6
.github/workflows/publish.yaml
vendored
6
.github/workflows/publish.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
id: current-time
|
||||
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -51,18 +51,20 @@ jobs:
|
||||
run: |
|
||||
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@b06cde36fc32a3ee94080e87258567f73f921537
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
path: "./element-call-*.tar.gz"
|
||||
publish_docker:
|
||||
needs: publish_tarball
|
||||
if: always()
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
uses: ./.github/workflows/docker.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=semver,pattern=v{{version}}
|
||||
|
||||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -9,9 +9,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
|
||||
with:
|
||||
flags: unittests
|
||||
|
||||
8
.github/workflows/translations-download.yaml
vendored
8
.github/workflows/translations-download.yaml
vendored
@@ -13,9 +13,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
run: "rm -R public/locales"
|
||||
|
||||
- name: Download translation files
|
||||
uses: localazy/download@v1.1.0
|
||||
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
|
||||
with:
|
||||
groups: "-p includeSourceLang:true"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v6.0.3
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
4
.github/workflows/translations-upload.yaml
vendored
4
.github/workflows/translations-upload.yaml
vendored
@@ -14,9 +14,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@v1
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
with:
|
||||
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
|
||||
|
||||
38
README.md
38
README.md
@@ -54,6 +54,38 @@ Therefore, to use a self-hosted homeserver, this is recommended to be a new serv
|
||||
|
||||
There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found in [ConfigOptions.ts](src/config/ConfigOptions.ts).
|
||||
|
||||
If you're using [Synapse](https://github.com/element-hq/synapse/), you'll need to additionally add the following to `homeserver.yaml` or Element Call won't work:
|
||||
|
||||
```
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
```
|
||||
|
||||
MSC3266 allows to request a room summary of rooms you are not joined.
|
||||
The summary contains the room join rules. We need that to decide if the user gets prompted with the option to knock ("ask to join"), a cannot join error or the join view.
|
||||
|
||||
Element Call requires a Livekit SFU behind a Livekit jwt service to work. The url to the Livekit jwt service can either be configured in the config of Element Call (fallback/legacy configuration) or be configured by your homeserver via the `.well-known`.
|
||||
This is the recommended method.
|
||||
|
||||
The configuration is a list of Foci configs:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://someurl.com"
|
||||
},
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://livekit2.com"
|
||||
},
|
||||
{
|
||||
"type": "another_foci",
|
||||
"props_for_another_foci": "val"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## Translation
|
||||
|
||||
If you'd like to help translate Element Call, head over to [Localazy](https://localazy.com/p/element-call). You're also encouraged to join the [Element Translators](https://matrix.to/#/#translators:element.io) space to discuss and coordinate translation efforts.
|
||||
@@ -93,11 +125,13 @@ service for development. These use a test 'secret' published in this
|
||||
repository, so this must be used only for local development and
|
||||
**_never be exposed to the public Internet._**
|
||||
|
||||
To use it, add SFU parameter in your local config `./public/config.json`:
|
||||
To use it, add a SFU parameter in your local config `./public/config.json`:
|
||||
(Be aware, that this is only the fallback Livekit SFU. If the homeserver
|
||||
advertises one in the client well-known, this will not be used.)
|
||||
|
||||
```json
|
||||
"livekit": {
|
||||
"livekit_service_url": "http://localhost:8881"
|
||||
"livekit_service_url": "http://localhost:7881"
|
||||
},
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
port: 7880
|
||||
environment: dev
|
||||
bind_addresses:
|
||||
- "0.0.0.0"
|
||||
rtc:
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
"server_name": "call.ems.host"
|
||||
}
|
||||
},
|
||||
"livekit": {
|
||||
"livekit_service_url": "http://localhost:7881"
|
||||
},
|
||||
"eula": "https://static.element.io/legal/online-EULA.pdf"
|
||||
}
|
||||
|
||||
31
package.json
31
package.json
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "element-call",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,9 +26,9 @@
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/context-zone": "^1.9.1",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.36.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.39.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.39.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
@@ -46,7 +47,7 @@
|
||||
"@react-stately/select": "^3.1.3",
|
||||
"@react-stately/tooltip": "^3.0.5",
|
||||
"@react-stately/tree": "^3.2.0",
|
||||
"@sentry/react": "^7.0.0",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/tracing": "^7.0.0",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
@@ -58,11 +59,11 @@
|
||||
"classnames": "^2.3.1",
|
||||
"events": "^3.3.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^7.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.0.0",
|
||||
"livekit-client": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#d55c6a36df539f6adacc335efe5b9be27c9cee4a",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
|
||||
"matrix-widget-api": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pako": "^2.0.4",
|
||||
@@ -70,15 +71,15 @@
|
||||
"posthog-js": "^1.29.0",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react-i18next": "^14.0.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"tinyqueue": "^2.0.3",
|
||||
"tinyqueue": "^3.0.0",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "9",
|
||||
"uuid": "10",
|
||||
"vaul": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -89,17 +90,19 @@
|
||||
"@react-spring/rafz": "^9.7.3",
|
||||
"@react-types/dialog": "^3.5.5",
|
||||
"@sentry/vite-plugin": "^2.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/dom": "^10.1.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/dom-screen-wake-lock": "^1.0.1",
|
||||
"@types/dompurify": "^3.0.2",
|
||||
"@types/grecaptcha": "^3.0.4",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/request": "^2.48.8",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "9",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"babel-loader": "^9.0.0",
|
||||
@@ -113,8 +116,8 @@
|
||||
"eslint-plugin-matrix-org": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"i18next-parser": "^8.0.0",
|
||||
"eslint-plugin-unicorn": "^54.0.0",
|
||||
"i18next-parser": "^9.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"sass": "^1.42.1",
|
||||
@@ -123,6 +126,6 @@
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,17 @@
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
||||
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
||||
"group_call_loader_failed_heading": "Call not found",
|
||||
"group_call_loader_failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
|
||||
"group_call_loader": {
|
||||
"banned_body": "You have been banned from the room.",
|
||||
"banned_heading": "Banned",
|
||||
"call_ended_body": "You have been removed from the call.",
|
||||
"call_ended_heading": "Call ended",
|
||||
"failed_heading": "Failed to join",
|
||||
"failed_text": "Call not found or is not accessible.",
|
||||
"knock_reject_body": "The room members declined your request to join.",
|
||||
"knock_reject_heading": "Not allowed to join",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"hangup_button_label": "End call",
|
||||
"header_label": "Element Call Home",
|
||||
"header_participants_label": "Participants",
|
||||
@@ -77,8 +86,10 @@
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents"
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
},
|
||||
"log_in": "Log In",
|
||||
"logging_in": "Logging in…",
|
||||
|
||||
@@ -3,9 +3,42 @@
|
||||
"extends": ["config:base"],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Disable renoavte for packages we want to monitor ourselves",
|
||||
"matchPackagePatterns": ["matrix-js-sdk"],
|
||||
"extends": ["group:allNonMajor", "schedule:weekly"]
|
||||
},
|
||||
{
|
||||
"groupName": "GitHub Actions",
|
||||
"matchDepTypes": ["action"],
|
||||
"pinDigests": true,
|
||||
"extends": ["schedule:monthly"]
|
||||
},
|
||||
{
|
||||
"description": "Disable Renovate for packages we want to monitor ourselves",
|
||||
"groupName": "manually updated packages",
|
||||
"matchDepNames": ["matrix-js-sdk"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"groupName": "matrix-widget-api",
|
||||
"matchDepNames": ["matrix-widget-api"]
|
||||
},
|
||||
{
|
||||
"groupName": "Compound",
|
||||
"matchPackagePrefixes": ["@vector-im/compound-"],
|
||||
"schedule": "before 5am on Tuesday and Friday"
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit client",
|
||||
"matchDepNames": ["livekit-client"]
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit components",
|
||||
"matchPackagePrefixes": ["@livekit/components-"]
|
||||
},
|
||||
{
|
||||
"groupName": "Vaul",
|
||||
"matchDepNames": ["vaul"],
|
||||
"extends": ["schedule:monthly"],
|
||||
"prHeader": "Please review modals on mobile for visual regressions."
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
|
||||
6
src/@types/global.d.ts
vendored
6
src/@types/global.d.ts
vendored
@@ -28,12 +28,6 @@ declare global {
|
||||
OLM_OPTIONS: Record<string, string>;
|
||||
}
|
||||
|
||||
// TypeScript doesn't know about the experimental setSinkId method, so we
|
||||
// declare it ourselves
|
||||
interface MediaElement extends HTMLVideoElement {
|
||||
setSinkId: (id: string) => void;
|
||||
}
|
||||
|
||||
interface HTMLElement {
|
||||
// Safari only supports this prefixed, so tell the type system about it
|
||||
webkitRequestFullscreen: () => void;
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
ClientEvent,
|
||||
ICreateClientOpts,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
@@ -317,7 +321,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
@@ -360,13 +364,13 @@ async function loadClient(): Promise<InitResult | null> {
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||
const initClientParams = {
|
||||
const initClientParams: ICreateClientOpts = {
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit!.livekit_service_url,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,7 @@ interface ErrorViewProps {
|
||||
|
||||
export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
||||
const location = useLocation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,25 +79,26 @@ export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
||||
: error.message}
|
||||
</p>
|
||||
<RageshakeButton description={`***Error View***: ${error.message}`} />
|
||||
{location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
)}
|
||||
{!confineToRoom &&
|
||||
(location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
))}
|
||||
</FullScreenView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,6 +109,7 @@ limitations under the License.
|
||||
|
||||
.participantsLine {
|
||||
grid-area: participants;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-1-5x);
|
||||
|
||||
@@ -117,7 +117,7 @@ interface RoomHeaderInfoProps {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
encrypted: boolean;
|
||||
participantCount: number;
|
||||
participantCount: number | null;
|
||||
}
|
||||
|
||||
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
@@ -150,7 +150,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
</Heading>
|
||||
<EncryptionLock encrypted={encrypted} />
|
||||
</div>
|
||||
{participantCount > 0 && (
|
||||
{(participantCount ?? 0) > 0 && (
|
||||
<div className={styles.participantsLine}>
|
||||
<UserProfileIcon
|
||||
width={20}
|
||||
@@ -158,7 +158,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
aria-label={t("header_participants_label")}
|
||||
/>
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
{t("participant_count", { count: participantCount })}
|
||||
{t("participant_count", { count: participantCount ?? 0 })}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const Toast: FC<Props> = ({
|
||||
useEffect(() => {
|
||||
if (open && autoDismiss !== undefined) {
|
||||
const timeout = setTimeout(onDismiss, autoDismiss);
|
||||
return () => clearTimeout(timeout);
|
||||
return (): void => clearTimeout(timeout);
|
||||
}
|
||||
}, [open, autoDismiss, onDismiss]);
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ limitations under the License.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
|
||||
export const PASSWORD_STRING = "password=";
|
||||
import { EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
@@ -328,3 +329,32 @@ export const useRoomIdentifier = (): RoomIdentifier => {
|
||||
[pathname, search, hash],
|
||||
);
|
||||
};
|
||||
|
||||
export function generateUrlSearchParams(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
viaServers?: string[],
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||
// it in case it came from another client that generated a non url-safe one
|
||||
switch (encryptionSystem?.kind) {
|
||||
case E2eeType.SHARED_KEY: {
|
||||
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
|
||||
if (encodedPassword !== encryptionSystem.secret) {
|
||||
logger.info(
|
||||
"Encoded call password used non URL-safe chars: buggy client?",
|
||||
);
|
||||
}
|
||||
params.set("password", encodedPassword);
|
||||
break;
|
||||
}
|
||||
case E2eeType.PER_PARTICIPANT:
|
||||
params.set("perParticipantE2EE", "true");
|
||||
break;
|
||||
}
|
||||
params.set("roomId", roomId);
|
||||
viaServers?.forEach((s) => params.set("viaServers", s));
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ limitations under the License.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
createClient,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
@@ -37,7 +41,7 @@ export function useInteractiveLogin(): (
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: () =>
|
||||
doRequest: (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
|
||||
@@ -16,7 +16,11 @@ limitations under the License.
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
createClient,
|
||||
MatrixClient,
|
||||
RegisterResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
@@ -69,7 +73,7 @@ export const useInteractiveRegistration = (): {
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: (auth) =>
|
||||
doRequest: (auth): Promise<RegisterResponse> =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
|
||||
@@ -55,16 +55,31 @@ export interface ConfigOptions {
|
||||
|
||||
// Describes the LiveKit configuration to be used.
|
||||
livekit?: {
|
||||
// The link to the service that returns a livekit url and token to use it
|
||||
// The link to the service that returns a livekit url and token to use it.
|
||||
// This is a fallback link in case the homeserver in use does not advertise
|
||||
// a livekit service url in the client well-known.
|
||||
// The well known needs to be formatted like so:
|
||||
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
|
||||
// and stored under the key: "livekit_focus"
|
||||
livekit_service_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow to join a group calls without audio and video.
|
||||
* TEMPORARY: Is a feature that's not proved and experimental
|
||||
* TEMPORARY experimental features.
|
||||
*/
|
||||
features?: {
|
||||
feature_group_calls_without_video_and_audio: boolean;
|
||||
/**
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
* This setting has no effect when the user joins an active call with
|
||||
* legacy state events. For compatibility, Element Call will always join
|
||||
* active legacy calls with legacy state events.
|
||||
*/
|
||||
feature_use_device_session_member_events?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,12 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Room } from "matrix-js-sdk";
|
||||
|
||||
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { widget } from "../widget";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
export function saveKeyForRoom(roomId: string, password: string): void {
|
||||
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||
@@ -68,30 +67,37 @@ const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
|
||||
: [undefined, undefined];
|
||||
};
|
||||
|
||||
export const useRoomSharedKey = (roomId: string): string | undefined => {
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
|
||||
// make sure we've extracted the key from the URL first
|
||||
// (and we still need to take the value it returns because
|
||||
// the effect won't run in time for it to save to localstorage in
|
||||
// time for us to read it out again).
|
||||
const [urlRoomId, passwordFormUrl] = useKeyFromUrl();
|
||||
|
||||
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||
|
||||
if (storedPassword) return storedPassword;
|
||||
if (urlRoomId === roomId) return passwordFormUrl;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
||||
const { client } = useClient();
|
||||
const room = useMemo(() => client?.getRoom(roomId), [roomId, client]);
|
||||
|
||||
return useMemo(() => !room || isRoomE2EE(room), [room]);
|
||||
};
|
||||
|
||||
export function isRoomE2EE(room: Room): boolean {
|
||||
// For now, rooms in widget mode are never considered encrypted.
|
||||
// In the future, when widget mode gains encryption support, then perhaps we
|
||||
// should inspect the e2eEnabled URL parameter here?
|
||||
return widget === null && !room.getCanonicalAlias();
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: storedPassword,
|
||||
};
|
||||
if (urlRoomId === roomId)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: passwordFromUrl,
|
||||
};
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return { kind: E2eeType.PER_PARTICIPANT };
|
||||
}
|
||||
return { kind: E2eeType.NONE };
|
||||
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||
return e2eeSystem;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import styles from "./CallList.module.css";
|
||||
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface CallListProps {
|
||||
rooms: GroupCallRoom[];
|
||||
@@ -66,16 +66,11 @@ interface CallTileProps {
|
||||
}
|
||||
|
||||
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
||||
|
||||
const roomEncryptionSystem = useRoomEncryptionSystem(room.roomId);
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
<Link
|
||||
to={getRelativeRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined,
|
||||
)}
|
||||
to={getRelativeRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||
className={styles.callTileLink}
|
||||
>
|
||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
@@ -89,11 +84,8 @@ const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
variant="icon"
|
||||
value={getAbsoluteRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined,
|
||||
)}
|
||||
// Todo add the viaServers to the created link
|
||||
value={getAbsoluteRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,12 +78,14 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
roomName,
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
createRoomResult.password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,13 +116,15 @@ export const UnauthenticatedView: FC = () => {
|
||||
if (!setClient) {
|
||||
throw new Error("setClient is undefined");
|
||||
}
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
setClient({ client, session });
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
createRoomResult.password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,20 +15,22 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
||||
import { useState, useEffect } from "react";
|
||||
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { getKeyForRoom, isRoomE2EE } from "../e2ee/sharedKeyManagement";
|
||||
import { getKeyForRoom } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export interface GroupCallRoom {
|
||||
roomAlias?: string;
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
room: Room;
|
||||
groupCall: GroupCall;
|
||||
session: MatrixRTCSession;
|
||||
participants: RoomMember[];
|
||||
}
|
||||
const tsCache: { [index: string]: number } = {};
|
||||
@@ -46,7 +48,7 @@ function getLastTs(client: MatrixClient, r: Room): number {
|
||||
|
||||
const myUserId = client.getUserId()!;
|
||||
|
||||
if (r.getMyMembership() !== "join") {
|
||||
if (r.getMyMembership() !== KnownMembership.Join) {
|
||||
const membershipEvent = r.currentState.getStateEvents(
|
||||
"m.room.member",
|
||||
myUserId,
|
||||
@@ -80,38 +82,77 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
||||
});
|
||||
}
|
||||
|
||||
function roomIsJoinable(room: Room): boolean {
|
||||
if (isRoomE2EE(room)) {
|
||||
return Boolean(getKeyForRoom(room.roomId));
|
||||
} else {
|
||||
return true;
|
||||
const roomIsJoinable = (room: Room): boolean => {
|
||||
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
// in case this key also does not exists we cannot join the room.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// otherwise we can always join rooms because we will automatically decide if we want to use perParticipant or password
|
||||
switch (room.getJoinRule()) {
|
||||
case JoinRule.Public:
|
||||
return true;
|
||||
case JoinRule.Knock:
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
case KnownMembership.Knock:
|
||||
return true;
|
||||
case KnownMembership.Invite:
|
||||
return (
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomMember, room.myUserId)
|
||||
?.getPrevContent().membership === JoinRule.Knock
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
// TODO: check JoinRule.Restricted and return true if join condition is satisfied
|
||||
default:
|
||||
return room.getMyMembership() === KnownMembership.Join;
|
||||
}
|
||||
};
|
||||
|
||||
const roomHasCallMembershipEvents = (room: Room): boolean => {
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
return !!room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.get(EventType.GroupCallMemberPrefix);
|
||||
case KnownMembership.Knock:
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
const [rooms, setRooms] = useState<GroupCallRoom[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
function updateRooms(): void {
|
||||
if (!client.groupCallEventHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCalls = client.groupCallEventHandler.groupCalls.values();
|
||||
const rooms = Array.from(groupCalls)
|
||||
.map((groupCall) => groupCall.room)
|
||||
// We want to show all rooms that historically had a call and which we are (can become) part of.
|
||||
const rooms = client
|
||||
.getRooms()
|
||||
.filter(roomHasCallMembershipEvents)
|
||||
.filter(roomIsJoinable);
|
||||
const sortedRooms = sortRooms(client, rooms);
|
||||
const items = sortedRooms.map((room) => {
|
||||
const groupCall = client.getGroupCallForRoom(room.roomId)!;
|
||||
|
||||
const session = client.matrixRTC.getRoomSession(room);
|
||||
session.memberships;
|
||||
return {
|
||||
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||
roomName: room.name,
|
||||
avatarUrl: room.getMxcAvatarUrl()!,
|
||||
room,
|
||||
groupCall,
|
||||
participants: [...groupCall!.participants.keys()],
|
||||
session,
|
||||
participants: session.memberships
|
||||
.filter((m) => m.sender)
|
||||
.map((m) => room.getMember(m.sender!))
|
||||
.filter((m) => m) as RoomMember[],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -120,15 +161,17 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
|
||||
updateRooms();
|
||||
|
||||
client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
|
||||
|
||||
return () => {
|
||||
client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.removeListener(
|
||||
GroupCallEventHandlerEvent.Participants,
|
||||
client.matrixRTC.on(
|
||||
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||
updateRooms,
|
||||
);
|
||||
client.on(RoomEvent.MyMembership, updateRooms);
|
||||
return (): void => {
|
||||
client.matrixRTC.off(
|
||||
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||
updateRooms,
|
||||
);
|
||||
client.off(RoomEvent.MyMembership, updateRooms);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { BrowserTracing } from "@sentry/browser";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
@@ -160,10 +159,7 @@ export class Initializer {
|
||||
dsn: Config.get().sentry?.DSN,
|
||||
environment: Config.get().sentry?.environment,
|
||||
integrations: [
|
||||
new BrowserTracing({
|
||||
routingInstrumentation:
|
||||
Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
Sentry.reactRouterV5BrowserTracingIntegration({ history }),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Focus } from "matrix-js-sdk/src/matrixrtc/focus";
|
||||
|
||||
export interface LivekitFocus extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function useObservableState<T>(
|
||||
// observable state doesn't run in SSR
|
||||
if (typeof window === "undefined" || !observable) return;
|
||||
const subscription = observable.subscribe(setState);
|
||||
return () => subscription.unsubscribe();
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [observable]);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import { IOpenIDToken, MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
import { LivekitFocus } from "./LivekitFocus";
|
||||
import { useActiveFocus } from "../room/useActiveFocus";
|
||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||
|
||||
export interface SFUConfig {
|
||||
url: string;
|
||||
@@ -46,7 +46,7 @@ export function useOpenIDSFU(
|
||||
): SFUConfig | undefined {
|
||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||
|
||||
const activeFocus = useActiveFocus(rtcSession);
|
||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||
|
||||
useEffect(() => {
|
||||
(async (): Promise<void> => {
|
||||
|
||||
@@ -192,7 +192,7 @@ export function useECConnectionState(
|
||||
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
if (oldRoom)
|
||||
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
};
|
||||
|
||||
@@ -41,11 +41,7 @@ import {
|
||||
} from "./useECConnectionState";
|
||||
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
export type E2EEConfig = {
|
||||
mode: E2eeType;
|
||||
sharedKey?: string;
|
||||
};
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
@@ -56,41 +52,35 @@ export function useLiveKit(
|
||||
rtcSession: MatrixRTCSession,
|
||||
muteStates: MuteStates,
|
||||
sfuConfig: SFUConfig | undefined,
|
||||
e2eeConfig: E2EEConfig,
|
||||
e2eeSystem: EncryptionSystem,
|
||||
): UseLivekitResult {
|
||||
const e2eeOptions = useMemo((): E2EEOptions | undefined => {
|
||||
if (e2eeConfig.mode === E2eeType.NONE) return undefined;
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
|
||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return {
|
||||
keyProvider: new MatrixKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
} else if (
|
||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
||||
e2eeConfig.sharedKey
|
||||
) {
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
return {
|
||||
keyProvider: new ExternalE2EEKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
}
|
||||
}, [e2eeConfig]);
|
||||
}, [e2eeSystem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (e2eeConfig.mode === E2eeType.NONE || !e2eeOptions) return;
|
||||
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||
|
||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||
} else if (
|
||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
||||
e2eeConfig.sharedKey
|
||||
) {
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey(
|
||||
e2eeConfig.sharedKey,
|
||||
e2eeSystem.secret,
|
||||
);
|
||||
}
|
||||
}, [e2eeOptions, e2eeConfig, rtcSession]);
|
||||
}, [e2eeOptions, e2eeSystem, rtcSession]);
|
||||
|
||||
const initialMuteStates = useRef<MuteStates>(muteStates);
|
||||
const devices = useMediaDevices();
|
||||
@@ -131,9 +121,9 @@ export function useLiveKit(
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
const room = useMemo(() => {
|
||||
const r = new Room(roomOptions);
|
||||
r.setE2EEEnabled(e2eeConfig.mode !== E2eeType.NONE);
|
||||
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE);
|
||||
return r;
|
||||
}, [roomOptions, e2eeConfig]);
|
||||
}, [roomOptions, e2eeSystem]);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
{
|
||||
|
||||
@@ -19,25 +19,25 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
||||
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
|
||||
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
|
||||
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
createClient,
|
||||
ICreateClientOpts,
|
||||
Preset,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||
import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
|
||||
import { generateUrlSearchParams, getUrlParams } from "./UrlParams";
|
||||
import { loadOlm } from "./olm";
|
||||
import { Config } from "./config/Config";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
||||
import { EncryptionSystem, saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
||||
|
||||
export const fallbackICEServerAllowed =
|
||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||
@@ -55,7 +55,13 @@ const SYNC_STORE_NAME = "element-call-sync";
|
||||
// (It's a good opportunity to make the database names consistent.)
|
||||
const CRYPTO_STORE_NAME = "element-call-crypto";
|
||||
|
||||
function waitForSync(client: MatrixClient): Promise<void> {
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
@@ -63,8 +69,12 @@ function waitForSync(client: MatrixClient): Promise<void> {
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
@@ -190,8 +200,14 @@ export async function initClient(
|
||||
|
||||
await client.initCrypto();
|
||||
client.setGlobalErrorOnUnknownDevices(false);
|
||||
await client.startClient();
|
||||
await waitForSync(client);
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -338,16 +354,6 @@ export async function createRoom(
|
||||
|
||||
const result = await createPromise;
|
||||
|
||||
logger.log(`Creating group call in ${result.room_id}`);
|
||||
|
||||
await client.createGroupCall(
|
||||
result.room_id,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
GroupCallIntent.Room,
|
||||
true,
|
||||
);
|
||||
|
||||
let password;
|
||||
if (e2ee == E2eeType.SHARED_KEY) {
|
||||
password = secureRandomBase64Url(16);
|
||||
@@ -365,39 +371,35 @@ export async function createRoom(
|
||||
* Returns an absolute URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param password e2e key for the room
|
||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||
*/
|
||||
export function getAbsoluteRoomUrl(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
roomName?: string,
|
||||
password?: string,
|
||||
viaServers?: string[],
|
||||
): string {
|
||||
return `${window.location.protocol}//${
|
||||
window.location.host
|
||||
}${getRelativeRoomUrl(roomId, roomName, password)}`;
|
||||
}${getRelativeRoomUrl(roomId, encryptionSystem, roomName, viaServers)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a relative URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param password e2e key for the room
|
||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||
*/
|
||||
export function getRelativeRoomUrl(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
roomName?: string,
|
||||
password?: string,
|
||||
viaServers?: string[],
|
||||
): string {
|
||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||
// it in case it came from another client that generated a non url-safe one
|
||||
const encodedPassword = password ? encodeURIComponent(password) : undefined;
|
||||
if (password && encodedPassword !== password) {
|
||||
logger.info("Encoded call password used non URL-safe chars: buggy client?");
|
||||
}
|
||||
|
||||
return `/room/#${
|
||||
roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
|
||||
}?roomId=${roomId}${password ? "&" + PASSWORD_STRING + encodedPassword : ""}`;
|
||||
const roomPart = roomName
|
||||
? "/" + roomAliasLocalpartFromRoomName(roomName)
|
||||
: "";
|
||||
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
SummaryStatsReport,
|
||||
CallFeedReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
@@ -446,7 +445,7 @@ export class OTelGroupCallMembership {
|
||||
const type = OTelStatsReportType.SummaryReport;
|
||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = setSpan(
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan,
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
||||
user?.on(UserEvent.AvatarUrl, onChangeUser);
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
if (user) {
|
||||
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
||||
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
||||
@@ -118,7 +118,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
||||
displayName,
|
||||
avatarUrl: removeAvatar
|
||||
? undefined
|
||||
: mxcAvatarUrl ?? prev.avatarUrl,
|
||||
: (mxcAvatarUrl ?? prev.avatarUrl),
|
||||
loading: false,
|
||||
success: true,
|
||||
}));
|
||||
|
||||
@@ -21,13 +21,14 @@ import PopOutIcon from "@vector-im/compound-design-tokens/icons/pop-out.svg?reac
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||
import styles from "./AppSelectionModal.module.css";
|
||||
import { editFragmentQuery } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
interface Props {
|
||||
roomId: string | null;
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
@@ -42,10 +43,9 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
const e2eeSystem = useRoomEncryptionSystem(roomId);
|
||||
|
||||
const roomSharedKey = useRoomSharedKey(roomId ?? "");
|
||||
const roomIsEncrypted = useIsRoomE2EE(roomId ?? "");
|
||||
if (roomIsEncrypted && roomSharedKey === undefined) {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) {
|
||||
logger.error(
|
||||
"Generating app redirect URL for encrypted room but don't have key available!",
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
const url = new URL(
|
||||
roomId === null
|
||||
? window.location.href
|
||||
: getAbsoluteRoomUrl(roomId, undefined, roomSharedKey ?? undefined),
|
||||
: getAbsoluteRoomUrl(roomId, e2eeSystem),
|
||||
);
|
||||
// Edit the URL to prevent the app selection prompt from appearing a second
|
||||
// time within the app, and to keep the user confined to the current room
|
||||
@@ -73,7 +73,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
const result = new URL("io.element.call:/");
|
||||
result.searchParams.set("url", url.toString());
|
||||
return result.toString();
|
||||
}, [roomId, roomSharedKey]);
|
||||
}, [e2eeSystem, roomId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -14,22 +14,25 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
CallTerminatedMessage,
|
||||
} from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (rtcSession: MatrixRTCSession) => ReactNode;
|
||||
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
@@ -51,20 +54,22 @@ export function GroupCallLoader({
|
||||
);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
case "canKnock":
|
||||
return children(groupCallState);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "loaded":
|
||||
return <>{children(groupCallState.rtcSession)}</>;
|
||||
case "failed":
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader_failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader_failed_text")}</Text>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
@@ -72,6 +77,22 @@ export function GroupCallLoader({
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("common.home")}
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ limitations under the License.
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Room, isE2EESupported } from "livekit-client";
|
||||
import {
|
||||
Room,
|
||||
isE2EESupported as isE2EESupportedBrowser,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
@@ -26,7 +29,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
import { FullScreenView } from "../FullScreenView";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
@@ -34,17 +37,16 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../media-utils";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { MuteStates, useMuteStates } from "./MuteStates";
|
||||
import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { E2EEConfig } from "../livekit/useLiveKit";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
@@ -62,6 +64,7 @@ interface Props {
|
||||
skipLobby: boolean;
|
||||
hideHeader: boolean;
|
||||
rtcSession: MatrixRTCSession;
|
||||
muteStates: MuteStates;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -72,13 +75,21 @@ export const GroupCallView: FC<Props> = ({
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
rtcSession,
|
||||
muteStates,
|
||||
}) => {
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
return () => {
|
||||
return (): void => {
|
||||
delete window.rtcSession;
|
||||
};
|
||||
}, [rtcSession]);
|
||||
@@ -86,10 +97,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(rtcSession.room);
|
||||
const roomAvatar = useRoomAvatar(rtcSession.room);
|
||||
const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
|
||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
||||
const roomEncrypted =
|
||||
useIsRoomE2EE(rtcSession.room.roomId) || perParticipantE2EE;
|
||||
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
return {
|
||||
@@ -100,16 +109,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
roomName,
|
||||
roomAlias: rtcSession.room.getCanonicalAlias(),
|
||||
roomAvatar,
|
||||
roomEncrypted,
|
||||
e2eeSystem,
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
rtcSession,
|
||||
rtcSession.room,
|
||||
roomName,
|
||||
roomAvatar,
|
||||
roomEncrypted,
|
||||
client,
|
||||
e2eeSystem,
|
||||
]);
|
||||
|
||||
// Count each member only once, regardless of how many devices they use
|
||||
@@ -122,20 +131,9 @@ export const GroupCallView: FC<Props> = ({
|
||||
const latestDevices = useRef<MediaDevices>();
|
||||
latestDevices.current = deviceContext;
|
||||
|
||||
const muteStates = useMuteStates(memberships.length);
|
||||
const latestMuteStates = useRef<MuteStates>();
|
||||
latestMuteStates.current = muteStates;
|
||||
|
||||
const e2eeConfig = useMemo((): E2EEConfig => {
|
||||
if (perParticipantE2EE) {
|
||||
return { mode: E2eeType.PER_PARTICIPANT };
|
||||
} else if (e2eeSharedKey) {
|
||||
return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
|
||||
} else {
|
||||
return { mode: E2eeType.NONE };
|
||||
}
|
||||
}, [perParticipantE2EE, e2eeSharedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDeviceSetup = async (
|
||||
requestedDeviceData: JoinCallData,
|
||||
@@ -193,11 +191,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): Promise<void> => {
|
||||
defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return () => {
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
@@ -252,7 +250,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
await leaveRTCSession(rtcSession);
|
||||
};
|
||||
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
||||
return () => {
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
||||
};
|
||||
}
|
||||
@@ -288,17 +286,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (roomEncrypted && !perParticipantE2EE && !e2eeSharedKey) {
|
||||
return (
|
||||
<ErrorView
|
||||
error={
|
||||
new Error(
|
||||
"No E2EE key provided: please make sure the URL you're using to join this call has been retrieved using the in-app button.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (!isE2EESupported() && roomEncrypted) {
|
||||
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
||||
// If we have a encryption system but the browser does not support it.
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
|
||||
@@ -324,7 +313,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={(): void => enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={participantCount}
|
||||
@@ -345,7 +334,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
onLeave={onLeave}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
e2eeConfig={e2eeConfig}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
onShareClick={onShareClick}
|
||||
/>
|
||||
|
||||
@@ -85,13 +85,13 @@ limitations under the License.
|
||||
|
||||
@media (min-height: 400px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-10x);
|
||||
padding-block: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-15x);
|
||||
padding-block: var(--cpd-space-10x);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { E2EEConfig, useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useFullscreen } from "./useFullscreen";
|
||||
import { useLayoutStates } from "../video-grid/Layout";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
@@ -76,13 +76,15 @@ import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import { useCallViewModel } from "../state/CallViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
e2eeConfig: E2EEConfig;
|
||||
e2eeSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
@@ -91,11 +93,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.rtcSession,
|
||||
props.muteStates,
|
||||
sfuConfig,
|
||||
props.e2eeConfig,
|
||||
props.e2eeSystem,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
return (): void => {
|
||||
livekitRoom?.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -218,7 +220,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
onSpotlightLayout,
|
||||
);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.TileLayout,
|
||||
onTileLayout,
|
||||
@@ -238,7 +240,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
const vm = useCallViewModel(
|
||||
rtcSession.room,
|
||||
livekitRoom,
|
||||
matrixInfo.roomEncrypted,
|
||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||
connState,
|
||||
);
|
||||
const items = useStateObservable(vm.tiles);
|
||||
@@ -252,7 +254,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
() =>
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
? (items.find((item) => item.isSpeaker) ?? items.at(0) ?? null)
|
||||
: null),
|
||||
[fullscreenItem, noControls, items],
|
||||
);
|
||||
@@ -432,7 +434,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
|
||||
@@ -25,8 +25,8 @@ import useClipboard from "react-use-clipboard";
|
||||
import { Modal } from "../Modal";
|
||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||
import styles from "./InviteModal.module.css";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { Toast } from "../Toast";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
@@ -36,11 +36,11 @@ interface Props {
|
||||
|
||||
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getAbsoluteRoomUrl(room.roomId, room.name, roomSharedKey ?? undefined),
|
||||
[room, roomSharedKey],
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
);
|
||||
const [, setCopied] = useClipboard(url);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
|
||||
@@ -25,6 +25,18 @@ limitations under the License.
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wait {
|
||||
color: var(--cpd-color-text-primary) !important;
|
||||
background-color: var(--cpd-color-bg-canvas-default) !important;
|
||||
/* relative colors are only supported on chromium based browsers */
|
||||
background-color: rgb(
|
||||
from var(--cpd-color-bg-canvas-default) r g b / 0.5
|
||||
) !important;
|
||||
}
|
||||
.wait > svg {
|
||||
color: var(--cpd-color-theme-primary) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.join {
|
||||
width: 100%;
|
||||
|
||||
@@ -21,8 +21,8 @@ import { Button, Link } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import styles from "./LobbyView.module.css";
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
@@ -36,16 +36,19 @@ import {
|
||||
} from "../button/Button";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
onEnter: () => void;
|
||||
enterLabel?: JSX.Element | string;
|
||||
confineToRoom: boolean;
|
||||
hideHeader: boolean;
|
||||
participantCount: number;
|
||||
participantCount: number | null;
|
||||
onShareClick: (() => void) | null;
|
||||
waitingForInvite?: boolean;
|
||||
}
|
||||
|
||||
export const LobbyView: FC<Props> = ({
|
||||
@@ -53,10 +56,12 @@ export const LobbyView: FC<Props> = ({
|
||||
matrixInfo,
|
||||
muteStates,
|
||||
onEnter,
|
||||
enterLabel,
|
||||
confineToRoom,
|
||||
hideHeader,
|
||||
participantCount,
|
||||
onShareClick,
|
||||
waitingForInvite,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
useLocationNavigation();
|
||||
@@ -104,7 +109,7 @@ export const LobbyView: FC<Props> = ({
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
@@ -116,12 +121,16 @@ export const LobbyView: FC<Props> = ({
|
||||
<div className={styles.content}>
|
||||
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
|
||||
<Button
|
||||
className={styles.join}
|
||||
size="lg"
|
||||
onClick={onEnter}
|
||||
className={classNames(styles.join, {
|
||||
[styles.wait]: waitingForInvite,
|
||||
})}
|
||||
size={waitingForInvite ? "sm" : "lg"}
|
||||
onClick={() => {
|
||||
if (!waitingForInvite) onEnter();
|
||||
}}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
{t("lobby.join_button")}
|
||||
{enterLabel ?? t("lobby.join_button")}
|
||||
</Button>
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
|
||||
@@ -20,10 +20,10 @@ import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
|
||||
/**
|
||||
* If there already is this many participants in the call, we automatically mute
|
||||
* the user
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
* the user.
|
||||
*/
|
||||
const MUTE_PARTICIPANT_COUNT = 8;
|
||||
export const MUTE_PARTICIPANT_COUNT = 8;
|
||||
|
||||
interface DeviceAvailable {
|
||||
enabled: boolean;
|
||||
@@ -51,26 +51,27 @@ function useMuteState(
|
||||
device: MediaDevice,
|
||||
enabledByDefault: () => boolean,
|
||||
): MuteState {
|
||||
const [enabled, setEnabled] = useReactiveState<boolean>(
|
||||
(prev) => device.available.length > 0 && (prev ?? enabledByDefault()),
|
||||
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
|
||||
(prev) =>
|
||||
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined,
|
||||
[device],
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
device.available.length === 0
|
||||
? deviceUnavailable
|
||||
: { enabled, setEnabled },
|
||||
: {
|
||||
enabled: enabled ?? false,
|
||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||
},
|
||||
[device, enabled, setEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
export function useMuteStates(participantCount: number): MuteStates {
|
||||
export function useMuteStates(): MuteStates {
|
||||
const devices = useMediaDevices();
|
||||
|
||||
const audio = useMuteState(
|
||||
devices.audioInput,
|
||||
() => participantCount <= MUTE_PARTICIPANT_COUNT,
|
||||
);
|
||||
const audio = useMuteState(devices.audioInput, () => true);
|
||||
const video = useMuteState(devices.videoInput, () => true);
|
||||
|
||||
return useMemo(() => ({ audio, video }), [audio, video]);
|
||||
|
||||
@@ -15,8 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
@@ -30,6 +31,11 @@ import { HomePage } from "../home/HomePage";
|
||||
import { platform } from "../Platform";
|
||||
import { AppSelectionModal } from "./AppSelectionModal";
|
||||
import { widget } from "../widget";
|
||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -40,7 +46,7 @@ export const RoomPage: FC = () => {
|
||||
displayName,
|
||||
skipLobby,
|
||||
} = useUrlParams();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||
|
||||
const roomIdOrAlias = roomId ?? roomAlias;
|
||||
@@ -48,17 +54,14 @@ export const RoomPage: FC = () => {
|
||||
logger.error("No room specified");
|
||||
}
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const { loading, authenticated, client, error, passwordlessUser } =
|
||||
useClientLegacy();
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
useEffect(() => {
|
||||
// If we've finished loading, are not already authed and we've been given a display name as
|
||||
@@ -77,19 +80,87 @@ export const RoomPage: FC = () => {
|
||||
registerPasswordlessUser,
|
||||
]);
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
useEffect(() => {
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
(rtcSession: MatrixRTCSession) => (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
/>
|
||||
),
|
||||
[client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby],
|
||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
passwordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
t,
|
||||
userDisplayName,
|
||||
avatarUrl,
|
||||
],
|
||||
);
|
||||
|
||||
let content: ReactNode;
|
||||
@@ -118,9 +189,9 @@ export const RoomPage: FC = () => {
|
||||
<>
|
||||
{content}
|
||||
{/* On Android and iOS, show a prompt to launch the mobile app. */}
|
||||
{appPrompt && (platform === "android" || platform === "ios") && (
|
||||
<AppSelectionModal roomId={roomId} />
|
||||
)}
|
||||
{appPrompt &&
|
||||
(platform === "android" || platform === "ios") &&
|
||||
roomId && <AppSelectionModal roomId={roomId} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, FC, ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import { usePreviewTracks } from "@livekit/components-react";
|
||||
@@ -32,6 +32,7 @@ import styles from "./VideoPreview.module.css";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
@@ -41,7 +42,7 @@ export type MatrixInfo = {
|
||||
roomName: string;
|
||||
roomAlias: string | null;
|
||||
roomAvatar: string | null;
|
||||
roomEncrypted: boolean;
|
||||
e2eeSystem: EncryptionSystem;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -67,8 +68,8 @@ export const VideoPreview: FC<Props> = ({
|
||||
deviceId: devices.audioInput.selectedId,
|
||||
};
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
const localTrackOptions = useMemo(
|
||||
() => ({
|
||||
// The only reason we request audio here is to get the audio permission
|
||||
// request over with at the same time. But changing the audio settings
|
||||
// shouldn't cause this hook to recreate the track, which is why we
|
||||
@@ -79,13 +80,21 @@ export const VideoPreview: FC<Props> = ({
|
||||
video: muteStates.video.enabled && {
|
||||
deviceId: devices.videoInput.selectedId,
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
}),
|
||||
[devices.videoInput.selectedId, muteStates.video.enabled],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
muteStates.video.setEnabled?.(false);
|
||||
},
|
||||
[muteStates.audio, muteStates.video],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.find((t) => t.kind === Track.Kind.Video) as
|
||||
@@ -101,7 +110,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
if (videoEl.current) {
|
||||
videoTrack?.attach(videoEl.current);
|
||||
}
|
||||
return () => {
|
||||
return (): void => {
|
||||
videoTrack?.detach();
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
@@ -21,33 +21,28 @@ import {
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { deepCompare } from "matrix-js-sdk/src/utils";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { LivekitFocus } from "../livekit/LivekitFocus";
|
||||
|
||||
function getActiveFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
const focus = oldestMembership?.getActiveFoci()[0] as LivekitFocus;
|
||||
|
||||
return focus;
|
||||
}
|
||||
import {
|
||||
LivekitFocus,
|
||||
isLivekitFocus,
|
||||
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
/**
|
||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||
* This logic is specific to livekit foci where the whole call must use one
|
||||
* and the same focus.
|
||||
*/
|
||||
export function useActiveFocus(
|
||||
export function useActiveLivekitFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const [activeFocus, setActiveFocus] = useState(() =>
|
||||
getActiveFocus(rtcSession),
|
||||
);
|
||||
const [activeFocus, setActiveFocus] = useState(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
});
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
const newActiveFocus = getActiveFocus(rtcSession);
|
||||
|
||||
const newActiveFocus = rtcSession.getActiveFocus();
|
||||
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
@@ -64,7 +59,7 @@ export function useActiveFocus(
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useFullscreen<T>(items: TileDescriptor<T>[]): {
|
||||
(prevItem) =>
|
||||
prevItem == null
|
||||
? null
|
||||
: items.find((i) => i.id === prevItem.id) ?? null,
|
||||
: (items.find((i) => i.id === prevItem.id) ?? null),
|
||||
[items],
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useFullscreen<T>(items: TileDescriptor<T>[]): {
|
||||
(itemId: string) => {
|
||||
setFullscreenItem(
|
||||
latestFullscreenItem.current === null
|
||||
? latestItems.current.find((i) => i.id === itemId) ?? null
|
||||
? (latestItems.current.find((i) => i.id === itemId) ?? null)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -14,15 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixClient,
|
||||
RoomSummary,
|
||||
} from "matrix-js-sdk/src/client";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { RoomEvent, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { JoinRule } from "matrix-js-sdk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { widget } from "../widget";
|
||||
|
||||
export type GroupCallLoaded = {
|
||||
kind: "loaded";
|
||||
@@ -38,14 +45,48 @@ export type GroupCallLoading = {
|
||||
kind: "loading";
|
||||
};
|
||||
|
||||
export type GroupCallWaitForInvite = {
|
||||
kind: "waitForInvite";
|
||||
roomSummary: RoomSummary;
|
||||
};
|
||||
|
||||
export type GroupCallCanKnock = {
|
||||
kind: "canKnock";
|
||||
roomSummary: RoomSummary;
|
||||
knock: () => void;
|
||||
};
|
||||
|
||||
export type GroupCallStatus =
|
||||
| GroupCallLoaded
|
||||
| GroupCallLoadFailed
|
||||
| GroupCallLoading;
|
||||
| GroupCallLoading
|
||||
| GroupCallWaitForInvite
|
||||
| GroupCallCanKnock;
|
||||
|
||||
export interface GroupCallLoadState {
|
||||
error?: Error;
|
||||
groupCall?: GroupCall;
|
||||
export class CallTerminatedMessage extends Error {
|
||||
/**
|
||||
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
|
||||
*/
|
||||
public messageBody: string;
|
||||
/**
|
||||
* @param reason The user provided reason for the termination (kick/ban)
|
||||
*/
|
||||
public reason?: string;
|
||||
/**
|
||||
*
|
||||
* @param messageTitle The title of the call ended screen message (translated)
|
||||
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
|
||||
* @param reason The user provided reason for the termination (kick/ban)
|
||||
*/
|
||||
public constructor(
|
||||
messageTitle: string,
|
||||
messageBody: string,
|
||||
reason?: string,
|
||||
) {
|
||||
super(messageTitle);
|
||||
this.messageBody = messageBody;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export const useLoadGroupCall = (
|
||||
@@ -53,36 +94,190 @@ export const useLoadGroupCall = (
|
||||
roomIdOrAlias: string,
|
||||
viaServers: string[],
|
||||
): GroupCallStatus => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
const activeRoom = useRef<Room>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bannedError = useCallback(
|
||||
(): CallTerminatedMessage =>
|
||||
new CallTerminatedMessage(
|
||||
t("group_call_loader.banned_heading"),
|
||||
t("group_call_loader.banned_body"),
|
||||
leaveReason(),
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const knockRejectError = useCallback(
|
||||
(): CallTerminatedMessage =>
|
||||
new CallTerminatedMessage(
|
||||
t("group_call_loader.knock_reject_heading"),
|
||||
t("group_call_loader.knock_reject_body"),
|
||||
leaveReason(),
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const removeNoticeError = useCallback(
|
||||
(): CallTerminatedMessage =>
|
||||
new CallTerminatedMessage(
|
||||
t("group_call_loader.call_ended_heading"),
|
||||
t("group_call_loader.call_ended_body"),
|
||||
leaveReason(),
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
const leaveReason = (): string =>
|
||||
activeRoom.current?.currentState
|
||||
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
|
||||
?.getContent().reason;
|
||||
|
||||
useEffect(() => {
|
||||
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
// though.
|
||||
// Also, we explicitly look up the room alias here. We previously just tried to
|
||||
// join anyway but the js-sdk recreates the room if you pass the alias for a
|
||||
// room you're already joined to (which it probably ought not to).
|
||||
let room: Room | null = null;
|
||||
const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase());
|
||||
logger.info(`${alias} resolved to ${lookupResult.room_id}`);
|
||||
room = client.getRoom(lookupResult.room_id);
|
||||
if (!room) {
|
||||
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
|
||||
room = await client.joinRoom(lookupResult.room_id, {
|
||||
viaServers: lookupResult.servers,
|
||||
});
|
||||
} else {
|
||||
logger.info(`Already in room ${lookupResult.room_id}, not rejoining.`);
|
||||
}
|
||||
return room;
|
||||
};
|
||||
|
||||
const getRoomByKnocking = async (
|
||||
roomId: string,
|
||||
viaServers: string[],
|
||||
onKnockSent: () => void,
|
||||
): Promise<Room> => {
|
||||
let joinedRoom: Room | null = null;
|
||||
await client.knockRoom(roomId, { viaServers });
|
||||
onKnockSent();
|
||||
const invitePromise = new Promise<void>((resolve, reject) => {
|
||||
client.on(
|
||||
RoomEvent.MyMembership,
|
||||
async (room, membership, prevMembership) => {
|
||||
if (roomId !== room.roomId) return;
|
||||
activeRoom.current = room;
|
||||
if (
|
||||
membership === KnownMembership.Invite &&
|
||||
prevMembership === KnownMembership.Knock
|
||||
) {
|
||||
await client.joinRoom(room.roomId, { viaServers });
|
||||
joinedRoom = room;
|
||||
logger.log("Auto-joined %s", room.roomId);
|
||||
resolve();
|
||||
}
|
||||
if (membership === KnownMembership.Ban) reject(bannedError());
|
||||
if (membership === KnownMembership.Leave)
|
||||
reject(knockRejectError());
|
||||
},
|
||||
);
|
||||
});
|
||||
await invitePromise;
|
||||
if (!joinedRoom) {
|
||||
throw new Error("Failed to join room after knocking.");
|
||||
}
|
||||
return joinedRoom;
|
||||
};
|
||||
|
||||
const fetchOrCreateRoom = async (): Promise<Room> => {
|
||||
let room: Room | null = null;
|
||||
if (roomIdOrAlias[0] === "#") {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
// though.
|
||||
// Also, we explicitly look up the room alias here. We previously just tried to
|
||||
// join anyway but the js-sdk recreates the room if you pass the alias for a
|
||||
// room you're already joined to (which it probably ought not to).
|
||||
const lookupResult = await client.getRoomIdForAlias(
|
||||
roomIdOrAlias.toLowerCase(),
|
||||
);
|
||||
logger.info(`${roomIdOrAlias} resolved to ${lookupResult.room_id}`);
|
||||
room = client.getRoom(lookupResult.room_id);
|
||||
if (!room) {
|
||||
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
|
||||
room = await client.joinRoom(lookupResult.room_id, {
|
||||
viaServers: lookupResult.servers,
|
||||
const alias = roomIdOrAlias;
|
||||
// The call uses a room alias
|
||||
room = await getRoomByAlias(alias);
|
||||
activeRoom.current = room;
|
||||
} else {
|
||||
// The call uses a room_id
|
||||
const roomId = roomIdOrAlias;
|
||||
|
||||
// first try if the room already exists
|
||||
// - in widget mode
|
||||
// - in SPA mode if the user already joined the room
|
||||
room = client.getRoom(roomId);
|
||||
activeRoom.current = room ?? undefined;
|
||||
const membership = room?.getMyMembership();
|
||||
if (membership === KnownMembership.Join) {
|
||||
// room already joined so we are done here already.
|
||||
return room!;
|
||||
}
|
||||
if (widget)
|
||||
// in widget mode we never should reach this point. (getRoom should return the room.)
|
||||
throw new Error(
|
||||
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
||||
);
|
||||
|
||||
if (membership === KnownMembership.Ban) {
|
||||
throw bannedError();
|
||||
} else if (membership === KnownMembership.Invite) {
|
||||
room = await client.joinRoom(roomId, {
|
||||
viaServers,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`Already in room ${lookupResult.room_id}, not rejoining.`,
|
||||
);
|
||||
// If the room does not exist we first search for it with viaServers
|
||||
let roomSummary: RoomSummary | undefined = undefined;
|
||||
try {
|
||||
roomSummary = await client.getRoomSummary(roomId, viaServers);
|
||||
} catch (error) {
|
||||
// If the room summary endpoint is not supported we let it be undefined and treat this case like
|
||||
// `JoinRule.Public`.
|
||||
// This is how the logic was done before: "we expect any room id passed to EC
|
||||
// to be for a public call" Which is definitely not ideal but worth a try if fetching
|
||||
// the summary crashes.
|
||||
logger.warn(
|
||||
`Could not load room summary to decide whether we want to join or knock.
|
||||
EC will fallback to join as if this would be a public room.
|
||||
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
roomSummary === undefined ||
|
||||
roomSummary.join_rule === JoinRule.Public
|
||||
) {
|
||||
room = await client.joinRoom(roomId, {
|
||||
viaServers,
|
||||
});
|
||||
} else if (roomSummary.join_rule === JoinRule.Knock) {
|
||||
// bind room summary in this scope so we have it stored in a binding of type `RoomSummary`
|
||||
// instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept
|
||||
// the type check from the if condition above.
|
||||
const _roomSummary = roomSummary;
|
||||
let knock: () => void = () => {};
|
||||
const userPressedAskToJoinPromise: Promise<void> = new Promise(
|
||||
(resolve) => {
|
||||
if (_roomSummary.membership !== KnownMembership.Knock) {
|
||||
knock = resolve;
|
||||
} else {
|
||||
// resolve immediately if the user already knocked
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
setState({ kind: "canKnock", roomSummary: _roomSummary, knock });
|
||||
await userPressedAskToJoinPromise;
|
||||
room = await getRoomByKnocking(
|
||||
roomSummary.room_id,
|
||||
viaServers,
|
||||
() =>
|
||||
setState({ kind: "waitForInvite", roomSummary: _roomSummary }),
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
room = await client.joinRoom(roomIdOrAlias, { viaServers });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -95,6 +290,7 @@ export const useLoadGroupCall = (
|
||||
|
||||
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
|
||||
const room = await fetchOrCreateRoom();
|
||||
activeRoom.current = room;
|
||||
logger.debug(`Fetched / joined room ${roomIdOrAlias}`);
|
||||
|
||||
const rtcSession = client.matrixRTC.getRoomSession(room);
|
||||
@@ -119,11 +315,33 @@ export const useLoadGroupCall = (
|
||||
}
|
||||
};
|
||||
|
||||
waitForClientSyncing()
|
||||
.then(fetchOrCreateGroupCall)
|
||||
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
|
||||
.catch((error) => setState({ kind: "failed", error }));
|
||||
}, [client, roomIdOrAlias, viaServers, t]);
|
||||
const observeMyMembership = async (): Promise<void> => {
|
||||
await new Promise((_, reject) => {
|
||||
client.on(RoomEvent.MyMembership, async (_, membership) => {
|
||||
if (membership === KnownMembership.Leave) reject(removeNoticeError());
|
||||
if (membership === KnownMembership.Ban) reject(bannedError());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (state.kind === "loading") {
|
||||
logger.log("Start loading group call");
|
||||
waitForClientSyncing()
|
||||
.then(fetchOrCreateGroupCall)
|
||||
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
|
||||
.then(observeMyMembership)
|
||||
.catch((error) => setState({ kind: "failed", error }));
|
||||
}
|
||||
}, [
|
||||
bannedError,
|
||||
client,
|
||||
knockRejectError,
|
||||
removeNoticeError,
|
||||
roomIdOrAlias,
|
||||
state,
|
||||
t,
|
||||
viaServers,
|
||||
]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export function usePageUnload(callback: () => void): void {
|
||||
// @ts-ignore
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
window.removeEventListener("pagehide", onBeforeUnload);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
|
||||
@@ -15,29 +15,90 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
LivekitFocus,
|
||||
LivekitFocusActive,
|
||||
isLivekitFocus,
|
||||
isLivekitFocusConfig,
|
||||
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { LivekitFocus } from "./livekit/LivekitFocus";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementWidgetActions, WidgetHelpers, widget } from "./widget";
|
||||
|
||||
function makeFocus(livekitAlias: string): LivekitFocus {
|
||||
const urlFromConf = Config.get().livekit!.livekit_service_url;
|
||||
if (!urlFromConf) {
|
||||
throw new Error("No livekit_service_url is configured!");
|
||||
}
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
|
||||
export function makeActiveFocus(): LivekitFocusActive {
|
||||
return {
|
||||
type: "livekit",
|
||||
livekit_service_url: urlFromConf,
|
||||
livekit_alias: livekitAlias,
|
||||
focus_selection: "oldest_membership",
|
||||
};
|
||||
}
|
||||
|
||||
export function enterRTCSession(
|
||||
async function makePreferredLivekitFoci(
|
||||
rtcSession: MatrixRTCSession,
|
||||
livekitAlias: string,
|
||||
): Promise<LivekitFocus[]> {
|
||||
logger.log("Start building foci_preferred list: ", rtcSession.room.roomId);
|
||||
|
||||
const preferredFoci: LivekitFocus[] = [];
|
||||
|
||||
// Make the Focus from the running rtc session the highest priority one
|
||||
// This minimizes how often we need to switch foci during a call.
|
||||
const focusInUse = rtcSession.getFocusInUse();
|
||||
if (focusInUse && isLivekitFocus(focusInUse)) {
|
||||
logger.log("Adding livekit focus from oldest member: ", focusInUse);
|
||||
preferredFoci.push(focusInUse);
|
||||
}
|
||||
|
||||
// Prioritize the client well known over the configured sfu.
|
||||
const wellKnownFoci =
|
||||
rtcSession.room.client.getClientWellKnown()?.[FOCI_WK_KEY];
|
||||
if (Array.isArray(wellKnownFoci)) {
|
||||
preferredFoci.push(
|
||||
...wellKnownFoci
|
||||
.filter((f) => !!f)
|
||||
.filter(isLivekitFocusConfig)
|
||||
.map((wellKnownFocus) => {
|
||||
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
|
||||
return { ...wellKnownFocus, livekit_alias: livekitAlias };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
||||
if (urlFromConf) {
|
||||
const focusFormConf: LivekitFocus = {
|
||||
type: "livekit",
|
||||
livekit_service_url: urlFromConf,
|
||||
livekit_alias: livekitAlias,
|
||||
};
|
||||
logger.log("Adding livekit focus from config: ", focusFormConf);
|
||||
preferredFoci.push(focusFormConf);
|
||||
}
|
||||
|
||||
if (preferredFoci.length === 0)
|
||||
throw new Error(
|
||||
`No livekit_service_url is configured so we could not create a focus.
|
||||
Currently we skip computing a focus based on other users in the room.`,
|
||||
);
|
||||
|
||||
return preferredFoci;
|
||||
|
||||
// TODO: we want to do something like this:
|
||||
//
|
||||
// const focusOtherMembers = await focusFromOtherMembers(
|
||||
// rtcSession,
|
||||
// livekitAlias,
|
||||
// );
|
||||
// if (focusOtherMembers) preferredFoci.push(focusOtherMembers);
|
||||
}
|
||||
|
||||
export async function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
encryptMedia: boolean,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||
|
||||
@@ -47,8 +108,18 @@ export function enterRTCSession(
|
||||
|
||||
// right now we assume everything is a room-scoped call
|
||||
const livekitAlias = rtcSession.room.roomId;
|
||||
|
||||
rtcSession.joinRoomSession([makeFocus(livekitAlias)], encryptMedia);
|
||||
const useDeviceSessionMemberEvents =
|
||||
Config.get().features?.feature_use_device_session_member_events;
|
||||
rtcSession.joinRoomSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
{
|
||||
manageMediaKeys: encryptMedia,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const widgetPostHangupProcedure = async (
|
||||
|
||||
@@ -47,18 +47,18 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
|
||||
useEffect(() => {
|
||||
const form = formRef.current!;
|
||||
// Auto-save when the user dismisses this component
|
||||
return () => {
|
||||
return (): void => {
|
||||
if (formChanged.current) {
|
||||
const data = new FormData(form);
|
||||
const displayNameDataEntry = data.get("displayName");
|
||||
const avatar = data.get("avatar");
|
||||
|
||||
const avatarSize =
|
||||
typeof avatar == "string" ? avatar.length : avatar?.size ?? 0;
|
||||
typeof avatar == "string" ? avatar.length : (avatar?.size ?? 0);
|
||||
const displayName =
|
||||
typeof displayNameDataEntry == "string"
|
||||
? displayNameDataEntry
|
||||
: displayNameDataEntry?.name ?? null;
|
||||
: (displayNameDataEntry?.name ?? null);
|
||||
|
||||
if (!displayName) {
|
||||
return;
|
||||
|
||||
@@ -298,13 +298,13 @@ export function useRageshakeRequest(): (
|
||||
|
||||
const sendRageshakeRequest = useCallback(
|
||||
(roomId: string, rageshakeRequestId: string) => {
|
||||
// @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
|
||||
client!.sendEvent(roomId, "org.matrix.rageshake_request", {
|
||||
request_id: rageshakeRequestId,
|
||||
});
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
return sendRageshakeRequest;
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export function useRageshakeRequestModal(
|
||||
|
||||
client.on(ClientEvent.Event, onEvent);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
client.removeListener(ClientEvent.Event, onEvent);
|
||||
};
|
||||
}, [setOpen, roomId, client]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -16,25 +16,41 @@ limitations under the License.
|
||||
|
||||
import {
|
||||
connectedParticipantsObserver,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { Room as LivekitRoom, RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
Room as LivekitRoom,
|
||||
LocalParticipant,
|
||||
ParticipantEvent,
|
||||
RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
Observable,
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
mergeAll,
|
||||
of,
|
||||
sample,
|
||||
scan,
|
||||
shareReplay,
|
||||
startWith,
|
||||
takeUntil,
|
||||
switchAll,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
timer,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
import { state } from "@react-rxjs/core";
|
||||
import { StateObservable, state } from "@react-rxjs/core";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -45,14 +61,21 @@ import {
|
||||
} from "../livekit/useECConnectionState";
|
||||
import { usePrevious } from "../usePrevious";
|
||||
import {
|
||||
TileViewModel,
|
||||
UserMediaTileViewModel,
|
||||
ScreenShareTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
MediaViewModel,
|
||||
UserMediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { finalizeValue } from "../observable-utils";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
// Represents something that should get a tile on the layout,
|
||||
// ie. a user's video feed or a screen share feed.
|
||||
// TODO: This exposes too much information to the view layer, let's keep this
|
||||
// information internal to the view model and switch to using Tile<T> instead
|
||||
export interface TileDescriptor<T> {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
@@ -65,9 +88,123 @@ export interface TileDescriptor<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
spotlight?: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLayout {
|
||||
type: "spotlight";
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface FullScreenLayout {
|
||||
type: "full screen";
|
||||
spotlight: MediaViewModel[];
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
type: "pip";
|
||||
spotlight: MediaViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout defining the media tiles present on screen and their visual
|
||||
* arrangement.
|
||||
*/
|
||||
export type Layout =
|
||||
| GridLayout
|
||||
| SpotlightLayout
|
||||
| FullScreenLayout
|
||||
| PipLayout;
|
||||
|
||||
export type GridMode = "grid" | "spotlight";
|
||||
|
||||
export type WindowMode = "normal" | "full screen" | "pip";
|
||||
|
||||
/**
|
||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||
*/
|
||||
enum SortingBin {
|
||||
SelfStart,
|
||||
Presenters,
|
||||
Speakers,
|
||||
VideoAndAudio,
|
||||
Video,
|
||||
Audio,
|
||||
NoMedia,
|
||||
SelfEnd,
|
||||
}
|
||||
|
||||
class UserMedia {
|
||||
private readonly scope = new ObservableScope();
|
||||
public readonly vm: UserMediaViewModel;
|
||||
public readonly speaker: Observable<boolean>;
|
||||
public readonly presenter: Observable<boolean>;
|
||||
|
||||
public constructor(
|
||||
public readonly id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
|
||||
|
||||
this.speaker = this.vm.speaking.pipeState(
|
||||
// Require 1 s of continuous speaking to become a speaker, and 10 s of
|
||||
// continuous silence to stop being considered a speaker
|
||||
audit((s) =>
|
||||
merge(
|
||||
timer(s ? 1000 : 10000),
|
||||
// If the speaking flag resets to its original value during this time,
|
||||
// end the silencing window to stick with that original value
|
||||
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
|
||||
),
|
||||
),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
this.scope.bind(),
|
||||
// Make this Observable hot so that the timers don't reset when you
|
||||
// resubscribe
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
this.presenter = observeParticipantEvents(
|
||||
participant,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(map((p) => p.isScreenShareEnabled));
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scope.end();
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenShare {
|
||||
public readonly vm: ScreenShareViewModel;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
function findMatrixMember(
|
||||
room: MatrixRoom,
|
||||
@@ -98,160 +235,346 @@ export class CallViewModel extends ViewModel {
|
||||
|
||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||
// they've left
|
||||
private readonly remoteParticipantHolds = zip(
|
||||
this.connectionState,
|
||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||
(s, ps) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||
// give their clients time to switch over and avoid jarring layout shifts
|
||||
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
||||
return concat(
|
||||
// Hold these participants
|
||||
of({ hold: ps }),
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
),
|
||||
new Promise<void>((resolve) => {
|
||||
const subscription = this.connectionState
|
||||
.pipe(takeUntil(this.destroyed))
|
||||
.subscribe((s) => {
|
||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Then unhold them
|
||||
]).then(() => Promise.resolve({ unhold: ps })),
|
||||
);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
// Aggregate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
scan(
|
||||
(holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
[] as RemoteParticipant[][],
|
||||
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||
zip(
|
||||
this.connectionState,
|
||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||
(s, ps) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||
// give their clients time to switch over and avoid jarring layout shifts
|
||||
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
||||
return concat(
|
||||
// Hold these participants
|
||||
of({ hold: ps }),
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
),
|
||||
new Promise<void>((resolve) => {
|
||||
const subscription = this.connectionState
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((s) => {
|
||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Then unhold them
|
||||
]).then(() => Promise.resolve({ unhold: ps })),
|
||||
);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
// Aggregate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
scan(
|
||||
(holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
[] as RemoteParticipant[][],
|
||||
),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||
combineLatest(
|
||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
private readonly mediaItems: StateObservable<MediaItem[]> = state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan(
|
||||
(
|
||||
prevItems,
|
||||
[remoteParticipants, { participant: localParticipant }],
|
||||
) => {
|
||||
let allGhosts = true;
|
||||
|
||||
const newItems = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||
const member = findMatrixMember(this.matrixRoom, p.identity);
|
||||
allGhosts &&= member === undefined;
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (p.identity !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaId = p.identity;
|
||||
yield [
|
||||
userMediaId,
|
||||
prevItems.get(userMediaId) ??
|
||||
new UserMedia(userMediaId, member, p, this.encrypted),
|
||||
];
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${userMediaId}:screen-share`;
|
||||
yield [
|
||||
screenShareId,
|
||||
prevItems.get(screenShareId) ??
|
||||
new ScreenShare(screenShareId, member, p, this.encrypted),
|
||||
];
|
||||
}
|
||||
}
|
||||
}.bind(this)(),
|
||||
);
|
||||
|
||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? new Map() : newItems;
|
||||
},
|
||||
new Map<string, MediaItem>(),
|
||||
),
|
||||
map((ms) => [...ms.values()]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.destroy();
|
||||
}),
|
||||
),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
private readonly remoteParticipants = combineLatest(
|
||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
||||
);
|
||||
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
private readonly screenShares: Observable<ScreenShare[]> =
|
||||
this.mediaItems.pipe(
|
||||
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
||||
);
|
||||
|
||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
||||
this.userMedia.pipe(
|
||||
switchMap((ms) =>
|
||||
ms.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
||||
),
|
||||
),
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
||||
(prev, ms) =>
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
ms.find(([m, s]) => m === prev && s)?.[0] ??
|
||||
// Otherwise, select anyone who is speaking
|
||||
ms.find(([, s]) => s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
prev ??
|
||||
// Otherwise, spotlight the local user
|
||||
ms.find(([m]) => m.vm.local)?.[0] ??
|
||||
null,
|
||||
null,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(800, undefined, { leading: true, trailing: true }),
|
||||
);
|
||||
|
||||
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
||||
switchMap((ms) => {
|
||||
const bins = ms.map((m) =>
|
||||
combineLatest(
|
||||
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
|
||||
(speaker, presenter, audio, video) => {
|
||||
let bin: SortingBin;
|
||||
if (m.vm.local) bin = SortingBin.SelfStart;
|
||||
else if (presenter) bin = SortingBin.Presenters;
|
||||
else if (speaker) bin = SortingBin.Speakers;
|
||||
else if (video)
|
||||
bin = audio ? SortingBin.VideoAndAudio : SortingBin.Video;
|
||||
else bin = audio ? SortingBin.Audio : SortingBin.NoMedia;
|
||||
|
||||
return [m, bin] as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
// Sort the media by bin order and generate a tile for each one
|
||||
return bins.length === 0
|
||||
? of([])
|
||||
: combineLatest(bins, (...bins) =>
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
||||
[this.screenShares, this.spotlightSpeaker],
|
||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
||||
screenShares.length > 0
|
||||
? screenShares.map((m) => m.vm)
|
||||
: spotlightSpeaker === null
|
||||
? []
|
||||
: [spotlightSpeaker.vm],
|
||||
);
|
||||
|
||||
// TODO: Make this react to changes in window dimensions and screen
|
||||
// orientation
|
||||
private readonly windowMode = of<WindowMode>("normal");
|
||||
|
||||
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
|
||||
/**
|
||||
* The layout mode of the media tile grid.
|
||||
*/
|
||||
public readonly gridMode = state(this._gridMode);
|
||||
|
||||
public setGridMode(value: GridMode): void {
|
||||
this._gridMode.next(value);
|
||||
}
|
||||
|
||||
public readonly layout: StateObservable<Layout> = state(
|
||||
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "full screen":
|
||||
throw new Error("unimplemented");
|
||||
case "pip":
|
||||
throw new Error("unimplemented");
|
||||
case "normal": {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight, this.screenShares],
|
||||
(grid, spotlight, screenShares): Layout => ({
|
||||
type: "grid",
|
||||
spotlight: screenShares.length > 0 ? spotlight : undefined,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
case "spotlight":
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight): Layout => ({
|
||||
type: "spotlight",
|
||||
spotlight,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
}).pipe(switchAll()),
|
||||
);
|
||||
|
||||
/**
|
||||
* The media tiles to be displayed in the call view.
|
||||
*/
|
||||
public readonly tiles = state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||
const ps = [localParticipant, ...remoteParticipants];
|
||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
let allGhosts = true;
|
||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
||||
// which keeps more details of the layout order internal to the view model
|
||||
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
|
||||
state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||
const ps = [localParticipant, ...remoteParticipants];
|
||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
let allGhosts = true;
|
||||
|
||||
const newTiles = ps.flatMap((p) => {
|
||||
const userMediaId = p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
allGhosts &&= member === undefined;
|
||||
const spokeRecently =
|
||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||
const newTiles = ps.flatMap((p) => {
|
||||
const userMediaId = p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
allGhosts &&= member === undefined;
|
||||
const spokeRecently =
|
||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (userMediaId !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaVm =
|
||||
tilesById.get(userMediaId)?.data ??
|
||||
new UserMediaTileViewModel(userMediaId, member, p, this.encrypted);
|
||||
tilesById.delete(userMediaId);
|
||||
|
||||
const userMediaTile: TileDescriptor<TileViewModel> = {
|
||||
id: userMediaId,
|
||||
focused: false,
|
||||
isPresenter: p.isScreenShareEnabled,
|
||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||
hasVideo: p.isCameraEnabled,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: false,
|
||||
data: userMediaVm,
|
||||
};
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${userMediaId}:screen-share`;
|
||||
const screenShareVm =
|
||||
tilesById.get(screenShareId)?.data ??
|
||||
new ScreenShareTileViewModel(
|
||||
screenShareId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (userMediaId !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||
);
|
||||
tilesById.delete(screenShareId);
|
||||
}
|
||||
|
||||
const screenShareTile: TileDescriptor<TileViewModel> = {
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
const userMediaVm =
|
||||
tilesById.get(userMediaId)?.data ??
|
||||
new UserMediaViewModel(userMediaId, member, p, this.encrypted);
|
||||
tilesById.delete(userMediaId);
|
||||
|
||||
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
||||
id: userMediaId,
|
||||
focused: false,
|
||||
isPresenter: p.isScreenShareEnabled,
|
||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||
hasVideo: p.isCameraEnabled,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: true,
|
||||
placeNear: userMediaId,
|
||||
data: screenShareVm,
|
||||
largeBaseSize: false,
|
||||
data: userMediaVm,
|
||||
};
|
||||
return [userMediaTile, screenShareTile];
|
||||
} else {
|
||||
return [userMediaTile];
|
||||
}
|
||||
});
|
||||
|
||||
// Any tiles left in the map are unused and should be destroyed
|
||||
for (const t of tilesById.values()) t.data.destroy();
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${userMediaId}:screen-share`;
|
||||
const screenShareVm =
|
||||
tilesById.get(screenShareId)?.data ??
|
||||
new ScreenShareViewModel(
|
||||
screenShareId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
);
|
||||
tilesById.delete(screenShareId);
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : newTiles;
|
||||
}, [] as TileDescriptor<TileViewModel>[]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.data.destroy();
|
||||
}),
|
||||
),
|
||||
);
|
||||
const screenShareTile: TileDescriptor<MediaViewModel> = {
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: true,
|
||||
placeNear: userMediaId,
|
||||
data: screenShareVm,
|
||||
};
|
||||
return [userMediaTile, screenShareTile];
|
||||
} else {
|
||||
return [userMediaTile];
|
||||
}
|
||||
});
|
||||
|
||||
// Any tiles left in the map are unused and should be destroyed
|
||||
for (const t of tilesById.values()) t.data.destroy();
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : newTiles;
|
||||
}, [] as TileDescriptor<MediaViewModel>[]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.data.destroy();
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -64,13 +63,13 @@ function observeTrackReference(
|
||||
);
|
||||
}
|
||||
|
||||
abstract class BaseTileViewModel extends ViewModel {
|
||||
abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* Whether the tile belongs to the local user.
|
||||
* Whether the media belongs to the local user.
|
||||
*/
|
||||
public readonly local = this.participant.isLocal;
|
||||
/**
|
||||
* The LiveKit video track to be shown on this tile.
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
|
||||
/**
|
||||
@@ -83,7 +82,7 @@ abstract class BaseTileViewModel extends ViewModel {
|
||||
// soon as that code is moved into the view models
|
||||
public readonly id: string,
|
||||
/**
|
||||
* The Matrix room member to which this tile belongs.
|
||||
* The Matrix room member to which this media belongs.
|
||||
*/
|
||||
// TODO: Fully separate the data layer from the UI layer by keeping the
|
||||
// member object internal
|
||||
@@ -109,14 +108,14 @@ abstract class BaseTileViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile displaying some media.
|
||||
* Some participant's media.
|
||||
*/
|
||||
export type TileViewModel = UserMediaTileViewModel | ScreenShareTileViewModel;
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* A tile displaying some participant's user media.
|
||||
* Some participant's user media.
|
||||
*/
|
||||
export class UserMediaTileViewModel extends BaseTileViewModel {
|
||||
export class UserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
@@ -201,7 +200,7 @@ export class UserMediaTileViewModel extends BaseTileViewModel {
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
muted ? 0 : volume,
|
||||
)
|
||||
.pipe(takeUntil(this.destroyed))
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((volume) => {
|
||||
(this.participant as RemoteParticipant).setVolume(volume);
|
||||
});
|
||||
@@ -221,9 +220,9 @@ export class UserMediaTileViewModel extends BaseTileViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile displaying some participant's screen share.
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
export class ScreenShareTileViewModel extends BaseTileViewModel {
|
||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
40
src/state/ObservableScope.ts
Normal file
40
src/state/ObservableScope.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs";
|
||||
|
||||
/**
|
||||
* A scope which limits the execution lifetime of its bound Observables.
|
||||
*/
|
||||
export class ObservableScope {
|
||||
private readonly ended = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Binds an Observable to this scope, so that it completes when the scope
|
||||
* ends.
|
||||
*/
|
||||
public bind<T>(): MonoTypeOperatorFunction<T> {
|
||||
return takeUntil(this.ended);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the scope, causing any bound Observables to complete.
|
||||
*/
|
||||
public end(): void {
|
||||
this.ended.next();
|
||||
this.ended.complete();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,20 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* An MVVM view model.
|
||||
*/
|
||||
export abstract class ViewModel {
|
||||
protected readonly destroyed = new Subject<void>();
|
||||
protected readonly scope = new ObservableScope();
|
||||
|
||||
/**
|
||||
* Instructs the ViewModel to clean up its resources. If you forget to call
|
||||
* this, there may be memory leaks!
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.destroyed.next();
|
||||
this.destroyed.complete();
|
||||
this.scope.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useEventTarget<T extends Event>(
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
target.addEventListener(eventType, listener as EventListener, options);
|
||||
return () =>
|
||||
return (): void =>
|
||||
target.removeEventListener(
|
||||
eventType,
|
||||
listener as EventListener,
|
||||
@@ -55,7 +55,7 @@ export function useTypedEventEmitter<
|
||||
): void {
|
||||
useEffect(() => {
|
||||
emitter.on(eventType, listener);
|
||||
return () => {
|
||||
return (): void => {
|
||||
emitter.off(eventType, listener);
|
||||
};
|
||||
}, [emitter, eventType, listener]);
|
||||
@@ -72,7 +72,7 @@ export function useEventEmitterThree<
|
||||
): void {
|
||||
useEffect(() => {
|
||||
emitter.on(eventType, listener);
|
||||
return () => {
|
||||
return (): void => {
|
||||
emitter.off(eventType, listener);
|
||||
};
|
||||
}, [emitter, eventType, listener]);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useLocalStorage = (
|
||||
|
||||
useEffect(() => {
|
||||
localStorageBus.on(key, setValue);
|
||||
return () => {
|
||||
return (): void => {
|
||||
localStorageBus.off(key, setValue);
|
||||
};
|
||||
}, [key, setValue]);
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useLocationNavigation(enabled = false): void {
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (unblock) {
|
||||
|
||||
@@ -26,19 +26,22 @@ export function useMatrixRTCSessionJoinState(
|
||||
): boolean {
|
||||
const [isJoined, setJoined] = useState(rtcSession.isJoined());
|
||||
|
||||
const onJoinStateChanged = useCallback(() => {
|
||||
logger.info(
|
||||
`Session in room ${rtcSession.room.roomId} changed to ${
|
||||
rtcSession.isJoined() ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
setJoined(rtcSession.isJoined());
|
||||
}, [rtcSession]);
|
||||
const onJoinStateChanged = useCallback(
|
||||
(isJoined: boolean) => {
|
||||
logger.info(
|
||||
`Session in room ${rtcSession.room.roomId} changed to ${
|
||||
isJoined ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
setJoined(isJoined);
|
||||
},
|
||||
[rtcSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.JoinStateChanged,
|
||||
onJoinStateChanged,
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useMatrixRTCSessionMemberships(
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
|
||||
@@ -27,8 +27,8 @@ export function useWakeLock(): void {
|
||||
let lock: WakeLockSentinel | null = null;
|
||||
|
||||
// The lock is automatically released whenever the window goes invisible,
|
||||
// so we need to reacquire it on visiblity changes
|
||||
const onVisiblityChange = async (): Promise<void> => {
|
||||
// so we need to reacquire it on visibility changes
|
||||
const onVisibilityChange = async (): Promise<void> => {
|
||||
if (document.visibilityState === "visible") {
|
||||
try {
|
||||
lock = await navigator.wakeLock.request("screen");
|
||||
@@ -44,16 +44,16 @@ export function useWakeLock(): void {
|
||||
}
|
||||
};
|
||||
|
||||
onVisiblityChange();
|
||||
document.addEventListener("visiblitychange", onVisiblityChange);
|
||||
onVisibilityChange();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
mounted = false;
|
||||
if (lock !== null)
|
||||
lock
|
||||
.release()
|
||||
.catch((e) => logger.warn("Can't release wake lock", e));
|
||||
document.removeEventListener("visiblitychange", onVisiblityChange);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
VideoGridProps as Props,
|
||||
TileSpring,
|
||||
ChildrenProperties,
|
||||
TileSpringUpdate,
|
||||
} from "./VideoGrid";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
@@ -110,7 +111,7 @@ export function NewVideoGrid<T>({
|
||||
});
|
||||
|
||||
observer.observe(slotsRoot, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
return (): void => observer.disconnect();
|
||||
}
|
||||
}, [slotsRoot, setRenderedGeneration]);
|
||||
|
||||
@@ -174,8 +175,8 @@ export function NewVideoGrid<T>({
|
||||
const [tileTransitions, springRef] = useTransition(
|
||||
tiles,
|
||||
() => ({
|
||||
key: ({ item }: Tile<T>) => item.id,
|
||||
from: ({ x, y, width, height }: Tile<T>) => ({
|
||||
key: ({ item }: Tile<T>): string => item.id,
|
||||
from: ({ x, y, width, height }: Tile<T>): TileSpringUpdate => ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
shadow: 0,
|
||||
@@ -188,7 +189,13 @@ export function NewVideoGrid<T>({
|
||||
immediate: disableAnimations,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
|
||||
update: ({ item, x, y, width, height }: Tile<T>) =>
|
||||
update: ({
|
||||
item,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}: Tile<T>): TileSpringUpdate | null =>
|
||||
item.id === dragState.current?.tileId
|
||||
? null
|
||||
: {
|
||||
@@ -230,7 +237,7 @@ export function NewVideoGrid<T>({
|
||||
disableAnimations || ((key): boolean => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key) => (key === "zIndex" ? 500 : 0),
|
||||
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
||||
}
|
||||
: {
|
||||
scale: 1.1,
|
||||
|
||||
@@ -77,6 +77,13 @@ export interface TileSpring {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TileSpringUpdate extends Partial<TileSpring> {
|
||||
from?: Partial<TileSpring>;
|
||||
reset?: boolean;
|
||||
immediate?: boolean | ((key: string) => boolean);
|
||||
delay?: (key: string) => number;
|
||||
}
|
||||
|
||||
type LayoutDirection = "vertical" | "horizontal";
|
||||
|
||||
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
|
||||
@@ -120,7 +127,7 @@ function useIsMounted(): MutableRefObject<boolean> {
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
@@ -1026,7 +1033,7 @@ export function VideoGrid<T>({
|
||||
const oneOnOneLayout =
|
||||
tiles.length === 2 && !tiles.some((t) => t.focused);
|
||||
|
||||
return (tileIndex: number) => {
|
||||
return (tileIndex: number): TileSpringUpdate => {
|
||||
const tile = tiles[tileIndex];
|
||||
const tilePosition = tilePositions[tile.order];
|
||||
const draggingTile = draggingTileRef.current;
|
||||
@@ -1045,7 +1052,7 @@ export function VideoGrid<T>({
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
shadowSpread: 0,
|
||||
immediate: (key: string) =>
|
||||
immediate: (key: string): boolean =>
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "x" ||
|
||||
@@ -1112,14 +1119,14 @@ export function VideoGrid<T>({
|
||||
shadowSpread: oneOnOneLayout && tile.item.local ? 1 : 0,
|
||||
from,
|
||||
reset,
|
||||
immediate: (key: string) =>
|
||||
immediate: (key: string): boolean =>
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "shadow" ||
|
||||
key === "shadowSpread",
|
||||
// If we just stopped dragging a tile, give it time for the
|
||||
// animation to settle before pushing its z-index back down
|
||||
delay: (key: string) => (key === "zIndex" ? 500 : 0),
|
||||
delay: (key: string): number => (key === "zIndex" ? 500 : 0),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,10 +58,10 @@ import { Avatar } from "../Avatar";
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import {
|
||||
ScreenShareTileViewModel,
|
||||
TileViewModel,
|
||||
UserMediaTileViewModel,
|
||||
} from "../state/TileViewModel";
|
||||
ScreenShareViewModel,
|
||||
MediaViewModel,
|
||||
UserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { Slider } from "../Slider";
|
||||
@@ -170,7 +170,7 @@ const Tile = forwardRef<HTMLDivElement, TileProps>(
|
||||
Tile.displayName = "Tile";
|
||||
|
||||
interface UserMediaTileProps {
|
||||
vm: UserMediaTileViewModel;
|
||||
vm: UserMediaViewModel;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
@@ -329,7 +329,7 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
|
||||
UserMediaTile.displayName = "UserMediaTile";
|
||||
|
||||
interface ScreenShareTileProps {
|
||||
vm: ScreenShareTileViewModel;
|
||||
vm: ScreenShareViewModel;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
@@ -403,7 +403,7 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
|
||||
ScreenShareTile.displayName = "ScreenShareTile";
|
||||
|
||||
interface Props {
|
||||
vm: TileViewModel;
|
||||
vm: MediaViewModel;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
@@ -455,7 +455,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
? t("video_tile.sfu_participant_local")
|
||||
: displayName;
|
||||
|
||||
if (vm instanceof UserMediaTileViewModel) {
|
||||
if (vm instanceof UserMediaViewModel) {
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
|
||||
@@ -77,8 +77,6 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
logger.info("Widget API is available");
|
||||
const api = new WidgetApi(widgetId, parentOrigin);
|
||||
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
|
||||
api.requestCapabilityToSendEvent(EventType.CallEncryptionKeysPrefix);
|
||||
api.requestCapabilityToReceiveEvent(EventType.CallEncryptionKeysPrefix);
|
||||
|
||||
// Set up the lazy action emitter, but only for select actions that we
|
||||
// intend for the app to handle
|
||||
@@ -116,13 +114,23 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
if (!baseUrl) throw new Error("Base URL must be supplied");
|
||||
|
||||
// These are all the event types the app uses
|
||||
const sendRecvEvent = ["org.matrix.rageshake_request"];
|
||||
const sendState = [
|
||||
{ eventType: EventType.GroupCallMemberPrefix, stateKey: userId },
|
||||
const sendRecvEvent = [
|
||||
"org.matrix.rageshake_request",
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
];
|
||||
|
||||
const sendState = [
|
||||
userId, // legacy call membership events
|
||||
`_${userId}_${deviceId}`, // session membership events
|
||||
`${userId}_${deviceId}`, // MSC3779 session membership events
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
}));
|
||||
const receiveState = [
|
||||
{ eventType: EventType.RoomCreate },
|
||||
{ eventType: EventType.RoomMember },
|
||||
{ eventType: EventType.GroupCallPrefix },
|
||||
{ eventType: EventType.RoomEncryption },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
];
|
||||
const sendRecvToDevice = [
|
||||
@@ -167,7 +175,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
// wait for the config file to be ready (we load very early on so it might not
|
||||
// be otherwise)
|
||||
await Config.init();
|
||||
await client.startClient();
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
resolve(client);
|
||||
})();
|
||||
});
|
||||
|
||||
93
test/rtcSessionHelper-test.ts
Normal file
93
test/rtcSessionHelper-test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
|
||||
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
||||
import { Config } from "../src/config/Config";
|
||||
|
||||
test("It joins the correct Session", async () => {
|
||||
const focusFromOlderMembership = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://my-oldest-member-service-url.com",
|
||||
livekit_alias: "my-oldest-member-service-alias",
|
||||
};
|
||||
|
||||
const focusConfigFromWellKnown = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
};
|
||||
const focusConfigFromWellKnown2 = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://my-well-known-service-url2.com",
|
||||
};
|
||||
const clientWellKnown = {
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
focusConfigFromWellKnown,
|
||||
focusConfigFromWellKnown2,
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(Config, "get").mockReturnValue({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
eula: "",
|
||||
});
|
||||
const mockedSession = vi.mocked({
|
||||
room: {
|
||||
roomId: "roomId",
|
||||
client: {
|
||||
getClientWellKnown: vi.fn().mockReturnValue(clientWellKnown),
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
|
||||
getOldestMembership: vi.fn().mockReturnValue({
|
||||
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
|
||||
}),
|
||||
joinRoomSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
await enterRTCSession(mockedSession, false);
|
||||
|
||||
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
|
||||
[
|
||||
{
|
||||
livekit_alias: "my-oldest-member-service-alias",
|
||||
livekit_service_url: "http://my-oldest-member-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url2.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-default-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
{
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
{ manageMediaKeys: false },
|
||||
);
|
||||
});
|
||||
@@ -22,6 +22,9 @@
|
||||
"livekit-client/dist/src/room/Room": [
|
||||
"./node_modules/livekit-client/dist/src/room/Room.d.ts"
|
||||
],
|
||||
"livekit-client/dist/src/room/track/TrackPublication": [
|
||||
"./node_modules/livekit-client/dist/src/room/track/TrackPublication.d.ts"
|
||||
],
|
||||
"livekit-client/dist/src/room/participant/Participant": [
|
||||
"./node_modules/livekit-client/dist/src/room/participant/Participant.d.ts"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user