mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88d8d5830 | ||
|
|
6c66f9a3e7 | ||
|
|
99f48ff987 | ||
|
|
6846c8dd3e | ||
|
|
28ee707ace | ||
|
|
3100c1c410 | ||
|
|
fc1e91f54c | ||
|
|
96949a57da | ||
|
|
2e05ef77a0 | ||
|
|
73826ec4f9 | ||
|
|
1135c02e6f | ||
|
|
9dc425b5f8 | ||
|
|
c1b94b62dd | ||
|
|
fa6efd9dad | ||
|
|
6906404a64 | ||
|
|
98e8343d0b | ||
|
|
88659d6498 | ||
|
|
cfd8312e46 |
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build & publish docker
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Element Call
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Deploy to call.element.dev
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
deployments: write
|
||||
environment: Netlify
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions: {}
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
publish_npm:
|
||||
needs: versioning
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: [versioning, publish_npm]
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
|
||||
# appends at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish Android AAR
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish SwiftPM Library
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
if: always()
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Shared with publish-component.yaml, which appends to the same release: two appends
|
||||
# at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
needs: build_element_call
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
add_docker_release_note:
|
||||
needs: publish_docker
|
||||
name: Add docker release note
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
name: Run end-to-end tests (${{ matrix.part }})
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# We manually shard the tests across runners. Playwright's own shards cut
|
||||
# the list of tests into equal counts in file order, which would leave the
|
||||
# widget suite, the slowest tests by far, together at the end.
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
name: Run end-to-end tests
|
||||
needs: playwright
|
||||
if: ${{ always() && !inputs.disable_e2e_tests }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
download:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions: {}
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,10 +19,10 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
volumes:
|
||||
# The other site opens the embedded package under test in place of the
|
||||
# copy its image bundles (the first site opens the dev server's app.
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
Generated
+21
-14
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
specifier: github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -1195,8 +1195,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2':
|
||||
resolution: {integrity: sha512-GHeuSeb5Fj6Wg2+NE/WoLJJs9U3nOzttO+2sj7lOaS5gNBpGIksc48N/Q/kK/Z+Lxh7YMhopB7M8b6HyASXnGQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -4734,9 +4734,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
version: 45.0.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
|
||||
resolution: {gitHosted: true, integrity: sha512-7cbPN96wGt93B73jZieW2HBtTnasgKJ7wzTgiV0SjwkNyb50EK3vDa17LdSJpyOwEjFP5edyPRprk865HQJqww==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9}
|
||||
version: 45.1.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.20.0:
|
||||
@@ -4946,8 +4946,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5318,6 +5318,10 @@ packages:
|
||||
randomfill@1.0.4:
|
||||
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
|
||||
|
||||
re2js@2.9.1:
|
||||
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
react-docgen-typescript@2.4.0:
|
||||
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
|
||||
peerDependencies:
|
||||
@@ -7174,7 +7178,7 @@ snapshots:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.23.0(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.3.1)':
|
||||
dependencies:
|
||||
@@ -10409,17 +10413,18 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.2
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
content-type: 3.1.1
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 8.0.1
|
||||
p-retry: 9.0.0
|
||||
re2js: 2.9.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10741,7 +10746,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.1:
|
||||
p-retry@9.0.0:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -11165,6 +11170,8 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
re2js@2.9.1: {}
|
||||
|
||||
react-docgen-typescript@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -162,6 +162,7 @@ describe("createLocalMedia", () => {
|
||||
media.release("camera");
|
||||
expect(trackBehind(camera).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([microphone]);
|
||||
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
|
||||
|
||||
media.stop();
|
||||
expect(trackBehind(microphone).stop).toHaveBeenCalled();
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createLocalMedia(
|
||||
if (source !== "microphone" && source !== "camera") return;
|
||||
capturedTracks.get(source)?.stop();
|
||||
capturedTracks.delete(source);
|
||||
requests.delete(source);
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ function setup(
|
||||
getDeviceId: () => deviceId,
|
||||
getDomain: () => "example.org",
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
|
||||
getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: publisherJWT }),
|
||||
} as unknown as MatrixClient;
|
||||
const members$ = new BehaviorSubject<RTCMember[]>([]);
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createRTCParticipation(
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
callIntent: options.callIntent,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
|
||||
+8
-4
@@ -12,6 +12,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -103,11 +104,14 @@ export interface RTCParticipationOptions {
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* The kind of call the local member intends to take part in: `"m.audio"`
|
||||
* or `"m.video"`. Carried by the membership as a field of its own.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||
* field of its own.
|
||||
* under namespaced keys. Opaque to the SDK.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Timings the host has configured; the defaults otherwise. */
|
||||
@@ -287,7 +291,7 @@ export interface RTCMember {
|
||||
memberId: string;
|
||||
/** Which transport this member is on; undefined when the membership has none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||
/** What the member's application says about itself, e.g. `intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
|
||||
@@ -31,7 +31,7 @@ function client(
|
||||
): Parameters<typeof discoverLocalTransport>[0] {
|
||||
return {
|
||||
getDomain: () => "example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("discoverLocalTransport", () => {
|
||||
logger,
|
||||
),
|
||||
).resolves.toBe(hostTransport);
|
||||
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
|
||||
expect(c.getRTCTransports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the homeserver's transport over the host's fallback", async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
* @throws {MatrixRTCTransportMissingError} where none of these exists.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
client: Pick<MatrixClient, "getDomain" | "getRTCTransports">,
|
||||
transportTypes: readonly string[],
|
||||
{
|
||||
transport: override,
|
||||
|
||||
@@ -36,11 +36,7 @@ const configTransport: Transport = {
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
return {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"prefers backend transport other app config $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -71,7 +67,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -81,10 +77,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
backendTransport,
|
||||
mLivekit,
|
||||
]);
|
||||
client.getRTCTransports.mockResolvedValue([backendTransport, mLivekit]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -100,7 +93,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
client.getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
new MatrixError(
|
||||
{
|
||||
@@ -124,7 +117,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
backendTransport,
|
||||
);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
@@ -135,7 +128,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -152,7 +145,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("returns null when backend and config are all unavailable", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
client.getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
|
||||
@@ -10,10 +10,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
type TransportDiscoveryClient = Pick<MatrixClient, "getRTCTransports">;
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
@@ -74,7 +71,7 @@ export class TransportAutoDiscovery {
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
try {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
client.getRTCTransports(),
|
||||
);
|
||||
for (const type of this.transportTypes) {
|
||||
const first = transportList.find(
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: {},
|
||||
applicationData: undefined,
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
@@ -95,13 +95,14 @@ describe("joinJsSdkSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -110,7 +111,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transportWithAlias],
|
||||
expect.objectContaining({
|
||||
callIntent: "audio",
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
@@ -124,7 +125,8 @@ describe("joinJsSdkSession", () => {
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -133,7 +135,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "audio",
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
callIntent?: RTCCallIntent;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
@@ -43,15 +44,12 @@ export function joinJsSdkSession(
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}: Options,
|
||||
): void {
|
||||
// The membership format carries the intent as a field of its own
|
||||
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||
const callIntent =
|
||||
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||
// Give up on the network as soon as either the sync has been down for the
|
||||
// grace period or the delayed leave has probably been sent, whichever is
|
||||
// sooner
|
||||
@@ -78,7 +76,7 @@ export function joinJsSdkSession(
|
||||
{
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData: rest,
|
||||
applicationData,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
|
||||
+12
-2
@@ -30,7 +30,7 @@ import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { type ClientLoader, ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -57,6 +57,16 @@ import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
// Element Call's own session, for when no host supplies a client.
|
||||
// - Passed in here, not imported by ClientProvider: this module is only in the
|
||||
// app builds, so the client setup and the store worker behind it stay out of
|
||||
// the component package (see the `loadClient` prop).
|
||||
// - Imported on demand, as it was before, so that a widget, which gets its
|
||||
// client from the host, never downloads it.
|
||||
// - Module-level, so that the provider's effect sees one stable loader.
|
||||
const loadStandaloneClient: ClientLoader = async (...args) =>
|
||||
(await import("./utils/spa")).initSPA(...args);
|
||||
|
||||
interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
@@ -146,7 +156,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
|
||||
@@ -11,7 +11,11 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
import {
|
||||
type ClientLoader,
|
||||
ClientProvider,
|
||||
useClientState,
|
||||
} from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -95,13 +99,13 @@ test("follows the client when the host swaps it", () => {
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
const loadClient = vi
|
||||
.fn<ClientLoader>()
|
||||
.mockResolvedValue({ client, passwordlessUser: true });
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ClientProvider loadClient={loadClient}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -110,6 +114,21 @@ test("finds a client of its own when the host supplies none", async () => {
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
// Given the session store to restore from and to clear on a stale session
|
||||
expect(loadClient).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
test("has no one signed in when given neither a client nor a way to load one", async () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
|
||||
});
|
||||
|
||||
+32
-12
@@ -143,9 +143,34 @@ interface Props {
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
/**
|
||||
* How Element Call finds a client when none is supplied: the standalone app
|
||||
* restores or creates a session of its own. Stable, as a hook dependency.
|
||||
* Left out along with the client, no one is signed in.
|
||||
*
|
||||
* A prop, rather than this module importing `utils/spa` itself, because:
|
||||
* - This module is in the component build, and so is everything it imports,
|
||||
* dynamically or not: a dynamic import becomes a chunk in the package,
|
||||
* not a module left out of it.
|
||||
* - Behind `utils/spa` sits `utils/initClient` and the client store worker,
|
||||
* which the bundler emits as soon as it loads the import (see there).
|
||||
* - `App.tsx`, which the component never imports, is where the standalone
|
||||
* app passes the loader in; the component passes nothing, having the
|
||||
* host's client.
|
||||
*/
|
||||
loadClient?: ClientLoader;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own, kept where the given
|
||||
* functions keep it. Resolves to null when no one is signed in.
|
||||
*/
|
||||
export type ClientLoader = (
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
) => Promise<InitResult | null>;
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
@@ -179,9 +204,13 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
if (loadClient === undefined) {
|
||||
setInitClientState(null);
|
||||
return;
|
||||
}
|
||||
initializing.current = true;
|
||||
|
||||
loadClient()
|
||||
loadClient(loadSession, clearSession)
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
@@ -189,7 +218,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
}, [client, loadClient]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -360,15 +389,6 @@ export type InitResult = {
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
|
||||
+10
-8
@@ -18,6 +18,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -179,40 +181,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
|
||||
+10
-6
@@ -21,6 +21,10 @@ import {
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
@@ -275,7 +279,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: "video",
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +326,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,7 +337,7 @@ describe("UrlParams", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,7 +352,7 @@ describe("UrlParams", () => {
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -363,7 +367,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "video",
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,7 +381,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
@@ -822,9 +822,7 @@ export function rtcParticipationOptions(
|
||||
const session = config.matrix_rtc_session;
|
||||
return {
|
||||
sendNotificationType: urlParams.sendNotificationType,
|
||||
applicationData: urlParams.callIntent
|
||||
? { "m.call.intent": urlParams.callIntent }
|
||||
: undefined,
|
||||
callIntent: urlParams.callIntent,
|
||||
timings: {
|
||||
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
||||
networkErrorRetryMs: session.network_error_retry_ms,
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { expect, type MockedFunction, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
|
||||
@@ -39,7 +40,7 @@ test("ringtone plays on loop while ringing", () => {
|
||||
userId: alice.userId,
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
pickupState$,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { it, vi, expect, beforeEach, afterEach, describe } from "vitest";
|
||||
import { firstValueFrom, of, Subject, take, toArray } from "rxjs";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||
import type { Controls, OutputDevice } from "../controls";
|
||||
@@ -117,7 +121,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"video",
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -142,7 +146,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -163,7 +167,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BT_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -190,7 +194,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(WIRED_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -300,7 +304,7 @@ describe("Test select a device", () => {
|
||||
b: BT_HEADSET_BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -340,7 +344,7 @@ describe("Test select a device", () => {
|
||||
c: BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -393,7 +397,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When a BT headset is added, control should switch to use it", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -419,7 +423,9 @@ describe("Available device changes", () => {
|
||||
|
||||
// Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.'
|
||||
it.skip("When a wired headset is added, control should switch to use it", async () => {
|
||||
const controlledAudioOutput = createAudioControlledOutput("video");
|
||||
const controlledAudioOutput = createAudioControlledOutput(
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
);
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -446,7 +452,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
|
||||
createAudioControlledOutput("audio");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_AUDIO);
|
||||
|
||||
// Emit the BT headset device list, the BT headset should be selected
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
@@ -472,7 +478,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -497,7 +503,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("Do not repeatidly set the same device", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "./MediaDevices.ts";
|
||||
import type { ObservableScope } from "./ObservableScope.ts";
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Controls, type OutputDevice } from "../controls.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
@@ -102,7 +105,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
* @constructor
|
||||
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
|
||||
* @param scope - The ObservableScope to create the Behaviors in.
|
||||
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param initialIntent - The initial call intent (e.g. "m.audio" or "m.video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
|
||||
*/
|
||||
public constructor(
|
||||
@@ -316,7 +319,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
this.logger.debug(
|
||||
`Android routing logic intent: ${this.initialIntent} finding best default...`,
|
||||
);
|
||||
if (this.initialIntent === "audio") {
|
||||
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
|
||||
const systemProposed = available[0];
|
||||
// If no headset is connected, android will route to the speaker by default,
|
||||
// but for a voice call we want to route to the earpiece instead,
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
@@ -33,10 +37,12 @@ function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
legacyIntent?: string,
|
||||
): CallNotificationWrapper {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
...(legacyIntent === undefined ? {} : { "m.call.intent": legacyIntent }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as CallNotificationWrapper;
|
||||
@@ -128,7 +134,25 @@ test("ring attempt times out after nobody joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt maps the legacy intent on the notification to its stable value", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberUserIds$: constant<string[]>([]),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30, local.userId, "video"),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_VIDEO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -148,7 +172,7 @@ test("ring attempt is accepted once recipient joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -166,7 +190,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -201,7 +225,7 @@ test("ring attempt can be declined", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "decline" },
|
||||
});
|
||||
});
|
||||
@@ -236,7 +260,7 @@ test("ring attempt times out if recipient declines too late", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -271,7 +295,7 @@ test("decline event relating to wrong event is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -306,7 +330,7 @@ test("decline event from wrong sender is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
toStableCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
filter,
|
||||
@@ -155,7 +157,10 @@ export function createCallNotificationLifecycle$({
|
||||
);
|
||||
|
||||
return of({
|
||||
intent: notificationEvent["m.call.intent"] ?? "audio",
|
||||
// The notification still carries the legacy intent values
|
||||
intent:
|
||||
toStableCallIntent(notificationEvent["m.call.intent"]) ??
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
recipient,
|
||||
outcome$: race(timeout$, accept$, decline$).pipe(
|
||||
take(1),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
@@ -1559,7 +1560,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
@@ -1605,7 +1606,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
EARPIECE_CONFIG_ID,
|
||||
IOSControlledAudioOutput,
|
||||
} from "./IOSControlledAudioOutput";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
// `vi.mock` calls are hoisted above all imports, so the static imports below
|
||||
// already see these mocks. Force the iOS platform so that the virtual earpiece
|
||||
@@ -81,7 +85,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -100,7 +104,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"video",
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -113,7 +117,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -130,7 +134,7 @@ describe("Explicit selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -150,7 +154,7 @@ describe("Explicit selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
||||
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||
outputDevice$ as controlledOutputSelection$,
|
||||
@@ -111,7 +114,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
* Chooses the default output device when no explicit selection (from the user
|
||||
* or the hosting application) has been made yet.
|
||||
*
|
||||
* For voice calls (`initialIntent === "audio"`) we want to start on the
|
||||
* For voice calls (`initialIntent === "m.audio"`) we want to start on the
|
||||
* earpiece rather than the speaker, like a regular phone call. We only
|
||||
* override when the device that would otherwise be the default is the
|
||||
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
|
||||
@@ -122,7 +125,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
available: Map<string, AudioOutputDeviceLabel>,
|
||||
): string | undefined {
|
||||
const firstId = available.keys().next().value;
|
||||
if (this.initialIntent === "audio") {
|
||||
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
|
||||
const firstLabel =
|
||||
firstId !== undefined ? available.get(firstId) : undefined;
|
||||
if (firstLabel?.type === "speaker") {
|
||||
|
||||
@@ -35,6 +35,10 @@ import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
// Which audio output implementation is used is decided by what the app hosting
|
||||
// Element Call told it, rather than being discovered from the environment.
|
||||
@@ -52,7 +56,7 @@ describe("MediaDevices audio output", () => {
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
|
||||
@@ -63,7 +67,7 @@ describe("MediaDevices audio output", () => {
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "video",
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
||||
|
||||
@@ -6,7 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { calculateInitialMuteState } from "./initialMuteState";
|
||||
|
||||
@@ -14,10 +18,10 @@ test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
allowJoinUnmutedViaIntent: boolean;
|
||||
}>([
|
||||
{ callIntent: "audio", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "audio", allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: "video", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "video", allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: "unknown", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "unknown", allowJoinUnmutedViaIntent: true },
|
||||
])(
|
||||
@@ -29,15 +33,15 @@ test.each<{
|
||||
allowJoinUnmutedViaIntent,
|
||||
);
|
||||
expect(audioEnabled).toBe(true);
|
||||
expect(videoEnabled).toBe(callIntent !== "audio");
|
||||
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
|
||||
},
|
||||
);
|
||||
|
||||
test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
}>([
|
||||
{ callIntent: "audio" },
|
||||
{ callIntent: "video" },
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO },
|
||||
{ callIntent: "unknown" },
|
||||
])(
|
||||
"Should always mute on start if skipping lobby and the host does not vouch for the intent (callIntent: $callIntent)",
|
||||
@@ -55,8 +59,8 @@ test.each<{
|
||||
test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
}>([
|
||||
{ callIntent: "audio" },
|
||||
{ callIntent: "video" },
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO },
|
||||
{ callIntent: "unknown" },
|
||||
])(
|
||||
"Can start unmuted if skipping lobby and the host vouches for the intent (callIntent: $callIntent)",
|
||||
@@ -67,6 +71,6 @@ test.each<{
|
||||
true,
|
||||
);
|
||||
expect(audioEnabled).toBe(true);
|
||||
expect(videoEnabled).toBe(callIntent !== "audio");
|
||||
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,10 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* Calculates the initial mute state for media devices based on configuration.
|
||||
@@ -40,10 +43,10 @@ export function calculateInitialMuteState(
|
||||
|
||||
// A host that vouches for the intent is a trusted environment, so it allows
|
||||
// unmuted by default. Same for when showing a lobby, as users can adjust
|
||||
// their settings there. Additionally, if the call intent is "audio", we
|
||||
// their settings there. Additionally, if the call intent is "m.audio", we
|
||||
// disable video by default.
|
||||
return {
|
||||
audioEnabled: true,
|
||||
videoEnabled: callIntent != "audio",
|
||||
videoEnabled: callIntent != RTC_CALL_INTENT_AUDIO,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
|
||||
import { test, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
@@ -122,7 +125,7 @@ test("GridTile displays ringing media", async () => {
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
id: "test",
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EndCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RTC_CALL_INTENT_VIDEO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -25,7 +26,7 @@ export const RingingStatus: FC<Props> = ({ vm }) => {
|
||||
const pickupState = useBehavior(vm.pickupState$);
|
||||
const Icon =
|
||||
pickupState === "ringing"
|
||||
? vm.intent === "video"
|
||||
? vm.intent === RTC_CALL_INTENT_VIDEO
|
||||
? VideoCallSolidIcon
|
||||
: VoiceCallSolidIcon
|
||||
: EndCallIcon;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
@@ -176,7 +177,7 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
id: "test",
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
const frame = {} as VideoFrame;
|
||||
const controller = {} as TransformStreamDefaultController<VideoFrame>;
|
||||
const stats = { processingTimeMs: 1, segmentationTimeMs: 1, filterTimeMs: 0 };
|
||||
|
||||
describe("BackgroundEffectTransformer", () => {
|
||||
afterEach(() => {
|
||||
@@ -66,9 +67,10 @@ describe("BackgroundEffectTransformer", () => {
|
||||
});
|
||||
|
||||
it("reports the first frame carrying an effect, once", async () => {
|
||||
// The library reports every frame it draws.
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
|
||||
async function (this: BackgroundTransformer) {
|
||||
this.isFirstFrame = false;
|
||||
this.options.onFrameProcessed?.(stats);
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
@@ -91,6 +93,37 @@ describe("BackgroundEffectTransformer", () => {
|
||||
expect(transformer.onFirstFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps reporting drawn frames to whoever asked", async () => {
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
|
||||
async function (this: BackgroundTransformer) {
|
||||
this.options.onFrameProcessed?.(stats);
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
const onFrameProcessed = vi.fn();
|
||||
const transformer = new BackgroundEffectTransformer({ onFrameProcessed });
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
expect(onFrameProcessed).toHaveBeenCalledWith(stats);
|
||||
});
|
||||
|
||||
it("never sends the first frame on untouched", async () => {
|
||||
// The library copies its first frame through untouched, ahead of the
|
||||
// drawn one, unless told the first frame is past
|
||||
const seen: boolean[] = [];
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
|
||||
async function (this: BackgroundTransformer) {
|
||||
seen.push(this.isFirstFrame);
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
expect(transformer.isFirstFrame).toBe(true);
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
expect(seen).toEqual([false]);
|
||||
});
|
||||
|
||||
it("passes frames on untouched once disabled, even after blurring", async () => {
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
|
||||
|
||||
@@ -41,18 +41,41 @@ const wasmFileset: WasmFileset = { wasmLoaderPath, wasmBinaryPath };
|
||||
* resource fetched from the public internet.
|
||||
*/
|
||||
export class BackgroundEffectTransformer extends BackgroundTransformer {
|
||||
/** Called once, when the first frame carrying an effect has been drawn. */
|
||||
/**
|
||||
* Called once, when the first frame carrying an effect has been drawn.
|
||||
* This is used for drewAFrame$ to show the correct loading state.
|
||||
*/
|
||||
public onFirstFrame: (() => void) | undefined;
|
||||
private drewAFrame = false;
|
||||
|
||||
public constructor(opts: BackgroundOptions) {
|
||||
super(opts);
|
||||
// Called by the library for a frame it drew, never for one it passed on
|
||||
// untouched.
|
||||
this.options.onFrameProcessed = (stats): void => {
|
||||
opts.onFrameProcessed?.(stats);
|
||||
if (this.drewAFrame) return;
|
||||
this.drewAFrame = true;
|
||||
this.onFirstFrame?.();
|
||||
};
|
||||
}
|
||||
|
||||
public override async transform(
|
||||
frame: VideoFrame,
|
||||
controller: TransformStreamDefaultController<VideoFrame>,
|
||||
): Promise<void> {
|
||||
const priming = this.isFirstFrame;
|
||||
// From the LK docs:
|
||||
// > Render a copy of the first frame is rendered to the screen as soon as possible to act
|
||||
// > as a less jarring initial state than a solid color while the synchronous work below
|
||||
// > (segmentation + frame rendering) occurs.
|
||||
// We do not want this copy (it leaks unblurred media).
|
||||
// So we always set `isFirstFrame` to false -> never show an unprocessed frame.
|
||||
// It does not have any impact other than setting the unprocessed frame while loading the processor.
|
||||
//
|
||||
// To not show the blank frame, we have our own mechanism for hiding the loading overlay:
|
||||
// `onFirstFrame` is what the EC UI will listen to and only then show the video feed.
|
||||
this.isFirstFrame = false;
|
||||
await super.transform(frame, controller);
|
||||
// Cleared only once a frame has been through the segmenter; a frame
|
||||
// passed through untouched leaves it set.
|
||||
if (priming && !this.isFirstFrame) this.onFirstFrame?.();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
import type * as MatrixSdk from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { flushPromises } from "./test";
|
||||
|
||||
interface Store {
|
||||
startup: Mock<() => Promise<void>>;
|
||||
getSavedSyncToken: Mock<() => Promise<string | null>>;
|
||||
}
|
||||
|
||||
const store = (): Store => ({
|
||||
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
getSavedSyncToken: vi
|
||||
.fn<() => Promise<string | null>>()
|
||||
.mockResolvedValue(null),
|
||||
});
|
||||
|
||||
// The client is the SDK's business; what these tests are about is how it is
|
||||
// set up: its stores, its crypto, and when it counts as ready.
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixSdk>()),
|
||||
createClient: vi.fn(),
|
||||
IndexedDBStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
MemoryStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
}));
|
||||
|
||||
interface FakeClient extends EventEmitter {
|
||||
store: Store;
|
||||
clearStores: Mock<() => Promise<void>>;
|
||||
initRustCrypto: Mock<() => Promise<void>>;
|
||||
startClient: Mock<() => Promise<void>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client that, unless told otherwise, reports its first sync a tick after
|
||||
* being started, as the real one does once the network answers.
|
||||
*/
|
||||
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
store: opts.store as unknown as Store,
|
||||
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
startClient: vi.fn(async () => {
|
||||
if (!syncs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
}),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
|
||||
const clients = vi.mocked(createClient) as unknown as Mock<
|
||||
(opts: ICreateClientOpts) => FakeClient
|
||||
>;
|
||||
const indexedDBStores = vi.mocked(IndexedDBStore);
|
||||
const memoryStores = vi.mocked(MemoryStore);
|
||||
|
||||
describe("initClient", () => {
|
||||
beforeEach(() => {
|
||||
clients.mockImplementation((opts) => fakeClient(opts));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("hands back the client once its first sync is done", async () => {
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client).toBe(clients.mock.results[0].value);
|
||||
expect(client.startClient).toHaveBeenCalledWith({
|
||||
clientWellKnownPollPeriod: 600,
|
||||
});
|
||||
expect(clients).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
useAuthorizationHeader: true,
|
||||
localTimeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stores when restoring a session", async () => {
|
||||
const client = (await initClient(options, true)) as unknown as FakeClient;
|
||||
expect(client.clearStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the stores before starting them when not restoring", async () => {
|
||||
const client = (await initClient(options, false)) as unknown as FakeClient;
|
||||
|
||||
expect(client.clearStores).toHaveBeenCalled();
|
||||
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
client.store.startup.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs into memory where there is no IndexedDB", async () => {
|
||||
// jsdom has no indexedDB
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(indexedDBStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", false);
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
|
||||
const [storeOptions] = indexedDBStores.mock.calls[0];
|
||||
expect(storeOptions.dbName).toBe("element-call-sync");
|
||||
expect(storeOptions.workerFactory).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB without a worker in development", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", true);
|
||||
|
||||
await initClient(options, true);
|
||||
|
||||
// The dev server does not bundle the worker
|
||||
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to memory when the IndexedDB store cannot start", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
const failing = store();
|
||||
failing.startup.mockRejectedValue(new Error("quota exceeded"));
|
||||
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
|
||||
Object.assign(this, failing);
|
||||
});
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(client.store.startup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries on without crypto when it cannot be initialised", async () => {
|
||||
clients.mockImplementation((opts) => {
|
||||
const client = fakeClient(opts);
|
||||
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
|
||||
return client;
|
||||
});
|
||||
|
||||
await expect(initClient(options, true)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("waits for a sync from the network, past one restored from the store", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => {
|
||||
client = fakeClient(opts, false);
|
||||
client.store.getSavedSyncToken.mockResolvedValue("saved");
|
||||
return client;
|
||||
});
|
||||
let ready = false;
|
||||
const result = initClient(options, true).then(() => (ready = true));
|
||||
await flushPromises();
|
||||
|
||||
// The first sync the client reports is the one restored from the store
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await flushPromises();
|
||||
expect(ready).toBe(false);
|
||||
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await result;
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when the first sync does", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
|
||||
const result = initClient(options, true);
|
||||
await flushPromises();
|
||||
|
||||
client.emit(ClientEvent.Sync, "ERROR", null, {
|
||||
error: new Error("offline"),
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// This import is why `initClient` has a module of its own:
|
||||
// - The bundler emits the worker file (11 MB: the Matrix SDK and its crypto
|
||||
// wasm) the moment it loads this import — whether or not anything ever calls
|
||||
// `new IndexedDBWorker()`. Tree shaking drops the call, not the file.
|
||||
// - So it may only sit in a module the component build never loads. It used
|
||||
// to be in `./matrix`, which the component reaches for the room URL helpers,
|
||||
// and the worker shipped in the package, unused and unusable by any host.
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
+8
-152
@@ -5,21 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
Preset,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
|
||||
import type { MatrixClient, Room } from "matrix-js-sdk";
|
||||
import { generateUrlSearchParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -27,149 +18,14 @@ import {
|
||||
saveKeyForRoom,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
|
||||
// Shared with the component, which reaches this module for the room URLs. So
|
||||
// nothing in here may import the client store worker: the bundler emits the
|
||||
// worker file on loading the import, used or not. The one function that needs
|
||||
// it, `initClient`, lives in `./initClient` for that reason.
|
||||
|
||||
export const fallbackICEServerAllowed =
|
||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
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,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function roomAliasLocalpartFromRoomName(roomName: string): string {
|
||||
return roomName
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { initSPA } from "./spa";
|
||||
import { Config } from "../config/Config";
|
||||
import { type Session } from "../ClientContext";
|
||||
|
||||
vi.mock("./initClient", () => ({ initClient: vi.fn() }));
|
||||
|
||||
const session: Session = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
passwordlessUser: true,
|
||||
};
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
describe("initSPA", () => {
|
||||
const clearSession = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("continues without a client when no session is stored", async () => {
|
||||
await expect(initSPA(() => undefined, clearSession)).resolves.toBeNull();
|
||||
|
||||
expect(initClient).not.toHaveBeenCalled();
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the stored session into a client", async () => {
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toEqual({
|
||||
client,
|
||||
passwordlessUser: true,
|
||||
});
|
||||
|
||||
expect(initClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
accessToken: "secret",
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "AAAA",
|
||||
}),
|
||||
// Restoring: the crypto store is the session's, not to be wiped
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a session the homeserver no longer knows", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
|
||||
);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toBeNull();
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the session and fails on any other error", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(new Error("offline"));
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).rejects.toThrow(
|
||||
"offline",
|
||||
);
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -9,7 +9,8 @@ import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed, initClient } from "./matrix";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
import { initClient } from "./initClient";
|
||||
import type { InitResult, Session } from "../ClientContext";
|
||||
|
||||
export async function initSPA(
|
||||
|
||||
Reference in New Issue
Block a user