mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
180 Commits
fkwp/refac
...
robin/erro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36e72147c | ||
|
|
905177f0b6 | ||
|
|
31c35583fb | ||
|
|
764c6fce24 | ||
|
|
86aa459a84 | ||
|
|
32b6250cc3 | ||
|
|
4a6dbf350f | ||
|
|
29968a3eba | ||
|
|
aa092c2195 | ||
|
|
0bf5c4a835 | ||
|
|
3d47609cff | ||
|
|
1425de8726 | ||
|
|
c1aa1888f0 | ||
|
|
58ebbfeed7 | ||
|
|
02f3f1b71a | ||
|
|
f7a6e20ae4 | ||
|
|
378d59e948 | ||
|
|
a07dbe645e | ||
|
|
a596eb71a9 | ||
|
|
c8a1cae18b | ||
|
|
4f1450aaf0 | ||
|
|
8704b44b78 | ||
|
|
0ee11af3c5 | ||
|
|
13fac57b01 | ||
|
|
e0ce58b2a2 | ||
|
|
6f9adfe3e8 | ||
|
|
d47e579994 | ||
|
|
61d8f9a412 | ||
|
|
31185ae365 | ||
|
|
e07c7c864b | ||
|
|
b88bce5220 | ||
|
|
775e110ca3 | ||
|
|
97d548ee3a | ||
|
|
34724b7a8c | ||
|
|
b005f36ac7 | ||
|
|
b0587fcfb3 | ||
|
|
b8eccc6d7a | ||
|
|
34df584366 | ||
|
|
a62b0be831 | ||
|
|
01240fb59d | ||
|
|
7851b9c289 | ||
|
|
15bcaef3a5 | ||
|
|
3c831fc5e6 | ||
|
|
26efbc1389 | ||
|
|
929367c9ce | ||
|
|
2f3e0b419d | ||
|
|
ef41ef85eb | ||
|
|
d9b91778b1 | ||
|
|
2f6bae5e5e | ||
|
|
40b351d76d | ||
|
|
8bc914a70f | ||
|
|
83bfb721e7 | ||
|
|
64e27152c6 | ||
|
|
f4a5ca9e84 | ||
|
|
789f37611b | ||
|
|
931cc9ddd7 | ||
|
|
65846dcf7d | ||
|
|
aa1b690b11 | ||
|
|
e5561dbd20 | ||
|
|
e68b403808 | ||
|
|
85d91de2e7 | ||
|
|
5f8a8d4c5b | ||
|
|
d2534748eb | ||
|
|
a548c5d592 | ||
|
|
08ab38280a | ||
|
|
a732fcc604 | ||
|
|
7e19b58976 | ||
|
|
e56fb7cfc8 | ||
|
|
aabfe16021 | ||
|
|
d572977922 | ||
|
|
6e0eee3509 | ||
|
|
87186058ab | ||
|
|
fbafc9f3f6 | ||
|
|
4f944ea72e | ||
|
|
61d2da2259 | ||
|
|
0661486676 | ||
|
|
07a4244c05 | ||
|
|
03f92ed6e4 | ||
|
|
469ec63fec | ||
|
|
50caca7bd3 | ||
|
|
81caa3321f | ||
|
|
3c3fce96e7 | ||
|
|
e757f7af6b | ||
|
|
7eae5b0ffb | ||
|
|
d82b91ad4a | ||
|
|
82b41ae86c | ||
|
|
68396f8a83 | ||
|
|
ebc714b73f | ||
|
|
e4fd630f37 | ||
|
|
a52b723414 | ||
|
|
99fc7162e2 | ||
|
|
8953936d3d | ||
|
|
a1110af6d5 | ||
|
|
b8951944ab | ||
|
|
16aa4ae3a9 | ||
|
|
d585f6232e | ||
|
|
b1ca464c58 | ||
|
|
0b8e7e9cae | ||
|
|
5374342d98 | ||
|
|
0971a15c40 | ||
|
|
78c59bb41c | ||
|
|
045d861ad1 | ||
|
|
269565d317 | ||
|
|
f0403c80bc | ||
|
|
a21da9417c | ||
|
|
fa0b5210fe | ||
|
|
9f84a5cae0 | ||
|
|
821c6e3763 | ||
|
|
4eb867436a | ||
|
|
323e088378 | ||
|
|
ab9dfc75f0 | ||
|
|
b969857583 | ||
|
|
aeae964e97 | ||
|
|
e4924df8fb | ||
|
|
cd48139fc3 | ||
|
|
2ec4b4ee87 | ||
|
|
a056a28423 | ||
|
|
c473d1fa8f | ||
|
|
57f312494f | ||
|
|
435a7d0adb | ||
|
|
a1759a46de | ||
|
|
1cf11b91c1 | ||
|
|
0412629871 | ||
|
|
c11a37ca61 | ||
|
|
fb95ba278e | ||
|
|
aa00a951b6 | ||
|
|
7fd7dc3794 | ||
|
|
ed234a1ad1 | ||
|
|
8497f008c8 | ||
|
|
d7e0abc55c | ||
|
|
e8c6d79a89 | ||
|
|
d94feaa8db | ||
|
|
6d0697cf2f | ||
|
|
28246ef86b | ||
|
|
0110465b38 | ||
|
|
5d6ec19ca0 | ||
|
|
956b7fc480 | ||
|
|
2946b307c2 | ||
|
|
c22e0cb7e2 | ||
|
|
01a2cd1410 | ||
|
|
7f4b0a33f6 | ||
|
|
35963bbc9e | ||
|
|
abf683f275 | ||
|
|
acaf69ca1b | ||
|
|
7a4c189249 | ||
|
|
7227c7b368 | ||
|
|
2012b09845 | ||
|
|
cc8ab0ac93 | ||
|
|
da2ce68348 | ||
|
|
471917b16a | ||
|
|
f6870ae5b4 | ||
|
|
b5fe55aef2 | ||
|
|
abd66f50db | ||
|
|
610e792394 | ||
|
|
7fa534d70d | ||
|
|
c8091ac111 | ||
|
|
86beaeb20f | ||
|
|
6b39d0a5fd | ||
|
|
f69c75322f | ||
|
|
f9b04ae38e | ||
|
|
53adfa4497 | ||
|
|
86d80630c1 | ||
|
|
18a59dd7db | ||
|
|
4569d01353 | ||
|
|
6b8c620bbb | ||
|
|
56328108ca | ||
|
|
1ff2fb3fff | ||
|
|
e6710f72e3 | ||
|
|
04bc6c77a3 | ||
|
|
34b3b9d733 | ||
|
|
44ed04475d | ||
|
|
de8a2cb533 | ||
|
|
4344f1dd1e | ||
|
|
9f8dd000e0 | ||
|
|
b681e53c35 | ||
|
|
6281628e4e | ||
|
|
9f727b904b | ||
|
|
380880137e | ||
|
|
8828b705d9 | ||
|
|
f78b8dd153 |
2
.github/workflows/blocked.yaml
vendored
2
.github/workflows/blocked.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Prevent blocked
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled]
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
23
.github/workflows/build-element-call.yaml
vendored
23
.github/workflows/build-element-call.yaml
vendored
@@ -9,6 +9,11 @@ on:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
||||
required: true
|
||||
build_mode:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The build mode for vite. Must be either 'development' or 'production'
|
||||
required: false
|
||||
default: production
|
||||
secrets:
|
||||
SENTRY_ORG:
|
||||
required: true
|
||||
@@ -31,26 +36,14 @@ jobs:
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build full version
|
||||
if: ${{ inputs.package == 'full' }}
|
||||
run: "yarn run build:full"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Build embedded
|
||||
if: ${{ inputs.package == 'embedded' }}
|
||||
run: "yarn run build:embedded"
|
||||
- name: Build Element Call
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -14,6 +14,7 @@ jobs:
|
||||
with:
|
||||
package: full
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
with:
|
||||
package: embedded
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
2
.github/workflows/changelog-label.yml
vendored
2
.github/workflows/changelog-label.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@2.1.0
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
|
||||
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
|
||||
|
||||
2
.github/workflows/deploy-to-netlify.yaml
vendored
2
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
Exercise caution. Use test accounts.
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
|
||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: ".node-version"
|
||||
|
||||
16
.github/workflows/publish-embedded-packages.yaml
vendored
16
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Determine filename
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
# n.b. We don't enable corepack here because we are using plain npm
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
path: embedded/android/lib/src/main/assets/element-call
|
||||
|
||||
- name: ☕️ Setup Java
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
path: element-call
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
6
.github/workflows/publish.yaml
vendored
6
.github/workflows/publish.yaml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Determine filename
|
||||
run: echo "FILENAME_PREFIX=element-call-${VERSION:1}" >> "$GITHUB_ENV"
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
15
.github/workflows/test.yaml
vendored
15
.github/workflows/test.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: ".node-version"
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -30,13 +30,13 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: ".node-version"
|
||||
@@ -46,11 +46,12 @@ jobs:
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
docker compose -f playwright-backend-docker-compose.yml up -d
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||
docker ps
|
||||
- name: Copy config file
|
||||
run: cp config/config.devenv.json public/config.json
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
2
.github/workflows/translations-download.yaml
vendored
2
.github/workflows/translations-download.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: ".node-version"
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY ./dist /dist
|
||||
WORKDIR /dist/assets
|
||||
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim
|
||||
|
||||
COPY --from=builder ./dist /app
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ server {
|
||||
# MatrixRTC reverse proxy
|
||||
# - MatrixRTC Authorization Service
|
||||
# - LiveKit SFU websocket signaling connection
|
||||
upstream jwt-auth-services {
|
||||
server auth-server:6080;
|
||||
server host.docker.internal:6080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@@ -62,8 +67,9 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# JWT Service running at port 8080
|
||||
proxy_pass http://auth-server:8080/;
|
||||
# JWT Service running at port 6080
|
||||
proxy_pass http://jwt-auth-services/;
|
||||
|
||||
}
|
||||
|
||||
location ^~ /livekit/sfu/ {
|
||||
@@ -145,11 +151,11 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://element-web:81;
|
||||
proxy_pass http://element-web:8081;
|
||||
proxy_ssl_verify off;
|
||||
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
"server_name": "call-unstable.ems.host"
|
||||
}
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
},
|
||||
"posthog": {
|
||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
|
||||
@@ -4,9 +4,10 @@ networks:
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
- LK_JWT_PORT=8080
|
||||
- LIVEKIT_JWT_PORT=6080
|
||||
- LIVEKIT_URL=wss://matrix-rtc.m.localhost/livekit/sfu
|
||||
- LIVEKIT_KEY=devkey
|
||||
- LIVEKIT_SECRET=secret
|
||||
@@ -18,7 +19,7 @@ services:
|
||||
condition: on-failure
|
||||
ports:
|
||||
# HOST_PORT:CONTAINER_PORT
|
||||
- 8080:8080
|
||||
- 6080:6080
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -73,11 +74,11 @@ services:
|
||||
image: ghcr.io/element-hq/element-web:develop
|
||||
pull_policy: always
|
||||
volumes:
|
||||
- ./backend/ew.test.config.json:/app/config.json
|
||||
- ./backend/ew.test.config.json:/app/config.json:Z
|
||||
environment:
|
||||
ELEMENT_WEB_PORT: 81
|
||||
ELEMENT_WEB_PORT: 8081
|
||||
ports:
|
||||
- "8081:81"
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -91,7 +92,6 @@ services:
|
||||
- ./backend/dev_tls_m.localhost.crt:/root/ssl/cert.pem:Z
|
||||
ports:
|
||||
# HOST_PORT:CONTAINER_PORT
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8008:80"
|
||||
- "4443:443"
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
# Global JS controls
|
||||
|
||||
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
|
||||
A few aspects of Element Call's interface can be controlled through a global API on the `window`.
|
||||
|
||||
## Picture-in-picture
|
||||
|
||||
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
|
||||
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
|
||||
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
|
||||
|
||||
## Audio devices
|
||||
|
||||
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
|
||||
|
||||
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
|
||||
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
|
||||
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
|
||||
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
|
||||
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
|
||||
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
|
||||
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
|
||||
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
|
||||
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
|
||||
In particular android is using it to setup the output channel so that the call volume can
|
||||
be controlled by the hardware volume rocker.
|
||||
|
||||
@@ -255,13 +255,17 @@ self-hosters and developers working with Element Call.
|
||||
|
||||
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
|
||||
|
||||
## 🛠️ How-Tos & Tutorials
|
||||
## 📝 How-Tos & Tutorials
|
||||
|
||||
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
|
||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
|
||||
|
||||
## 🤝 Want to Contribute?
|
||||
|
||||
Have a guide or blog post you'd like to share? Open a
|
||||
|
||||
@@ -63,6 +63,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.8.0"
|
||||
android_gradle_plugin = "8.10.1"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }
|
||||
BIN
embedded/android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
embedded/android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
9
embedded/android/gradlew
vendored
9
embedded/android/gradlew
vendored
@@ -86,8 +86,7 @@ done
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
@@ -115,7 +114,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -206,7 +205,7 @@ fi
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
4
embedded/android/gradlew.bat
vendored
4
embedded/android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
||||
@@ -27,7 +27,7 @@ android {
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
|
||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
|
||||
|
||||
signAllPublications()
|
||||
|
||||
|
||||
4
knip.ts
4
knip.ts
@@ -27,6 +27,10 @@ export default {
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// We need this so that TypeScript is happy with @livekit/track-processors.
|
||||
// This might be a bug in the LiveKit repo but for now we fix it on the
|
||||
// Element Call side.
|
||||
"@types/dom-mediacapture-transform",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
@@ -70,10 +70,12 @@
|
||||
"livekit_server_info": "Informace o serveru LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Ztlumit všechny zvuky (účastníci, reakce, zvuky připojení)",
|
||||
"show_connection_stats": "Zobrazit statistiky připojení",
|
||||
"show_non_member_tiles": "Zobrazit dlaždice pro nečlenská média",
|
||||
"url_params": "Parametry URL",
|
||||
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager"
|
||||
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager",
|
||||
"use_to_device_key_transport": "Použít přenos klíčů do zařízení. Tím se vrátíte k přenosu klíčů do místnosti, když jiný účastník hovoru pošle klíč místnosti"
|
||||
},
|
||||
"disconnected_banner": "Připojení k serveru bylo ztraceno.",
|
||||
"error": {
|
||||
@@ -164,6 +166,9 @@
|
||||
"effect_volume_description": "Upravit hlasitost přehrávání reakcí a efektů zvednutých rukou.",
|
||||
"effect_volume_label": "Hlasitost zvukového efektu"
|
||||
},
|
||||
"background_blur_header": "Pozadí",
|
||||
"background_blur_label": "Rozostřit pozadí videa",
|
||||
"blur_not_supported_by_browser": "(Toto zařízení nepodporuje rozostření pozadí.)",
|
||||
"developer_tab_title": "Vývojář",
|
||||
"devices": {
|
||||
"camera": "Fotoaparát",
|
||||
|
||||
221
locales/da/app.json
Normal file
221
locales/da/app.json
Normal file
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"a11y": {
|
||||
"user_menu": "Brugermenu"
|
||||
},
|
||||
"action": {
|
||||
"close": "Luk",
|
||||
"copy_link": "Kopiér link",
|
||||
"edit": "Rediger",
|
||||
"go": "Gå",
|
||||
"invite": "Invitér",
|
||||
"lower_hand": "Sænk hånd",
|
||||
"no": "Nej",
|
||||
"pick_reaction": "Vælg reaktion",
|
||||
"raise_hand": "Ræk hånden op",
|
||||
"register": "Registrér",
|
||||
"remove": "Fjern",
|
||||
"show_less": "Vis mindre",
|
||||
"show_more": "Vis mere",
|
||||
"sign_in": "Log ind",
|
||||
"sign_out": "Log ud",
|
||||
"submit": "Indsend",
|
||||
"upload_file": "Upload fil"
|
||||
},
|
||||
"analytics_notice": "Ved at deltage i denne beta giver du samtykke til indsamling af anonyme data, som vi bruger til at forbedre produktet. Du kan finde flere oplysninger om, hvilke data vi sporer, i vores <2>fortrolighedspolitik</2> og vores <6>cookiepolitik</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Fortsæt i browseren",
|
||||
"open_in_app": "Åbn i appen",
|
||||
"text": "Klar til at deltage?",
|
||||
"title": "Vælg app"
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Opret konto",
|
||||
"create_account_prompt": "<0>Hvorfor ikke afslutte med at oprette en adgangskode for at beholde din konto? </0><1>Du kan beholde dit navn og indstille en avatar til brug ved fremtidige opkald </1>",
|
||||
"feedback_done": "<0>Tak for din feedback! </0>",
|
||||
"feedback_prompt": "<0>Vi vil meget gerne høre din feedback, så vi kan forbedre din oplevelse.</0>",
|
||||
"headline": "{{displayName}}, dit opkald er afsluttet.",
|
||||
"not_now_button": "Ikke nu, vend tilbage til startskærmen",
|
||||
"reconnect_button": "Tilslut igen",
|
||||
"survey_prompt": "Hvordan gik det?"
|
||||
},
|
||||
"call_name": "Navn på opkald",
|
||||
"common": {
|
||||
"analytics": "Analyse-værktøj",
|
||||
"audio": "Lyd",
|
||||
"avatar": "Avatar",
|
||||
"back": "Tilbage",
|
||||
"display_name": "Vist navn",
|
||||
"encrypted": "Krypteret",
|
||||
"home": "Hjem",
|
||||
"loading": "Indlæser...",
|
||||
"next": "Næste",
|
||||
"options": "Valgmuligheder",
|
||||
"password": "Adgangskode",
|
||||
"preferences": "Foretrukne",
|
||||
"profile": "Profil",
|
||||
"reaction": "Reaktion",
|
||||
"reactions": "Reaktioner",
|
||||
"settings": "Indstillinger",
|
||||
"unencrypted": "Ikke krypteret",
|
||||
"username": "Brugernavn",
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Krypto-version: {{version}}",
|
||||
"debug_tile_layout_label": "Fejlfinding af fliselayout",
|
||||
"device_id": "Enheds-id: {{id}}",
|
||||
"duplicate_tiles_label": "Antal ekstra flisekopier pr. deltager",
|
||||
"environment_variables": "Miljøvariabler",
|
||||
"hostname": "Værtsnavn: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit Serverinfo",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"show_connection_stats": "Vis forbindelsesstatistik",
|
||||
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
|
||||
"url_params": "URL-parametre",
|
||||
"use_new_membership_manager": "Brug den nye implementering af opkaldet MembershipManager",
|
||||
"use_to_device_key_transport": "Bruges til at transportere enhedsnøgler. Dette vil falde tilbage til transport af værelsesnøgler, når et andet opkaldsmedlem sender en rumnøgle"
|
||||
},
|
||||
"disconnected_banner": "Forbindelsen til serveren er gået tabt.",
|
||||
"error": {
|
||||
"call_is_not_supported": "Opkald er ikke understøttet",
|
||||
"call_not_found": "Opkald ikke fundet",
|
||||
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
|
||||
"connection_lost": "Forbindelsen gik tabt",
|
||||
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
|
||||
"e2ee_unsupported": "Inkompatibel browser",
|
||||
"e2ee_unsupported_description": "Din webbrowser understøtter ikke krypterede opkald. Understøttede browsere inkluderer Chrome, Safari og Firefox 117+.",
|
||||
"generic": "Noget gik galt",
|
||||
"generic_description": "Indsendelse af fejlfindingslogfiler hjælper os med at spore problemet.",
|
||||
"insufficient_capacity": "Utilstrækkelig kapacitet",
|
||||
"insufficient_capacity_description": "Serveren har nået sin maksimale kapacitet, og du kan ikke deltage i opkaldet på dette tidspunkt. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
|
||||
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
|
||||
"open_elsewhere": "Åbnet i en anden fane",
|
||||
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
|
||||
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Du er blevet spærret fra rummet.",
|
||||
"banned_heading": "Spærret",
|
||||
"call_ended_body": "Du er blevet fjernet fra opkaldet.",
|
||||
"call_ended_heading": "Opkaldet afsluttet",
|
||||
"knock_reject_body": "Din anmodning om at deltage blev afvist.",
|
||||
"knock_reject_heading": "Adgang nægtet",
|
||||
"reason": "Årsag: {{reason}}"
|
||||
},
|
||||
"hangup_button_label": "Afslut opkald",
|
||||
"header_label": "Element Ring hjem",
|
||||
"header_participants_label": "Deltagere",
|
||||
"invite_modal": {
|
||||
"link_copied_toast": "Link kopieret til udklipsholder",
|
||||
"title": "Inviter til dette opkald"
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Ja, deltag i opkald",
|
||||
"text": "Dette opkald findes allerede, vil du være med?",
|
||||
"title": "Deltag i eksisterende opkald?"
|
||||
},
|
||||
"layout_grid_label": "Gitter",
|
||||
"layout_spotlight_label": "Spotlys",
|
||||
"lobby": {
|
||||
"ask_to_join": "Anmod om at deltage i opkaldet",
|
||||
"join_as_guest": "Deltag som gæst",
|
||||
"join_button": "Deltag i opkald",
|
||||
"leave_button": "Tilbage til seneste",
|
||||
"waiting_for_invite": "Anmodning sendt! Venter på tilladelse til at deltage..."
|
||||
},
|
||||
"log_in": "Log ind",
|
||||
"logging_in": "Logger ind...",
|
||||
"login_auth_links": "<0>Opret en konto </0> eller <2> få adgang som gæst </2>",
|
||||
"login_auth_links_prompt": "Ikke registreret endnu?",
|
||||
"login_subheading": "For at fortsætte til Element",
|
||||
"login_title": "Login",
|
||||
"microphone_off": "Mikrofon slukket",
|
||||
"microphone_on": "Mikrofon tændt",
|
||||
"mute_microphone_button_label": "Slå mikrofonen fra",
|
||||
"participant_count_one": "{{count, number}}",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "QR-kode",
|
||||
"rageshake_button_error_caption": "Prøv at sende logfiler igen",
|
||||
"rageshake_request_modal": {
|
||||
"body": "En anden bruger på dette opkald har et problem. For bedre at kunne diagnosticere sådanne problemer, vil vi gerne indsamle en fejlfindingslog.",
|
||||
"title": "Anmodning om fejlfindingslogfil"
|
||||
},
|
||||
"rageshake_send_logs": "Send fejlfindingslogfiler",
|
||||
"rageshake_sending": "Sender...",
|
||||
"rageshake_sending_logs": "Afsendelse af fejlfindingslogfiler...",
|
||||
"rageshake_sent": "Tak!",
|
||||
"recaptcha_dismissed": "Recaptcha afvist",
|
||||
"recaptcha_not_loaded": "Recaptcha ikke indlæst",
|
||||
"recaptcha_ssla_caption": "Dette websted er beskyttet af ReCAPTCHA og Googles <2>Privatlivspolitik</2> og <6>Servicevilkår</6> gælder.<9></9>Ved at klikke på \"Registrer\" accepterer du vores <12>Software og Services Licensaftale (SSLA)</12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Adgangskoderne skal være identiske",
|
||||
"registering": "Registrering..."
|
||||
},
|
||||
"register_auth_links": "<0>Har du allerede en konto? </0><1><0>Log ind </0> eller <2> få adgang som gæst </2> </1>",
|
||||
"register_confirm_password_label": "Bekræft adgangskode",
|
||||
"register_heading": "Opret din konto",
|
||||
"return_home_button": "Vend tilbage til startskærmen",
|
||||
"room_auth_view_continue_button": "Fortsæt",
|
||||
"room_auth_view_ssla_caption": "Ved at klikke på „Deltag i opkald nu“ accepterer du vores <2> Software og Services Licensaftale (SSLA) </2>",
|
||||
"screenshare_button_label": "Del din skærm",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
|
||||
"effect_volume_label": "Lydstyrke for lydeffekter"
|
||||
},
|
||||
"developer_tab_title": "Udvikler",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard <2>({{name}})</2>",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Højttaler",
|
||||
"speaker_numbered": "Højttaler {{n}}"
|
||||
},
|
||||
"feedback_tab_body": "Hvis du oplever problemer eller bare gerne vil give feedback, kan du sende os en kort beskrivelse herunder.",
|
||||
"feedback_tab_description_label": "Din tilbagemelding",
|
||||
"feedback_tab_h4": "Indsend feedback",
|
||||
"feedback_tab_send_logs_label": "Medtag fejlfindingslogfiler",
|
||||
"feedback_tab_thank_you": "Tak, vi har modtaget din feedback!",
|
||||
"feedback_tab_title": "Feedback",
|
||||
"opt_in_description": "<0></0><1></1>Du kan trække dit samtykke tilbage ved at fjerne markeringen i dette felt. Hvis du i øjeblikket er i gang med et opkald, træder denne indstilling i kraft ved afslutningen af opkaldet.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Udviklertilstand",
|
||||
"developer_mode_label_description": "Aktivér udviklertilstand og vis fanen udviklerindstillinger.",
|
||||
"introduction": "Her kan du konfigurere ekstra muligheder for en forbedret oplevelse.",
|
||||
"reactions_play_sound_description": "Afspil en lydeffekt, når nogen sender en reaktion i et opkald.",
|
||||
"reactions_play_sound_label": "Afspil reaktionslyde",
|
||||
"reactions_show_description": "Vis en animation, når nogen sender en reaktion.",
|
||||
"reactions_show_label": "Vis reaktioner",
|
||||
"show_hand_raised_timer_description": "Vis en timer, når en deltager rækker hånden",
|
||||
"show_hand_raised_timer_label": "Vis varighed af håndsoprækning"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} stjerne",
|
||||
"star_rating_input_label_other": "{{count}} stjerner",
|
||||
"start_new_call": "Start nyt opkald",
|
||||
"start_video_button_label": "Start video",
|
||||
"stop_screenshare_button_label": "Skærmen bliver delt",
|
||||
"stop_video_button_label": "Stop video",
|
||||
"submitting": "Indsender...",
|
||||
"switch_camera": "Skift kamera",
|
||||
"unauthenticated_view_body": "Ikke registreret endnu? <2>Opret en konto </2>",
|
||||
"unauthenticated_view_login_button": "Log ind på din konto",
|
||||
"unauthenticated_view_ssla_caption": "Ved at klikke på \"Start\" accepterer du vores <2>Software og Services Licensaftale (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Slå mikrofonen til",
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Vis altid",
|
||||
"camera_starting": "Indlæser video",
|
||||
"change_fit_contain": "Tilpas til rammen",
|
||||
"collapse": "Fold sammen",
|
||||
"expand": "Udvid",
|
||||
"mute_for_me": "Slå lyden fra for mig",
|
||||
"muted_for_me": "Dæmpet for mig",
|
||||
"volume": "Lydstyrke",
|
||||
"waiting_for_media": "Venter på medier..."
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "iPhone-Ohrhörer-Option auf allen Plattformen anzeigen",
|
||||
"crypto_version": "Krypto-Version: {{version}}",
|
||||
"debug_tile_layout_label": "Kachel-Layout debuggen",
|
||||
"device_id": "Geräte-ID: {{id}}",
|
||||
@@ -70,10 +71,12 @@
|
||||
"livekit_server_info": "LiveKit-Server Informationen",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix-ID: {{id}}",
|
||||
"mute_all_audio": "Stummschalten aller Audiosignale (Teilnehmer, Reaktionen, Beitrittsgeräusche)",
|
||||
"show_connection_stats": "Verbindungsstatistiken anzeigen",
|
||||
"show_non_member_tiles": "Kacheln für Nicht-Mitgliedermedien anzeigen",
|
||||
"url_params": "URL-Parameter",
|
||||
"use_new_membership_manager": "Neuen MembershipManager verwenden"
|
||||
"use_new_membership_manager": "Neuen MembershipManager verwenden",
|
||||
"use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen."
|
||||
},
|
||||
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
|
||||
"error": {
|
||||
@@ -163,12 +166,16 @@
|
||||
"effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden.",
|
||||
"effect_volume_label": "Lautstärke der Soundeffekte"
|
||||
},
|
||||
"background_blur_header": "Hintergrund",
|
||||
"background_blur_label": "Unschärfeeffekt für den Hintergrund aktivieren",
|
||||
"blur_not_supported_by_browser": "(Hintergrundunschärfe wird von diesem Gerät nicht unterstützt.)",
|
||||
"developer_tab_title": "Entwickler",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard<2> ({{name}} )</2>",
|
||||
"earpiece": "Ohrhörer",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon{{n}}",
|
||||
"speaker": "Lautsprecher",
|
||||
|
||||
@@ -4,15 +4,30 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "Κλείσιμο",
|
||||
"copy_link": "Αντιγραφή συνδέσμου",
|
||||
"edit": "Επεξεργασία",
|
||||
"go": "Μετάβαση",
|
||||
"invite": "Πρόσκληση",
|
||||
"lower_hand": "Κατεβάστε το χέρι",
|
||||
"no": "Όχι",
|
||||
"pick_reaction": "Επιλέξτε αντίδραση",
|
||||
"raise_hand": "Σηκώστε το χέρι",
|
||||
"register": "Εγγραφή",
|
||||
"remove": "Αφαίρεση",
|
||||
"show_less": "Εμφάνιση λιγότερων",
|
||||
"show_more": "Εμφάνιση περισσότερων",
|
||||
"sign_in": "Σύνδεση",
|
||||
"sign_out": "Αποσύνδεση",
|
||||
"submit": "Υποβολή"
|
||||
"submit": "Υποβολή",
|
||||
"upload_file": "Μεταφόρτωση αρχείου"
|
||||
},
|
||||
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <6>Πολιτική cookies</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Συνέχεια στο πρόγραμμα περιήγησης",
|
||||
"open_in_app": "Ανοίξτε στην εφαρμογή",
|
||||
"text": "Έτοιμοι να συμμετάσχετε?",
|
||||
"title": "Επιλέξτε εφαρμογή"
|
||||
},
|
||||
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <5>Πολιτική cookies</5>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Δημιουργία λογαριασμού",
|
||||
"create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;</0><1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.</1>",
|
||||
@@ -20,19 +35,45 @@
|
||||
"feedback_prompt": "<0>Θα θέλαμε να ακούσουμε τα σχόλιά σας ώστε να βελτιώσουμε την εμπειρία σας.</0>",
|
||||
"headline": "{{displayName}}, η κλήση σας τερματίστηκε.",
|
||||
"not_now_button": "Όχι τώρα, επιστροφή στην αρχική οθόνη",
|
||||
"reconnect_button": "Επανασύνδεση",
|
||||
"survey_prompt": "Πώς σας φάνηκε;"
|
||||
},
|
||||
"call_name": "Όνομα κλήσης",
|
||||
"common": {
|
||||
"analytics": "Δεδομένα ανάλυσης",
|
||||
"audio": "Ήχος",
|
||||
"avatar": "Εικόνα Προφίλ",
|
||||
"back": "Πίσω",
|
||||
"display_name": "Εμφανιζόμενο όνομα",
|
||||
"encrypted": "Κρυπτογραφημένο",
|
||||
"home": "Αρχική",
|
||||
"loading": "Φόρτωση…",
|
||||
"next": "Επόμενο",
|
||||
"options": "Επιλογές",
|
||||
"password": "Κωδικός",
|
||||
"preferences": "Προτιμήσεις",
|
||||
"profile": "Προφίλ",
|
||||
"reaction": "Αντίδραση",
|
||||
"reactions": "Αντιδράσεις",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"unencrypted": "Μη κρυπτογραφημένο",
|
||||
"username": "Όνομα χρήστη",
|
||||
"video": "Βίντεο"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Έκδοση κρυπτογράφησης: {{version}}",
|
||||
"debug_tile_layout_label": "Διάταξη πλακιδίων εντοπισμού σφαλμάτων",
|
||||
"device_id": "Αναγνωριστικό συσκευής: {{id}}",
|
||||
"duplicate_tiles_label": "Αριθμός επιπλέον αντιγράφων πλακιδίων ανά συμμετέχοντα",
|
||||
"environment_variables": "Μεταβλητές περιβάλλοντος",
|
||||
"hostname": "Όνομα κεντρικού υπολογιστή: {{hostname}}",
|
||||
"livekit_server_info": "Πληροφορίες διακομιστή LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Αναγνωριστικό Matrix: {{id}}",
|
||||
"show_connection_stats": "Εμφάνιση στατιστικών σύνδεσης",
|
||||
"show_non_member_tiles": "Εμφάνιση πλακιδίων για μέσα μη-μελών",
|
||||
"url_params": "Παράμετροι URL"
|
||||
},
|
||||
"header_label": "Element Κεντρική Οθόνη Κλήσεων",
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Ναι, συμμετοχή στην κλήση",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"debug_tile_layout_label": "Debug tile layout",
|
||||
"device_id": "Device ID: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
|
||||
"show_connection_stats": "Show connection statistics",
|
||||
"show_non_member_tiles": "Show tiles for non-member media",
|
||||
"url_params": "URL parameters",
|
||||
@@ -80,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Call is not supported",
|
||||
"call_not_found": "Call not found",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
|
||||
"connection_lost": "Connection lost",
|
||||
"connection_lost_description": "You were disconnected from the call.",
|
||||
"e2ee_unsupported": "Incompatible browser",
|
||||
@@ -171,8 +173,10 @@
|
||||
"devices": {
|
||||
"camera": "Camera",
|
||||
"camera_numbered": "Camera {{n}}",
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"earpiece": "Earpiece",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
"speaker": "Speaker",
|
||||
|
||||
@@ -70,10 +70,12 @@
|
||||
"livekit_server_info": "LiveKiti serveri teave",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrixi kasutajatunnus: {{id}}",
|
||||
"mute_all_audio": "Summuta kõik helid (osalejad, regeerimised, liitumise helid)",
|
||||
"show_connection_stats": "Näita ühenduse statistikat",
|
||||
"show_non_member_tiles": "Näita ka mitteseotud meedia paane",
|
||||
"url_params": "Võrguaadressi parameetrid",
|
||||
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni"
|
||||
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni",
|
||||
"use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust"
|
||||
},
|
||||
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
|
||||
"error": {
|
||||
@@ -163,6 +165,9 @@
|
||||
"effect_volume_description": "Häälesta helivaljust, mida kasutatakse käe tõstmisel ja regeerimisel",
|
||||
"effect_volume_label": "Efektide helivajlus"
|
||||
},
|
||||
"background_blur_header": "Taust",
|
||||
"background_blur_label": "Hägusta video taust",
|
||||
"blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)",
|
||||
"developer_tab_title": "Arendaja",
|
||||
"devices": {
|
||||
"camera": "Kaamera",
|
||||
|
||||
224
locales/fi/app.json
Normal file
224
locales/fi/app.json
Normal file
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"a11y": {
|
||||
"user_menu": "Käyttäjävalikko"
|
||||
},
|
||||
"action": {
|
||||
"close": "Sulje",
|
||||
"copy_link": "Kopioi linkki",
|
||||
"edit": "Muokkaa",
|
||||
"go": "Siirry",
|
||||
"invite": "Kutsu",
|
||||
"lower_hand": "Laske käsi",
|
||||
"no": "Ei",
|
||||
"pick_reaction": "Valitse reaktio",
|
||||
"raise_hand": "Nosta käsi",
|
||||
"register": "Rekisteröidy",
|
||||
"remove": "Poista",
|
||||
"show_less": "Näytä vähemmän",
|
||||
"show_more": "Näytä lisää",
|
||||
"sign_in": "Kirjaudu sisään",
|
||||
"sign_out": "Kirjaudu ulos",
|
||||
"submit": "Lähetä",
|
||||
"upload_file": "Lähetä tiedosto"
|
||||
},
|
||||
"analytics_notice": "Osallistumalla tähän betaan hyväksyt nimettömien tietojen keräämisen, joita käytämme tuotteen parantamiseen. Löydät lisätietoa siitä, mitä tietoja seuraamme meidän <2> Tietosuojakäytännöstä</2> ja <6>Evästekäytännöstä</6> .",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Jatka selaimessa",
|
||||
"open_in_app": "Avaa sovelluksessa",
|
||||
"text": "Oletko valmis liittymään?",
|
||||
"title": "Valitse sovellus"
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Luo tili",
|
||||
"create_account_prompt": "<0>Miksi et viimeistelisi määrittämällä salasanaa tilisi säilyttämiseksi?</0><1>Voit säilyttää nimesi ja asettaa avatarin käytettäväksi tulevissa puheluissa</1>",
|
||||
"feedback_done": "<0>Kiitos palautteestasi!</0>",
|
||||
"feedback_prompt": "<0>Haluaisimme kuulla palautteesi, jotta voimme parantaa käyttökokemustasi.</0>",
|
||||
"headline": "{{displayName}}, puhelusi on päättynyt.",
|
||||
"not_now_button": "Ei nyt, palaa aloitusnäyttöön",
|
||||
"reconnect_button": "Yhdistä uudelleen",
|
||||
"survey_prompt": "Miten se meni?"
|
||||
},
|
||||
"call_name": "Puhelun nimi",
|
||||
"common": {
|
||||
"analytics": "Analytiikka",
|
||||
"audio": "Ääni",
|
||||
"avatar": "Avatar",
|
||||
"back": "Takaisin",
|
||||
"display_name": "Näyttönimi",
|
||||
"encrypted": "Salattu",
|
||||
"home": "Etusivu",
|
||||
"loading": "Ladataan...",
|
||||
"next": "Seuraava",
|
||||
"options": "Vaihtoehdot",
|
||||
"password": "Salasana",
|
||||
"preferences": "Asetukset",
|
||||
"profile": "Profiili",
|
||||
"reaction": "Reaktio",
|
||||
"reactions": "Reaktiot",
|
||||
"settings": "Asetukset",
|
||||
"unencrypted": "Ei salattu",
|
||||
"username": "Käyttäjänimi",
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Kryptoversio: {{version}}",
|
||||
"debug_tile_layout_label": "Laattojen asettelun vianmääritys",
|
||||
"device_id": "Laitteen tunnus: {{id}}",
|
||||
"duplicate_tiles_label": "Lisälaattakopioiden määrä osallistujaa kohti",
|
||||
"environment_variables": "Ympäristömuuttujat",
|
||||
"hostname": "Isäntänimi: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit-palvelimen tiedot",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix tunnus: {{id}}",
|
||||
"show_connection_stats": "Näytä yhteystilastot",
|
||||
"show_non_member_tiles": "Näytä laatat ei-jäsenien medialle",
|
||||
"url_params": "URL-parametrit",
|
||||
"use_new_membership_manager": "Käytä uutta puhelun MembershipManagerin toteutusta",
|
||||
"use_to_device_key_transport": "Käytä laitteen avainten kuljetusta. Tämä palaa huoneen avainten siirtoon, kun toinen puhelun jäsen lähettää huoneavaimen"
|
||||
},
|
||||
"disconnected_banner": "Yhteys palvelimeen on katkennut.",
|
||||
"error": {
|
||||
"call_is_not_supported": "Puhelua ei tueta",
|
||||
"call_not_found": "Puhelua ei löydy",
|
||||
"call_not_found_description": "<0>Kyseinen linkki ei näytä kuuluvan mihinkään olemassa olevaan puheluun. Tarkista, että sinulla on oikea linkki, tai <1>luo uusi linkki</1>.</0>",
|
||||
"connection_lost": "Yhteys katkesi",
|
||||
"connection_lost_description": "Sinut katkaistiin puhelusta.",
|
||||
"e2ee_unsupported": "Yhteensopimaton selain",
|
||||
"e2ee_unsupported_description": "Verkkoselaimesi ei tue salattuja puheluita. Tuettuja selaimia ovat Chrome, Safari ja Firefox 117+.",
|
||||
"generic": "Jokin meni pieleen",
|
||||
"generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.",
|
||||
"insufficient_capacity": "Riittämätön kapasiteetti",
|
||||
"insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
|
||||
"matrix_rtc_focus_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
|
||||
"open_elsewhere": "Avattu toisessa välilehdessä",
|
||||
"open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.",
|
||||
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Sinulle on annettu porttikielto huoneesta.",
|
||||
"banned_heading": "Kielletty",
|
||||
"call_ended_body": "Sinut on poistettu puhelusta.",
|
||||
"call_ended_heading": "Puhelu päättyi",
|
||||
"knock_reject_body": "Liittymispyyntösi hylättiin.",
|
||||
"knock_reject_heading": "Pääsy kielletty",
|
||||
"reason": "Syy: {{reason}}"
|
||||
},
|
||||
"hangup_button_label": "Lopeta puhelu",
|
||||
"header_label": "Element Call Etusivu",
|
||||
"header_participants_label": "Osallistujat",
|
||||
"invite_modal": {
|
||||
"link_copied_toast": "Linkki kopioitu leikepöydälle",
|
||||
"title": "Kutsu tähän puheluun"
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Kyllä, liity puheluun",
|
||||
"text": "Tämä puhelu on jo olemassa, haluatko liittyä siihen?",
|
||||
"title": "Liity olemassa olevaan puheluun?"
|
||||
},
|
||||
"layout_grid_label": "Ruudukko",
|
||||
"layout_spotlight_label": "Valokeila",
|
||||
"lobby": {
|
||||
"ask_to_join": "Pyydä liittymistä puheluun",
|
||||
"join_as_guest": "Liity vieraana",
|
||||
"join_button": "Liity puheluun",
|
||||
"leave_button": "Takaisin viimeisimpiin puheluihin",
|
||||
"waiting_for_invite": "Pyyntö lähetetty! Odotetaan lupaa liittyä…"
|
||||
},
|
||||
"log_in": "Kirjaudu sisään",
|
||||
"logging_in": "Kirjaudutaan sisään…",
|
||||
"login_auth_links": "<0>Luo tili</0> tai <2>Käytä vieraana</2>",
|
||||
"login_auth_links_prompt": "Etkö ole vielä rekisteröitynyt?",
|
||||
"login_subheading": "Jatkaaksesi Elementiin",
|
||||
"login_title": "Kirjaudu sisään",
|
||||
"microphone_off": "Mikrofoni pois päältä",
|
||||
"microphone_on": "Mikrofoni päällä",
|
||||
"mute_microphone_button_label": "Mykistä mikrofoni",
|
||||
"participant_count_one": "{{count, number}}",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "QR-koodi",
|
||||
"rageshake_button_error_caption": "Yritä uudelleen lokien lähettämistä",
|
||||
"rageshake_request_modal": {
|
||||
"body": "Toisella käyttäjällä tässä puhelussa on ongelma. Jotta voimme diagnosoida nämä ongelmat paremmin, haluaisimme kerätä virheenkorjauslokin.",
|
||||
"title": "Virheenkorjauslokipyyntö"
|
||||
},
|
||||
"rageshake_send_logs": "Lähetä virheenkorjauslokit",
|
||||
"rageshake_sending": "Lähetetään…",
|
||||
"rageshake_sending_logs": "Lähetetään virheenkorjauslokeja…",
|
||||
"rageshake_sent": "Kiitos!",
|
||||
"recaptcha_dismissed": "Recaptcha hylätty",
|
||||
"recaptcha_not_loaded": "Recaptcha ei ole ladattu",
|
||||
"recaptcha_ssla_caption": "Tämä sivusto on suojattu ReCAPTCHA:lla, ja siihen sovelletaan Googlen <2>Tietosuojakäytäntöä</2> ja <6>Palveluehtoja</6>.<9></9>Klikkaamalla \"Rekisteröidy\" hyväksyt <12>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Salasanojen on täsmättävä",
|
||||
"registering": "Rekisteröidään…"
|
||||
},
|
||||
"register_auth_links": "<0>Onko sinulla jo tili?</0><1><0>Kirjaudu sisään</0> tai <2>Käytä vieraana</2></1>",
|
||||
"register_confirm_password_label": "Vahvista salasana",
|
||||
"register_heading": "Luo tilisi",
|
||||
"return_home_button": "Palaa aloitusnäyttöön",
|
||||
"room_auth_view_continue_button": "Jatka",
|
||||
"room_auth_view_ssla_caption": "Klikkaamalla \"Liity puheluun nyt\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
|
||||
"screenshare_button_label": "Jaa näyttö",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Säädä äänenvoimakkuutta, jolla reaktioiden ja käden nostojen ääniefektit toistetaan.",
|
||||
"effect_volume_label": "Ääniefektien äänenvoimakkuus"
|
||||
},
|
||||
"background_blur_header": "Tausta",
|
||||
"background_blur_label": "Sumenna videon tausta",
|
||||
"blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)",
|
||||
"developer_tab_title": "Kehittäjä",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Oletus",
|
||||
"default_named": "Oletus <2>({{name}})</2>",
|
||||
"microphone": "Mikrofoni",
|
||||
"microphone_numbered": "Mikrofoni {{n}}",
|
||||
"speaker": "Kaiutin",
|
||||
"speaker_numbered": "Kaiutin {{n}}"
|
||||
},
|
||||
"feedback_tab_body": "Jos sinulla on ongelmia tai haluat vain antaa palautetta, lähetä meille lyhyt kuvaus alla.",
|
||||
"feedback_tab_description_label": "Palautteesi",
|
||||
"feedback_tab_h4": "Lähetä palautetta",
|
||||
"feedback_tab_send_logs_label": "Sisällytä virheenkorjauslokit",
|
||||
"feedback_tab_thank_you": "Kiitos, saimme palautteesi!",
|
||||
"feedback_tab_title": "Palaute",
|
||||
"opt_in_description": "<0></0><1></1>Voit peruuttaa suostumuksesi poistamalla tämän ruudun valinnan. Jos puhelu on käynnissä, tämä asetus tulee voimaan puhelun lopussa.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Kehittäjätila",
|
||||
"developer_mode_label_description": "Ota kehittäjätila käyttöön ja näytä kehittäjäasetukset-välilehti.",
|
||||
"introduction": "Täällä voit määrittää lisävaihtoehtoja parempaa käyttökokemusta varten.",
|
||||
"reactions_play_sound_description": "Toista äänitefekti, kun joku lähettää reaktion puheluun.",
|
||||
"reactions_play_sound_label": "Toista reaktioäänet",
|
||||
"reactions_show_description": "Näytä animaatio, kun joku lähettää reaktion.",
|
||||
"reactions_show_label": "Näytä reaktiot",
|
||||
"show_hand_raised_timer_description": "Näytä ajastin, kun osallistuja nostaa kätensä",
|
||||
"show_hand_raised_timer_label": "Näytä kädennoston kesto"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} tähti",
|
||||
"star_rating_input_label_other": "{{count}} tähteä",
|
||||
"start_new_call": "Aloita uusi puhelu",
|
||||
"start_video_button_label": "Aloita video",
|
||||
"stop_screenshare_button_label": "Jaetaan näyttöä",
|
||||
"stop_video_button_label": "Lopeta video",
|
||||
"submitting": "Lähetetään…",
|
||||
"switch_camera": "Vaihda kameraa",
|
||||
"unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili</2>",
|
||||
"unauthenticated_view_login_button": "Kirjaudu tilillesi",
|
||||
"unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Poista mikrofonin mykistys",
|
||||
"version": "{{productName}} versio: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Näytä aina",
|
||||
"camera_starting": "Videota ladataan...",
|
||||
"change_fit_contain": "Sovita kehykseen",
|
||||
"collapse": "Supista",
|
||||
"expand": "Laajenna",
|
||||
"mute_for_me": "Mykistä minulle",
|
||||
"muted_for_me": "Mykistetty minulle",
|
||||
"volume": "Äänenvoimakkuus",
|
||||
"waiting_for_media": "Odotetaan mediaa..."
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,21 @@
|
||||
"action": {
|
||||
"close": "Tutup",
|
||||
"copy_link": "Salin tautan",
|
||||
"edit": "Sunting",
|
||||
"go": "Bergabung",
|
||||
"invite": "Undang",
|
||||
"lower_hand": "Turunkan tangan",
|
||||
"no": "Tidak",
|
||||
"pick_reaction": "Pilih reaksi",
|
||||
"raise_hand": "Angkat tangan",
|
||||
"register": "Daftar",
|
||||
"remove": "Hapus",
|
||||
"show_less": "Tampilkan lebih sedikit",
|
||||
"show_more": "Tampilkan lebih banyak",
|
||||
"sign_in": "Masuk",
|
||||
"sign_out": "Keluar",
|
||||
"submit": "Kirim"
|
||||
"submit": "Kirim",
|
||||
"upload_file": "Unggah berkas"
|
||||
},
|
||||
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
|
||||
"app_selection_modal": {
|
||||
@@ -33,17 +40,69 @@
|
||||
},
|
||||
"call_name": "Nama panggilan",
|
||||
"common": {
|
||||
"analytics": "Analitik",
|
||||
"audio": "Audio",
|
||||
"avatar": "Avatar",
|
||||
"back": "Kembali",
|
||||
"display_name": "Nama tampilan",
|
||||
"encrypted": "Terenkripsi",
|
||||
"home": "Beranda",
|
||||
"loading": "Memuat…",
|
||||
"next": "Berikutnya",
|
||||
"options": "Opsi",
|
||||
"password": "Kata sandi",
|
||||
"preferences": "Preferensi",
|
||||
"profile": "Profil",
|
||||
"reaction": "Reaksi",
|
||||
"reactions": "Reaksi",
|
||||
"settings": "Pengaturan",
|
||||
"unencrypted": "Tidak terenkripsi",
|
||||
"username": "Nama pengguna"
|
||||
"username": "Nama pengguna",
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Versi kripto: {{version}}",
|
||||
"debug_tile_layout_label": "Awakutu tata letak ubin",
|
||||
"device_id": "ID perangkat: {{id}}",
|
||||
"duplicate_tiles_label": "Jumlah salinan ubin tambahan per peserta",
|
||||
"environment_variables": "Variabel lingkungan",
|
||||
"hostname": "Nama hos: {{hostname}}",
|
||||
"livekit_server_info": "Info Server LiveKit",
|
||||
"livekit_sfu": "SFU LiveKit: {{url}}",
|
||||
"matrix_id": "ID Matrix: {{id}}",
|
||||
"show_connection_stats": "Tampilkan statistik koneksi",
|
||||
"show_non_member_tiles": "Tampilkan ubin untuk media non-anggota",
|
||||
"url_params": "Parameter URL",
|
||||
"use_new_membership_manager": "Gunakan implementasi baru dari panggilan MembershipManager",
|
||||
"use_to_device_key_transport": "Gunakan untuk transportasi kunci perangkat. Ini akan kembali ke transportasi kunci ruangan ketika anggota panggilan lain mengirim kunci ruangan"
|
||||
},
|
||||
"disconnected_banner": "Koneksi ke server telah hilang.",
|
||||
"error": {
|
||||
"call_is_not_supported": "Panggilan tidak didukung",
|
||||
"call_not_found": "Panggilan tidak ditemukan",
|
||||
"call_not_found_description": "<0>Tautan itu tampaknya bukan milik panggilan yang ada. Periksa apakah Anda memiliki tautan yang tepat, atau <1> buat yang baru</1>.</0>",
|
||||
"connection_lost": "Koneksi terputus",
|
||||
"connection_lost_description": "Anda terputus dari panggilan.",
|
||||
"e2ee_unsupported": "Peramban tidak kompatibel",
|
||||
"e2ee_unsupported_description": "Peramban web Anda tidak mendukung panggilan terenkripsi. Peramban yang didukung meliputi Chrome, Safari, dan Firefox 117+.",
|
||||
"generic": "Ada yang salah",
|
||||
"generic_description": "Mengirimkan log awakutu akan membantu kami melacak masalah.",
|
||||
"insufficient_capacity": "Kapasitas tidak mencukupi",
|
||||
"insufficient_capacity_description": "Server telah mencapai kapasitas maksimum dan Anda tidak dapat bergabung dalam panggilan saat ini. Coba lagi nanti, atau hubungi admin server Anda jika masalah masih berlanjut.",
|
||||
"matrix_rtc_focus_missing": "Server tidak dikonfigurasi untuk bekerja dengan {{brand}}. Silakan hubungi admin server Anda (Domain: {{domain}}, Kode Kesalahan: {{ errorCode }}).",
|
||||
"open_elsewhere": "Dibuka di tab lain",
|
||||
"open_elsewhere_description": "{{brand}} telah dibuka di tab lain. Jika sepertinya tidak benar, coba muat ulang halaman.",
|
||||
"unexpected_ec_error": "Terjadi kesalahan tak terduga (<0> Kode Kesalahan:</0><1>{{ errorCode }}</1>). Silakan hubungi admin server Anda."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Anda telah dilarang dari ruangan.",
|
||||
"banned_heading": "Dilarang",
|
||||
"call_ended_body": "Anda telah dikeluarkan dari panggilan.",
|
||||
"call_ended_heading": "Panggilan berakhir",
|
||||
"knock_reject_body": "Permintaan Anda untuk bergabung ditolak.",
|
||||
"knock_reject_heading": "Akses ditolak",
|
||||
"reason": "Alasan: {{reason}}"
|
||||
},
|
||||
"hangup_button_label": "Akhiri panggilan",
|
||||
"header_label": "Beranda Element Call",
|
||||
"header_participants_label": "Peserta",
|
||||
@@ -59,15 +118,23 @@
|
||||
"layout_grid_label": "Kisi",
|
||||
"layout_spotlight_label": "Sorotan",
|
||||
"lobby": {
|
||||
"ask_to_join": "Permintaan untuk bergabung dalam panggilan",
|
||||
"join_as_guest": "Bergabung sebagai tamu",
|
||||
"join_button": "Bergabung ke panggilan",
|
||||
"leave_button": "Kembali ke terkini"
|
||||
"leave_button": "Kembali ke terkini",
|
||||
"waiting_for_invite": "Permintaan terkirim! Menunggu izin untuk bergabung…"
|
||||
},
|
||||
"log_in": "Masuk",
|
||||
"logging_in": "Memasuki…",
|
||||
"login_auth_links": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
|
||||
"login_auth_links_prompt": "Belum terdaftar?",
|
||||
"login_subheading": "Untuk melanjutkan ke Element",
|
||||
"login_title": "Masuk",
|
||||
"microphone_off": "Mikrofon dimatikan",
|
||||
"microphone_on": "Mikrofon dinyalakan",
|
||||
"mute_microphone_button_label": "Matikan mikrofon",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "Kode QR",
|
||||
"rageshake_button_error_caption": "Kirim ulang catatan",
|
||||
"rageshake_request_modal": {
|
||||
"body": "Pengguna yang lain di panggilan ini sedang mengalami masalah. Supaya dapat mendiagnosa masalah ini, kami ingin mengumpulkan sebuah catatan pengawakutuan.",
|
||||
@@ -79,23 +146,55 @@
|
||||
"rageshake_sent": "Terima kasih!",
|
||||
"recaptcha_dismissed": "Recaptcha ditutup",
|
||||
"recaptcha_not_loaded": "Recaptcha tidak dimuat",
|
||||
"recaptcha_ssla_caption": "Situs ini dilindungi oleh ReCAPTCHA dan <2>Kebijakan Privasi</2> dan <6>Ketentuan Layanan</6> Google berlaku.<9></9>Dengan mengeklik \"Daftar\", Anda menyetujui <12>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Kata sandi harus cocok",
|
||||
"registering": "Mendaftarkan…"
|
||||
},
|
||||
"register_auth_links": "<0>Sudah punya akun?</0><1><0>Masuk</0> Atau <2>Akses sebagai tamu</2></1>",
|
||||
"register_confirm_password_label": "Konfirmasi kata sandi",
|
||||
"register_heading": "Buat akun Anda",
|
||||
"return_home_button": "Kembali ke layar beranda",
|
||||
"room_auth_view_continue_button": "Lanjutkan",
|
||||
"room_auth_view_ssla_caption": "Dengan mengeklik “Gabung panggilan sekarang”, Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
|
||||
"screenshare_button_label": "Bagikan layar",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Sesuaikan volume saat reaksi dan efek tangan terangkat diputar.",
|
||||
"effect_volume_label": "Volume efek suara"
|
||||
},
|
||||
"background_blur_header": "Latar belakang",
|
||||
"background_blur_label": "Buramkan latar belakang video",
|
||||
"blur_not_supported_by_browser": "(Pemburaman latar belakang tidak didukung oleh perangkat ini.)",
|
||||
"developer_tab_title": "Pengembang",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Bawaan",
|
||||
"default_named": "Bawaan <2>({{name}})</2>",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Speaker",
|
||||
"speaker_numbered": "Speaker {{n}}"
|
||||
},
|
||||
"feedback_tab_body": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.",
|
||||
"feedback_tab_description_label": "Masukan Anda",
|
||||
"feedback_tab_h4": "Kirim masukan",
|
||||
"feedback_tab_send_logs_label": "Termasuk catatan pengawakutuan",
|
||||
"feedback_tab_thank_you": "Terima kasih, kami telah menerima masukan Anda!",
|
||||
"feedback_tab_title": "Masukan",
|
||||
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan."
|
||||
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Mode pengembang",
|
||||
"developer_mode_label_description": "Aktifkan mode pengembang dan tampilkan tab pengaturan pengembang.",
|
||||
"introduction": "Di sini Anda dapat mengonfigurasi opsi tambahan untuk pengalaman yang lebih baik.",
|
||||
"reactions_play_sound_description": "Mainkan efek suara ketika seseorang mengirim reaksi ke panggilan.",
|
||||
"reactions_play_sound_label": "Putar suara reaksi",
|
||||
"reactions_show_description": "Tampilkan animasi ketika ada yang mengirimkan reaksi.",
|
||||
"reactions_show_label": "Tampilkan reaksi",
|
||||
"show_hand_raised_timer_description": "Tampilkan pengatur waktu saat peserta mengangkat tangannya",
|
||||
"show_hand_raised_timer_label": "Tampilkan durasi angkat tangan"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} bintang",
|
||||
"star_rating_input_label_other": "{{count}} bintang",
|
||||
@@ -104,8 +203,21 @@
|
||||
"stop_screenshare_button_label": "Berbagi layar",
|
||||
"stop_video_button_label": "Matikan video",
|
||||
"submitting": "Mengirim…",
|
||||
"switch_camera": "Ganti kamera",
|
||||
"unauthenticated_view_body": "Belum terdaftar? <2>Buat sebuah akun</2>",
|
||||
"unauthenticated_view_login_button": "Masuk ke akun Anda",
|
||||
"unauthenticated_view_ssla_caption": "Dengan mengeklik \"Go\", Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
|
||||
"unmute_microphone_button_label": "Nyalakan mikrofon",
|
||||
"version": "Versi: {{version}}"
|
||||
"version": "Versi: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Selalu tampilkan",
|
||||
"camera_starting": "Memuat video...",
|
||||
"change_fit_contain": "Sesuai dengan bingkai",
|
||||
"collapse": "Tutup",
|
||||
"expand": "Buka",
|
||||
"mute_for_me": "Bisukan untuk saya",
|
||||
"muted_for_me": "Dibisukan untuk saya",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Menunggu media..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,30 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "Aizvērt",
|
||||
"copy_link": "Kopēt saiti",
|
||||
"edit": "Labot",
|
||||
"go": "Aiziet",
|
||||
"invite": "Uzaicināt",
|
||||
"lower_hand": "Nolaist roku",
|
||||
"no": "Nē",
|
||||
"pick_reaction": "Reaģēt",
|
||||
"raise_hand": "Pacelt roku",
|
||||
"register": "Reģistrēties",
|
||||
"remove": "Noņemt",
|
||||
"show_less": "Rādīt mazāk",
|
||||
"show_more": "Rādīt vairāk",
|
||||
"sign_in": "Pieteikties",
|
||||
"sign_out": "Atteikties",
|
||||
"submit": "Iesniegt"
|
||||
"submit": "Iesniegt",
|
||||
"upload_file": "Augšupielādēt failu"
|
||||
},
|
||||
"analytics_notice": "Piedaloties šajā beta versijā, jūs piekrītat anonīmu datu vākšanai, ko mēs izmantojam produkta uzlabošanai. Plašāku informāciju par to, kādus datus mēs izsekojam, varat atrast mūsu <2>konfidencialitātes politikā</2> un mūsu <6>sīkfailu politikā</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Turpināt pārlūkprogrammā",
|
||||
"open_in_app": "Atvērt lietotnē",
|
||||
"text": "Gatavs pievienoties?",
|
||||
"title": "Izvēlies lietotni"
|
||||
},
|
||||
"analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos</2> un <5>sīkdatņu nosacījumos</5>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Izveidot kontu",
|
||||
"create_account_prompt": "<0>Kādēļ nepabeigt ar paroles iestatīšanu, lai paturētu savu kontu?</0><1>Būs iespējams paturēt savu vārdu un iestatīt attēlu izmantošanai turpmākajos zvanos</1>",
|
||||
@@ -23,31 +38,104 @@
|
||||
"reconnect_button": "Atkārtoti savienoties",
|
||||
"survey_prompt": "Kā Tev veicās?"
|
||||
},
|
||||
"call_name": "Zvana nosaukums",
|
||||
"common": {
|
||||
"analytics": "Analītika",
|
||||
"audio": "Skaņa",
|
||||
"avatar": "Attēls",
|
||||
"back": "Atpakaļ",
|
||||
"display_name": "Attēlojamais vārds",
|
||||
"encrypted": "Šifrēts",
|
||||
"home": "Sākums",
|
||||
"loading": "Lādējas…",
|
||||
"next": "Nākamais",
|
||||
"options": "Opcijas",
|
||||
"password": "Parole",
|
||||
"preferences": "Iestatījumi",
|
||||
"profile": "Profils",
|
||||
"reaction": "Reakcija",
|
||||
"reactions": "Reakcijas",
|
||||
"settings": "Iestatījumi",
|
||||
"username": "Lietotājvārds"
|
||||
"unencrypted": "Nav šifrēts",
|
||||
"username": "Lietotājvārds",
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Crypto versija: {{version}}",
|
||||
"debug_tile_layout_label": "Vietu izkārtojuma atkļūdošana",
|
||||
"device_id": "Ierīces ID: {{id}}",
|
||||
"duplicate_tiles_label": "Papildu vietu kopiju skaits vienam dalībniekam",
|
||||
"environment_variables": "Vides mainīgie",
|
||||
"hostname": "Saimniekdatora nosaukums: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit Server informācija",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"show_connection_stats": "Rādīt savienojuma statistiku",
|
||||
"show_non_member_tiles": "Rādīt vietu medijiem no ne-dalībniekiem",
|
||||
"url_params": "URL parametri",
|
||||
"use_new_membership_manager": "Izmantojiet jauno zvana MembershipManager versiju"
|
||||
},
|
||||
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
|
||||
"error": {
|
||||
"call_is_not_supported": "Zvans netiek atbalstīts",
|
||||
"call_not_found": "Zvans nav atrasts",
|
||||
"call_not_found_description": "<0>Šķiet, ka šī saite nepieder nevienam esošam zvaniem. Pārbaudiet, vai jums ir pareizā saite, vai <1> izveidojiet jaunu</1>. </0>",
|
||||
"connection_lost": "Savienojums zaudēts",
|
||||
"connection_lost_description": "Jūs tikāt atvienots no zvana.",
|
||||
"e2ee_unsupported": "Nesaderīgs pārlūks",
|
||||
"e2ee_unsupported_description": "Jūsu tīmekļa pārlūkprogramma neatbalsta encrypted zvanus. Atbalstītās pārlūkprogrammas ir Chrome, Safari un Firefox 117+.",
|
||||
"generic": "Kaut kas nogāja greizi",
|
||||
"generic_description": "Atkļūdošanas žurnālu iesniegšana palīdzēs mums izsekot problēmu.",
|
||||
"insufficient_capacity": "Nepietiekama jauda",
|
||||
"insufficient_capacity_description": "Serveris ir sasniedzis maksimālo ietilpību, un jūs šobrīd nevarat pievienoties zvanam. Mēģiniet vēlreiz vēlāk vai sazinieties ar servera administratoru, ja problēma joprojām pastāv.",
|
||||
"matrix_rtc_focus_missing": "Serveris nav konfigurēts darbam ar{{brand}}. Lūdzu, sazinieties ar sava servera administratoru (Domēns: {{domain}}, Kļūdas kods: {{ errorCode }}).",
|
||||
"open_elsewhere": "Atvērts citā cilnē",
|
||||
"open_elsewhere_description": "{{brand}} ir atvērts citā cilnē. Ja tas neizklausās pareizi, mēģiniet atkārtoti ielādēt lapu.",
|
||||
"unexpected_ec_error": "Negaidīta kļūda (<0>kļūdas kods: </0> <1> {{ errorCode }}</1>). Lūdzu, sazinieties ar servera administratoru."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Jums ir liegta ieeja šajā istabā.",
|
||||
"banned_heading": "Aizliegts",
|
||||
"call_ended_body": "Jūs esat noņemts no zvana.",
|
||||
"call_ended_heading": "Zvans beidzies",
|
||||
"knock_reject_body": "Jūsu pieteikums pievienoties tika noraidīts.",
|
||||
"knock_reject_heading": "Piekļuve liegta",
|
||||
"reason": "Iemesls: {{reason}}"
|
||||
},
|
||||
"hangup_button_label": "Beigt zvanu",
|
||||
"header_label": "Element Call sākums",
|
||||
"header_participants_label": "Dalībnieki",
|
||||
"invite_modal": {
|
||||
"link_copied_toast": "Saite nokopēta",
|
||||
"title": "Uzaicināt uz šo zvanu"
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Jā, pievienoties zvanam",
|
||||
"text": "Šis zvans jau pastāv. Vai vēlies pievienoties?",
|
||||
"title": "Pievienoties esošam zvanam?"
|
||||
},
|
||||
"layout_grid_label": "Režģis",
|
||||
"layout_spotlight_label": "Starmešu gaisma",
|
||||
"lobby": {
|
||||
"join_button": "Pievienoties zvanam"
|
||||
"ask_to_join": "Pieprasīt pievienoties zvanam",
|
||||
"join_as_guest": "Pievienojies kā viesis",
|
||||
"join_button": "Pievienoties zvanam",
|
||||
"leave_button": "Atpakaļ uz jaunākajiem",
|
||||
"waiting_for_invite": "Pieprasījums nosūtīts! Gaida atļauju pievienoties..."
|
||||
},
|
||||
"log_in": "Pieslēgties",
|
||||
"logging_in": "Piesakās…",
|
||||
"login_auth_links": "<0>Izveidot kontu</0> vai <2>Piekļūt kā viesim</2>",
|
||||
"login_auth_links_prompt": "Vēl neesi reģistrējies?",
|
||||
"login_subheading": "Lai turpinātu uz Element",
|
||||
"login_title": "Pieteikties",
|
||||
"microphone_off": "Mikrofons izslēgts",
|
||||
"microphone_on": "Mikrofons ieslēgts",
|
||||
"mute_microphone_button_label": "Izslēgt mikrofonu",
|
||||
"participant_count_zero": "{{count, number}}",
|
||||
"participant_count_one": "{{count, number}}",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "QR kods",
|
||||
"rageshake_button_error_caption": "Atkārtoti mēģināt žurnāla ierakstu nosūtīšanu",
|
||||
"rageshake_request_modal": {
|
||||
"body": "Citam lietotājam šajā zvanā ir sarežģījumi. Lai labāk atklātu šīs nepilnības, mēs gribētu iegūt atkļūdošanas žurnālu.",
|
||||
@@ -59,28 +147,76 @@
|
||||
"rageshake_sent": "Paldies!",
|
||||
"recaptcha_dismissed": "ReCaptcha atmesta",
|
||||
"recaptcha_not_loaded": "ReCaptcha nav ielādēta",
|
||||
"recaptcha_ssla_caption": "Šo vietni aizsargā ReCAPTCHA un tiek piemēroti Google <2>konfidencialitātes politika</2> un <6>Pakalpojuma noteikumi</6>.<9></9>Noklikšķinot uz \"Reģistrēties\", jūs piekrītat mūsu <12>Programmatūras un pakalpojumu licences līgumam (SSLA)</12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Parolēm ir jāsakrīt",
|
||||
"registering": "Reģistrē…"
|
||||
},
|
||||
"register_auth_links": "<0>Jau ir konts?</0><1><0>Pieteikties</0> vai <2>Piekļūt kā viesim</2></1>",
|
||||
"register_confirm_password_label": "Apstiprināt paroli",
|
||||
"register_heading": "Izveido kontu",
|
||||
"return_home_button": "Atgriezties sākuma ekrānā",
|
||||
"room_auth_view_continue_button": "Turpināt",
|
||||
"room_auth_view_ssla_caption": "Noklikšķinot uz “Pievienoties zvanam tūlīt”, jūs piekrītat mūsu <2> programmatūras un pakalpojumu licences līgumam (SSLA) </2>",
|
||||
"screenshare_button_label": "Kopīgot ekrānu",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Pielāgojiet skaļumu, kurā tiek atskaņotas reakcijas un paceltas rokas skaņas.",
|
||||
"effect_volume_label": "Skaņas efektu skaļums"
|
||||
},
|
||||
"developer_tab_title": "Izstrādātājs",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Noklusējums",
|
||||
"default_named": "Noklusējums <2> ({{name}} )</2>",
|
||||
"microphone": "Mikrofons",
|
||||
"microphone_numbered": "Mikrofons {{n}}",
|
||||
"speaker": "Skaļrunis",
|
||||
"speaker_numbered": "Skaļrunis {{n}}"
|
||||
},
|
||||
"feedback_tab_body": "Ja tiek piedzīvoti sarežģījumi vai vienkārši ir vēlme sniegt kādu atsauksmi, lūgums zemāk nosūtīt mums īsu aprakstu.",
|
||||
"feedback_tab_description_label": "Tava atsauksme",
|
||||
"feedback_tab_h4": "Iesniegt atsauksmi",
|
||||
"feedback_tab_send_logs_label": "Iekļaut atkļūdošanas žurnāla ierakstus",
|
||||
"feedback_tab_thank_you": "Paldies, mēs saņēmām atsauksmi!",
|
||||
"feedback_tab_title": "Atsauksmes",
|
||||
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās."
|
||||
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Izstrādātāja režīms",
|
||||
"developer_mode_label_description": "Iespējot izstrādātāja režīmu un rādīt cilni izstrādātāja iestatījumi.",
|
||||
"introduction": "Šeit varat konfigurēt papildu opcijas, lai uzlabotu pieredzi.",
|
||||
"reactions_play_sound_description": "Atskaņojiet skaņas efektu, kad kāds sūta reakciju uz zvanu.",
|
||||
"reactions_play_sound_label": "Atskaņojiet reakcijas skaņas",
|
||||
"reactions_show_description": "Rādīt animāciju, kad kāds nosūta reakciju.",
|
||||
"reactions_show_label": "Rādīt reakcijas",
|
||||
"show_hand_raised_timer_description": "Rādīt taimeri, kad dalībnieks paceļ roku",
|
||||
"show_hand_raised_timer_label": "Rādīt rokas pacelšanas ilgumu"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_zero": "{{count}} zvaigznes",
|
||||
"star_rating_input_label_one": "{{count}} zvaigzne",
|
||||
"star_rating_input_label_other": "{{count}} zvaigznes",
|
||||
"start_new_call": "Sākt jaunu zvanu",
|
||||
"start_video_button_label": "Sākt video",
|
||||
"stop_screenshare_button_label": "Kopīgo ekrānu",
|
||||
"stop_video_button_label": "Apturēt video",
|
||||
"submitting": "Iesniedz…",
|
||||
"switch_camera": "Pārslēgt kameru",
|
||||
"unauthenticated_view_body": "Vēl neesi reģistrējies? <2>Izveidot kontu</2>",
|
||||
"unauthenticated_view_login_button": "Pieteikties kontā",
|
||||
"version": "Versija: {{version}}"
|
||||
"unauthenticated_view_ssla_caption": "Noklikšķinot uz \"Aiziet\", jūs piekrītat mūsu <2>Programmatūras un pakalpojumu licences līgumam (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Ieslēgt mikrofonu",
|
||||
"version": "{{productName}} versija: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Vienmēr rādīt",
|
||||
"camera_starting": "Video ielāde...",
|
||||
"change_fit_contain": "Pielāgot rāmim",
|
||||
"collapse": "Sakļaut",
|
||||
"expand": "Izvērst",
|
||||
"mute_for_me": "Klusums man",
|
||||
"muted_for_me": "Man izslēgts",
|
||||
"volume": "Skaļums",
|
||||
"waiting_for_media": "Gaida medijus..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "Închide",
|
||||
"copy_link": "Copiază linkul",
|
||||
"copy_link": "Copiaţi linkul",
|
||||
"edit": "Editare",
|
||||
"go": "Du-te",
|
||||
"invite": "Invită",
|
||||
"lower_hand": "Mâna inferioară",
|
||||
"no": "No",
|
||||
"no": "Nu",
|
||||
"pick_reaction": "Alegeți reacția",
|
||||
"raise_hand": "Ridicați mâna",
|
||||
"register": "Inregistrare",
|
||||
"register": "Creaţi un cont",
|
||||
"remove": "elimina",
|
||||
"show_less": "Arată mai puțin",
|
||||
"show_more": "Arată mai mult",
|
||||
"sign_in": "Autentificare",
|
||||
"sign_out": "Sign out",
|
||||
"sign_out": "Deconecta-ţi-vă",
|
||||
"submit": "Trimiteți",
|
||||
"upload_file": "Încărcați fișierul"
|
||||
},
|
||||
@@ -26,27 +26,27 @@
|
||||
"continue_in_browser": "Continuați în browser",
|
||||
"open_in_app": "Deschideți în aplicație",
|
||||
"text": "Sunteți gata să vă alăturați?",
|
||||
"title": "Selectați aplicația"
|
||||
"title": "Selectați o aplicație"
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Creează cont",
|
||||
"create_account_button": "Creaţi un cont",
|
||||
"create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? </0><1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare </1>",
|
||||
"feedback_done": "<0>Vă mulțumim pentru feedback! </0>",
|
||||
"feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. </0>",
|
||||
"headline": "{{displayName}}, apelul tău s-a încheiat.",
|
||||
"not_now_button": "Nu acum, reveniți la ecranul de pornire",
|
||||
"reconnect_button": "Reconecta",
|
||||
"survey_prompt": "Cum a mers?"
|
||||
"not_now_button": "Nu acum, reveniți la ecranul principal",
|
||||
"reconnect_button": "Reconectaţi-vă",
|
||||
"survey_prompt": "Cum a fost?"
|
||||
},
|
||||
"call_name": "Numele apelului",
|
||||
"common": {
|
||||
"analytics": "Analiză",
|
||||
"audio": "Audio",
|
||||
"avatar": "avatar",
|
||||
"avatar": "Imaginea de profil",
|
||||
"back": "Înapoi",
|
||||
"display_name": "Nume afișat",
|
||||
"encrypted": "Criptat",
|
||||
"home": "Acasa",
|
||||
"home": "Acasă",
|
||||
"loading": "Se încarcă...",
|
||||
"next": "Urmator\n",
|
||||
"options": "Opțiuni",
|
||||
@@ -55,19 +55,43 @@
|
||||
"profile": "Profil",
|
||||
"reaction": "Reacție",
|
||||
"reactions": "Reacții",
|
||||
"settings": "Settings",
|
||||
"settings": "Setări",
|
||||
"unencrypted": "Nu este criptat",
|
||||
"username": "Nume utilizator",
|
||||
"video": "Videoclip"
|
||||
"username": "Numele utilizatorului",
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"crypto_version": "Versiunea Crypto: {{version}}",
|
||||
"debug_tile_layout_label": "Depanaţi aranjamentul cartonaşelor",
|
||||
"device_id": "ID-ul dispozitivului: {{id}}",
|
||||
"duplicate_tiles_label": "Numărul de exemplare suplimentare de cartonașe per participant",
|
||||
"environment_variables": "Variabile de mediu",
|
||||
"hostname": "Numele gazdei: {{hostname}}",
|
||||
"matrix_id": "ID-ul matricei: {{id}}"
|
||||
"matrix_id": "ID-ul matricei: {{id}}",
|
||||
"show_connection_stats": "Afişaţi informaţii cu privire la starea conexiunii",
|
||||
"show_non_member_tiles": "Afişaţi pictograme pentru fluxul media care nu aparţine participanţilor apelului",
|
||||
"url_params": "Parametrii linkului",
|
||||
"use_new_membership_manager": "Folosiţi noua versiune de administrator pentru participanţi ai apelului",
|
||||
"use_to_device_key_transport": "Folosiţi metoda de transport direct către dispozitiv. Aceasta va reveni la transportul prin intermediul evenimentelor din cameră doar dacă un alt participant la apel recurge la acel mod de transport mai întâi."
|
||||
},
|
||||
"disconnected_banner": "Conexiunea către server s-a încheiat abrupt",
|
||||
"error": {
|
||||
"call_is_not_supported": "Acest tip de apel nu este suportat",
|
||||
"call_not_found": "Apelul nu a fost găsit",
|
||||
"call_not_found_description": "<0>Acel link nu pare să aparţină unui apel existent. Verificaţi dacă aţi introdus linkul corect, sau <1>creaţi unul nou</1>.</0>",
|
||||
"connection_lost": "Conexiunea s-a pierdut",
|
||||
"connection_lost_description": "Aţi fost deconectat/ă de la apel",
|
||||
"e2ee_unsupported": "Navigatorul dumneavoastră este incompatibil cu criptarea integrală",
|
||||
"e2ee_unsupported_description": "Navigatorul/browserul dumneavoastră web nu suportă apeluri în conversaţii cu criptare integrală. Printre navigatoarele suportate, se numără Chrome, Safari şi Firefox 117+.",
|
||||
"generic": "A apărut o eroare neaşteptată",
|
||||
"generic_description": "Dacă ne trimiteţi jurnalele de depanare generate de aplicaţie, ne puteţi ajuta să rezolvăm problema.",
|
||||
"insufficient_capacity": "Capacitate insuficientă",
|
||||
"insufficient_capacity_description": "Serverul a ajuns la capacitatea maximă și nu vă puteți alătura apelului în acest moment. Încercați din nou in câteva minute, sau contactați administratorul serverului dumneavoastră dacă problema persistă.",
|
||||
"matrix_rtc_focus_missing": "Serverul nu este configurat să funcționeze cu{{brand}}. Vă rugăm să contactați administratorul serverului dumneavoastră pentru a raporta o eroare în configurare. Detalii: Domeniu: {{domain}}. Cod de eroare: {{ errorCode }}.",
|
||||
"open_elsewhere": "Aplicaţia este deschisă intr-o altă pagină",
|
||||
"open_elsewhere_description": "{{brand}} a fost deschis într-o altă pagină. Dacă credeți că acest mesaj a fost emis in eroare, încercați să reîncărcați pagina.",
|
||||
"unexpected_ec_error": "A apărut o eroare neașteptată (Cod de <0> eroare: </0> <1> {{ errorCode }}</1>). Vă rugăm să contactați administratorul serverului dumneavoastră."
|
||||
},
|
||||
"disconnected_banner": "Conectivitatea la server a fost pierdută.",
|
||||
"group_call_loader": {
|
||||
"banned_body": "Ai fost interzis să ieși din cameră.",
|
||||
"banned_heading": "Interzis",
|
||||
@@ -135,6 +159,10 @@
|
||||
"effect_volume_label": "Volumul efectului sonor"
|
||||
},
|
||||
"developer_tab_title": "dezvoltator",
|
||||
"devices": {
|
||||
"microphone": "Microfon",
|
||||
"speaker": "Difuzor"
|
||||
},
|
||||
"feedback_tab_body": "Dacă întâmpinați probleme sau pur și simplu doriți să oferiți feedback, vă rugăm să ne trimiteți o scurtă descriere mai jos.",
|
||||
"feedback_tab_description_label": "Feedback-ul tău",
|
||||
"feedback_tab_h4": "Trimiteți Feedback",
|
||||
@@ -143,6 +171,8 @@
|
||||
"feedback_tab_title": "Feedback",
|
||||
"opt_in_description": "<0></0><1></1>Puteți retrage consimțământul debifând această casetă. Dacă sunteți în prezent la un apel, această setare va intra în vigoare la sfârșitul apelului.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Modul dezvoltator",
|
||||
"developer_mode_label_description": "Activați modul dezvoltator și afișați pagina specifică setărilor pentru dezvoltatori",
|
||||
"introduction": "Aici puteți configura opțiuni suplimentare pentru o experiență îmbunătățită.",
|
||||
"reactions_play_sound_description": "Redați un efect sonor atunci când cineva trimite o reacție la un apel.",
|
||||
"reactions_play_sound_label": "Redați sunete de reacție",
|
||||
@@ -164,11 +194,13 @@
|
||||
"version": "{{productName}}Versiune: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Arată întotdeauna",
|
||||
"camera_starting": "Se încarcă fluxul video...",
|
||||
"change_fit_contain": "Se potrivește cadrului",
|
||||
"collapse": "colaps",
|
||||
"expand": "Extindeți",
|
||||
"mute_for_me": "Mute pentru mine",
|
||||
"muted_for_me": "Dezactivat pentru mine",
|
||||
"volume": "VOLUM"
|
||||
"volume": "VOLUM",
|
||||
"waiting_for_media": "Flux multimedia în aşteptare"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"submit": "Отправить",
|
||||
"upload_file": "Загрузить файл"
|
||||
},
|
||||
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <5> Политике использования файлов cookie</5>.",
|
||||
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Дополнительную информацию о том, какие данные мы отслеживаем, можно найти в нашей <2> Политике конфиденциальности </2> и Политике <6> использования файлов cookie</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Продолжить в браузере",
|
||||
"open_in_app": "Открыть в приложении",
|
||||
@@ -65,13 +65,16 @@
|
||||
"debug_tile_layout_label": "Отладка расположения плиток",
|
||||
"device_id": "Идентификатор устройства: {{id}}",
|
||||
"duplicate_tiles_label": "Количество дополнительных копий плиток на участника",
|
||||
"environment_variables": "Переменные окружения",
|
||||
"hostname": "Имя хоста: {{hostname}}",
|
||||
"livekit_server_info": "Информация о сервере LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"show_connection_stats": "Показать статистику подключений",
|
||||
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
|
||||
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager"
|
||||
"url_params": "Параметры URL-адреса",
|
||||
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager",
|
||||
"use_to_device_key_transport": "Используйте для передачи ключей устройства. Это позволит вернуться к передаче ключей комнаты, когда другой участник вызова отправит ключ комнаты"
|
||||
},
|
||||
"disconnected_banner": "Связь с сервером была потеряна.",
|
||||
"error": {
|
||||
@@ -98,7 +101,7 @@
|
||||
"call_ended_heading": "Вызов завершен",
|
||||
"knock_reject_body": "Участники комнаты отклонили ваш запрос на присоединение.",
|
||||
"knock_reject_heading": "Не разрешено присоединиться",
|
||||
"reason": "Причина"
|
||||
"reason": "Причина: {{reason}}"
|
||||
},
|
||||
"hangup_button_label": "Завершить звонок",
|
||||
"header_label": "Главная Element Call",
|
||||
@@ -145,6 +148,7 @@
|
||||
"rageshake_sent": "Спасибо!",
|
||||
"recaptcha_dismissed": "Проверка не пройдена",
|
||||
"recaptcha_not_loaded": "Невозможно начать проверку",
|
||||
"recaptcha_ssla_caption": "Этот сайт защищен reCAPTCHA, и к нему применяются <2> Политика конфиденциальности </2> и <6> Условия обслуживания Google. </6> <9></9>Нажимая «Зарегистрироваться», вы соглашаетесь с нашим лицензионным соглашением на <12> программное обеспечение и услуги (SSLA) </12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Пароли должны совпадать",
|
||||
"registering": "Регистрация…"
|
||||
@@ -154,12 +158,16 @@
|
||||
"register_heading": "Создать учетную запись",
|
||||
"return_home_button": "Вернуться в Начало",
|
||||
"room_auth_view_continue_button": "Продолжить",
|
||||
"room_auth_view_ssla_caption": "Нажимая кнопку \"Присоединиться к звонку\", вы соглашаетесь с нашим <2>Лицензионное соглашение на программное обеспечение и услуги (SSLA)</2>",
|
||||
"screenshare_button_label": "Поделиться экраном",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Отрегулируйте громкость воспроизведения реакций и эффектов поднятия руки.",
|
||||
"effect_volume_label": "Громкость звукового эффекта"
|
||||
},
|
||||
"background_blur_header": "Фон",
|
||||
"background_blur_label": "Размытие фона видео",
|
||||
"blur_not_supported_by_browser": "(Размытие фона не поддерживается этим устройством.)",
|
||||
"developer_tab_title": "Разработчику",
|
||||
"devices": {
|
||||
"camera": "Камера",
|
||||
@@ -190,8 +198,9 @@
|
||||
"show_hand_raised_timer_label": "Показать продолжительность поднятия руки"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} отмечен",
|
||||
"star_rating_input_label_other": "{{count}} отмеченных",
|
||||
"star_rating_input_label_one": "{{count}} звезда",
|
||||
"star_rating_input_label_few": "{{count}} звезды",
|
||||
"star_rating_input_label_many": "{{count}} звезд",
|
||||
"start_new_call": "Начать новый звонок",
|
||||
"start_video_button_label": "Начать видео",
|
||||
"stop_screenshare_button_label": "Демонстрация экрана",
|
||||
@@ -200,8 +209,9 @@
|
||||
"switch_camera": "Переключить камеру",
|
||||
"unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
|
||||
"unauthenticated_view_login_button": "Войдите в свой аккаунт",
|
||||
"unauthenticated_view_ssla_caption": "Нажимая «Перейти», вы соглашаетесь с нашим лицензионным соглашением на <2> программное обеспечение и услуги (SSLA) </2>",
|
||||
"unmute_microphone_button_label": "Включить микрофон",
|
||||
"version": "Версия: {{version}}",
|
||||
"version": "{{productName}} версия: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Показывать всегда",
|
||||
"camera_starting": "Загрузка видео...",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"submit": "Odoslať",
|
||||
"upload_file": "Nahrať súbor"
|
||||
},
|
||||
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
|
||||
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <6>Zásadách používania súborov cookie</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Pokračovať v prehliadači",
|
||||
"open_in_app": "Otvoriť v aplikácii",
|
||||
@@ -65,13 +65,17 @@
|
||||
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
|
||||
"device_id": "ID zariadenia: {{id}}",
|
||||
"duplicate_tiles_label": "Počet ďalších kópií dlaždíc na účastníka",
|
||||
"environment_variables": "Premenné prostredia",
|
||||
"hostname": "Názov hostiteľa: {{hostname}}",
|
||||
"livekit_server_info": "Informácie o serveri LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Stlmiť všetky zvuky (účastníkov, reakcií, zvuky pripojenia)",
|
||||
"show_connection_stats": "Zobraziť štatistiky pripojenia",
|
||||
"show_non_member_tiles": "Zobraziť dlaždice pre nečlenské médiá",
|
||||
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager"
|
||||
"url_params": "Parametre URL adresy",
|
||||
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager",
|
||||
"use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti"
|
||||
},
|
||||
"disconnected_banner": "Spojenie so serverom sa stratilo.",
|
||||
"error": {
|
||||
@@ -145,6 +149,7 @@
|
||||
"rageshake_sent": "Ďakujeme!",
|
||||
"recaptcha_dismissed": "Recaptcha zamietnutá",
|
||||
"recaptcha_not_loaded": "Recaptcha sa nenačítala",
|
||||
"recaptcha_ssla_caption": "Táto stránka je chránená reCAPTCHA a platia <2>Zásady ochrany osobných údajov</2> a <6>Podmienky služby</6> spoločnosti Google. <9></9>Kliknutím na tlačidlo „Registrovať“ súhlasíte s našou <12>Licenčnou zmluvou o softvéri a službách (SSLA)</12>",
|
||||
"register": {
|
||||
"passwords_must_match": "Heslá sa musia zhodovať",
|
||||
"registering": "Registrácia…"
|
||||
@@ -154,12 +159,16 @@
|
||||
"register_heading": "Vytvorte si účet",
|
||||
"return_home_button": "Návrat na domovskú obrazovku",
|
||||
"room_auth_view_continue_button": "Pokračovať",
|
||||
"room_auth_view_ssla_caption": "Kliknutím na „Pripojiť sa k hovoru teraz“ súhlasíte s našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
|
||||
"screenshare_button_label": "Zdieľať obrazovku",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Upraviť hlasitosť, pri ktorej sa prehrávajú reakcie a efekty zdvihnutia ruky.",
|
||||
"effect_volume_label": "Hlasitosť zvukového efektu"
|
||||
},
|
||||
"background_blur_header": "Pozadie",
|
||||
"background_blur_label": "Rozmazať pozadie videa",
|
||||
"blur_not_supported_by_browser": "(Rozmazanie pozadia nie je podporované týmto zariadením.)",
|
||||
"developer_tab_title": "Vývojár",
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
@@ -191,6 +200,7 @@
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} hviezdička",
|
||||
"star_rating_input_label_few": "{{count}} hviezdičky",
|
||||
"star_rating_input_label_other": "{{count}} hviezdičiek",
|
||||
"start_new_call": "Spustiť nový hovor",
|
||||
"start_video_button_label": "Spustiť video",
|
||||
@@ -200,8 +210,9 @@
|
||||
"switch_camera": "Prepnúť fotoaparát",
|
||||
"unauthenticated_view_body": "Ešte nie ste zaregistrovaný? <2>Vytvorte si účet</2>",
|
||||
"unauthenticated_view_login_button": "Prihláste sa do svojho konta",
|
||||
"unauthenticated_view_ssla_caption": "Kliknutím na tlačidlo „Prejsť“ súhlasítes našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu",
|
||||
"version": "Verzia: {{version}}",
|
||||
"version": "Verzia {{productName}}: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Vždy zobraziť",
|
||||
"camera_starting": "Načítavanie videa...",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"show_connection_stats": "Visa anslutningsstatistik",
|
||||
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
|
||||
"url_params": "URL-parametrar",
|
||||
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager"
|
||||
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager",
|
||||
"use_to_device_key_transport": "Använd \"till enhet\"-nyckeltransport. Detta kommer att falla tillbaka till rumsnyckeltransport om en annan samtalsmedlem skickar en rumsnyckel."
|
||||
},
|
||||
"disconnected_banner": "Anslutningen till servern har brutits.",
|
||||
"error": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"submit": "Надіслати",
|
||||
"upload_file": "Завантажити файл"
|
||||
},
|
||||
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
|
||||
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <6>Політиці про файли cookie</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Продовжити у браузері",
|
||||
"open_in_app": "Відкрити у застосунку",
|
||||
@@ -194,8 +194,9 @@
|
||||
"show_hand_raised_timer_label": "Показувати тривалість підняття руки"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} зірок",
|
||||
"star_rating_input_label_other": "{{count}} зірок",
|
||||
"star_rating_input_label_one": "{{count}} зірка",
|
||||
"star_rating_input_label_few": "{{count}} зірки",
|
||||
"star_rating_input_label_many": "{{count}} зірок",
|
||||
"start_new_call": "Розпочати новий виклик",
|
||||
"start_video_button_label": "Розпочати відео",
|
||||
"stop_screenshare_button_label": "Презентація екрана",
|
||||
@@ -206,7 +207,7 @@
|
||||
"unauthenticated_view_login_button": "Увійдіть до свого облікового запису",
|
||||
"unauthenticated_view_ssla_caption": "Натискаючи \"Перейти\", ви погоджуєтеся з нашою <2>Ліцензійною угодою на програмне забезпечення та послуги (SSLA) </2>",
|
||||
"unmute_microphone_button_label": "Увімкнути мікрофон",
|
||||
"version": "Версія: {{version}}",
|
||||
"version": "{{productName}} версія: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Показувати завжди",
|
||||
"camera_starting": "Завантаження відео...",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "关闭",
|
||||
"copy_link": "复制链接",
|
||||
"go": "开始",
|
||||
"invite": "邀请",
|
||||
"no": "否",
|
||||
"register": "注册",
|
||||
"remove": "移除",
|
||||
@@ -31,12 +33,14 @@
|
||||
},
|
||||
"call_name": "通话名称",
|
||||
"common": {
|
||||
"analytics": "分析",
|
||||
"audio": "音频",
|
||||
"avatar": "头像",
|
||||
"display_name": "显示名称",
|
||||
"encrypted": "已加密",
|
||||
"home": "主页",
|
||||
"loading": "加载中……",
|
||||
"options": "选项",
|
||||
"password": "密码",
|
||||
"profile": "个人信息",
|
||||
"settings": "设置",
|
||||
@@ -45,8 +49,20 @@
|
||||
"video": "视频"
|
||||
},
|
||||
"disconnected_banner": "与服务器的连接中断。",
|
||||
"group_call_loader": {
|
||||
"banned_body": "你已被房间封禁",
|
||||
"banned_heading": "已被封禁",
|
||||
"call_ended_body": "你已被移出通话",
|
||||
"call_ended_heading": "通话结束",
|
||||
"knock_reject_body": "房间成员拒绝了你的加入请求"
|
||||
},
|
||||
"hangup_button_label": "通话结束",
|
||||
"header_label": "Element Call主页",
|
||||
"header_participants_label": "参与者",
|
||||
"invite_modal": {
|
||||
"link_copied_toast": "链接已复制到剪贴板",
|
||||
"title": "邀请参加此次通话"
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "是,加入通话",
|
||||
"text": "该通话已存在,你想加入吗?",
|
||||
@@ -58,12 +74,16 @@
|
||||
"join_button": "加入通话",
|
||||
"leave_button": "返回最近通话"
|
||||
},
|
||||
"log_in": "登录",
|
||||
"logging_in": "登录中……",
|
||||
"login_auth_links": "<0>创建账户</0> Or <2>以访客身份继续</2>",
|
||||
"login_auth_links_prompt": "还未注册?",
|
||||
"login_subheading": "继续使用 Element",
|
||||
"login_title": "登录",
|
||||
"microphone_off": "麦克风关闭",
|
||||
"microphone_on": "麦克风开启",
|
||||
"mute_microphone_button_label": "静音麦克风",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"rageshake_button_error_caption": "重传日志",
|
||||
"rageshake_request_modal": {
|
||||
"body": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。",
|
||||
@@ -81,6 +101,7 @@
|
||||
},
|
||||
"register_auth_links": "<0>已有账户?</0><1><0>登录</0> Or <2>以访客身份继续</2></1>",
|
||||
"register_confirm_password_label": "确认密码",
|
||||
"register_heading": "创建您的账户",
|
||||
"return_home_button": "返回主页",
|
||||
"screenshare_button_label": "屏幕共享",
|
||||
"settings": {
|
||||
@@ -103,5 +124,10 @@
|
||||
"unauthenticated_view_body": "还没有注册? <2>创建账户<2>",
|
||||
"unauthenticated_view_login_button": "登录你的账户",
|
||||
"unmute_microphone_button_label": "取消麦克风静音",
|
||||
"version": "版本:{{version}}"
|
||||
"version": "版本:{{version}}",
|
||||
"video_tile": {
|
||||
"change_fit_contain": "贴合框架",
|
||||
"mute_for_me": "为我静音",
|
||||
"volume": "音量"
|
||||
}
|
||||
}
|
||||
|
||||
47
package.json
47
package.json
@@ -8,7 +8,11 @@
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"build": "yarn build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full:production": "yarn build:full",
|
||||
"build:full:development": "yarn build:full --mode development",
|
||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "yarn build:embedded",
|
||||
"build:embedded:development": "yarn build:embedded --mode development",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
@@ -22,6 +26,7 @@
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
|
||||
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
|
||||
"test:playwright": "playwright test",
|
||||
"test:playwright:open": "yarn test:playwright --ui",
|
||||
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
|
||||
@@ -39,21 +44,21 @@
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.33.0",
|
||||
"@livekit/protocol": "^1.38.0",
|
||||
"@livekit/track-processors": "^0.5.5",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/core": "^1.25.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||
"@opentelemetry/core": "^2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.201.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
"@react-spring/web": "^9.4.4",
|
||||
"@react-spring/web": "^10.0.0",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/vite-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
@@ -62,32 +67,34 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^3.0.0",
|
||||
"@vector-im/compound-web": "^7.2.0",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.12.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-deprecate": "^0.8.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-matrix-org": "^2.0.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-rxjs": "^5.0.3",
|
||||
@@ -98,11 +105,11 @@
|
||||
"i18next-parser": "^9.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.11.3",
|
||||
"livekit-client": "^2.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#19b1b901f575755d29d1fe03ca48cbf7c1cae05c",
|
||||
"matrix-widget-api": "1.11.0",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
|
||||
"matrix-widget-api": "^1.13.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
@@ -111,11 +118,10 @@
|
||||
"posthog-js": "1.160.3",
|
||||
"prettier": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.42.1",
|
||||
@@ -124,7 +130,7 @@
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-generate-file": "^0.2.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
@@ -132,8 +138,7 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"matrix-widget-api": "1.11.0"
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -25,7 +29,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "https://localhost:3000",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
@@ -73,9 +77,13 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "yarn dev",
|
||||
url: "https://localhost:3000",
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ test("Sign up a new account, then login, then logout", async ({ browser }) => {
|
||||
|
||||
// logout
|
||||
await returningUserPage.getByTestId("usermenu_open").click();
|
||||
await returningUserPage.locator('[data-test-id="usermenu_logout"]').click();
|
||||
await returningUserPage.locator('[data-testid="usermenu_logout"]').click();
|
||||
|
||||
await expect(
|
||||
returningUserPage.getByRole("link", { name: "Log In" }),
|
||||
|
||||
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Page, test, expect, type JSHandle } from "@playwright/test";
|
||||
import {
|
||||
type Browser,
|
||||
type Page,
|
||||
test,
|
||||
expect,
|
||||
type JSHandle,
|
||||
} from "@playwright/test";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -30,8 +36,8 @@ const PASSWORD = "foobarbaz1!";
|
||||
const CONFIG_JSON = {
|
||||
default_server_config: {
|
||||
"m.homeserver": {
|
||||
base_url: "http://synapse.localhost:8008",
|
||||
server_name: "synapse.localhost",
|
||||
base_url: "https://synapse.m.localhost",
|
||||
server_name: "synapse.m.localhost",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -63,15 +69,72 @@ const CONFIG_JSON = {
|
||||
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
||||
* @param page
|
||||
*/
|
||||
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
||||
? async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"http://localhost:8080/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
: async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a new user and returns page, clientHandle and mxId.
|
||||
*/
|
||||
async function registerUser(
|
||||
browser: Browser,
|
||||
username: string,
|
||||
): Promise<{ page: Page; clientHandle: JSHandle<MatrixClient>; mxId: string }> {
|
||||
const userContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
await page.goto("http://localhost:8081/#/welcome");
|
||||
await page.getByRole("link", { name: "Create Account" }).click();
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
||||
try {
|
||||
await expect(continueButton).toBeVisible({ timeout: 5000 });
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await continueButton.click();
|
||||
} catch {
|
||||
// continueButton not visible, continue as normal
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||
).toBeVisible();
|
||||
await setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const mxId = (await clientHandle.evaluate(
|
||||
(cli: MatrixClient) => cli.getUserId(),
|
||||
clientHandle,
|
||||
))!;
|
||||
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
export const widgetTest = test.extend<MyFixtures>({
|
||||
@@ -83,61 +146,17 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
const userA = `brooks_${Date.now()}`;
|
||||
const userB = `whistler_${Date.now()}`;
|
||||
|
||||
const user1Context = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const ewPage1 = await user1Context.newPage();
|
||||
// Register the first user
|
||||
await ewPage1.goto("http://localhost:8081/#/welcome");
|
||||
await ewPage1.getByRole("link", { name: "Create Account" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Username" }).fill(userA);
|
||||
await ewPage1
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await ewPage1.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await ewPage1
|
||||
.getByRole("textbox", { name: "Confirm password" })
|
||||
.fill(PASSWORD);
|
||||
await ewPage1.getByRole("button", { name: "Register" }).click();
|
||||
await expect(
|
||||
ewPage1.getByRole("heading", { name: `Welcome ${userA}` }),
|
||||
).toBeVisible();
|
||||
await setDevToolElementCallDevUrl(ewPage1);
|
||||
|
||||
const brooksClientHandle = await ewPage1.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const brooksMxId = (await brooksClientHandle.evaluate((cli) => {
|
||||
return cli.getUserId();
|
||||
}, brooksClientHandle))!;
|
||||
|
||||
const user2Context = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const ewPage2 = await user2Context.newPage();
|
||||
// Register the second user
|
||||
await ewPage2.goto("http://localhost:8081/#/welcome");
|
||||
await ewPage2.getByRole("link", { name: "Create Account" }).click();
|
||||
await ewPage2.getByRole("textbox", { name: "Username" }).fill(userB);
|
||||
await ewPage2
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await ewPage2.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await ewPage2
|
||||
.getByRole("textbox", { name: "Confirm password" })
|
||||
.fill(PASSWORD);
|
||||
await ewPage2.getByRole("button", { name: "Register" }).click();
|
||||
await expect(
|
||||
ewPage2.getByRole("heading", { name: `Welcome ${userB}` }),
|
||||
).toBeVisible();
|
||||
await setDevToolElementCallDevUrl(ewPage2);
|
||||
|
||||
const whistlerClientHandle = await ewPage2.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const whistlerMxId = (await whistlerClientHandle.evaluate((cli) => {
|
||||
return cli.getUserId();
|
||||
}, whistlerClientHandle))!;
|
||||
// Register users
|
||||
const {
|
||||
page: ewPage1,
|
||||
clientHandle: brooksClientHandle,
|
||||
mxId: brooksMxId,
|
||||
} = await registerUser(browser, userA);
|
||||
const {
|
||||
page: ewPage2,
|
||||
clientHandle: whistlerClientHandle,
|
||||
mxId: whistlerMxId,
|
||||
} = await registerUser(browser, userB);
|
||||
|
||||
// Invite the second user
|
||||
await ewPage1.getByRole("button", { name: "Add room" }).click();
|
||||
|
||||
@@ -9,11 +9,14 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
|
||||
// Skip test, including Fixtures
|
||||
widgetTest.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget",
|
||||
);
|
||||
|
||||
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget",
|
||||
);
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
"ignoreDeps": ["posthog-js"],
|
||||
"ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"],
|
||||
"vulnerabilityAlerts": {
|
||||
"schedule": ["at any time"],
|
||||
"prHourlyLimit": 0,
|
||||
|
||||
10
scripts/playwright-webserver-command.sh
Executable file
10
scripts/playwright-webserver-command.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
if [ -n "$USE_DOCKER" ]; then
|
||||
set -ex
|
||||
yarn build
|
||||
docker build -t element-call:testing .
|
||||
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
|
||||
else
|
||||
cp config/config.devenv.json public/config.json
|
||||
exec yarn dev
|
||||
fi
|
||||
146
src/@types/dom-mediacapture-transform.d.ts
vendored
146
src/@types/dom-mediacapture-transform.d.ts
vendored
@@ -1,146 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// The contents of this file below the line are copied from
|
||||
// @types/dom-mediacapture-transform, which is inlined here into Element Call so
|
||||
// that we can apply the patch to @types/dom-webcodecs found in
|
||||
// ./dom-webcodecs.d.ts, which it depends on.
|
||||
// (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72625)
|
||||
// Once that PR is merged and released, we can remove this file and return to
|
||||
// depending on @types/dom-mediacapture-transform.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// This project is licensed under the MIT license.
|
||||
// Copyrights are respective of each contributor listed at the beginning of each definition file.
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
// In general, these types are only available behind a command line flag or an origin trial in
|
||||
// Chrome 90+.
|
||||
|
||||
// This API depends on WebCodecs.
|
||||
|
||||
// Versioning:
|
||||
// Until the above-mentioned spec is finalized, the major version number is 0. Although not
|
||||
// necessary for version 0, consider incrementing the minor version number for breaking changes.
|
||||
|
||||
// The following modify existing DOM types to allow defining type-safe APIs on audio and video tracks.
|
||||
|
||||
/** Specialize MediaStreamTrack so that we can refer specifically to an audio track. */
|
||||
interface MediaStreamAudioTrack extends MediaStreamTrack {
|
||||
readonly kind: "audio";
|
||||
clone(): MediaStreamAudioTrack;
|
||||
}
|
||||
|
||||
/** Specialize MediaStreamTrack so that we can refer specifically to a video track. */
|
||||
interface MediaStreamVideoTrack extends MediaStreamTrack {
|
||||
readonly kind: "video";
|
||||
clone(): MediaStreamVideoTrack;
|
||||
}
|
||||
|
||||
/** Assert that getAudioTracks and getVideoTracks return the tracks with the appropriate kind. */
|
||||
interface MediaStream {
|
||||
getAudioTracks(): MediaStreamAudioTrack[];
|
||||
getVideoTracks(): MediaStreamVideoTrack[];
|
||||
}
|
||||
|
||||
// The following were originally generated from the spec using
|
||||
// https://github.com/microsoft/TypeScript-DOM-lib-generator, then heavily modified.
|
||||
|
||||
/**
|
||||
* A track sink that is capable of exposing the unencoded frames from the track to a
|
||||
* ReadableStream, and exposes a control channel for signals going in the oppposite direction.
|
||||
*/
|
||||
interface MediaStreamTrackProcessor<T extends AudioData | VideoFrame> {
|
||||
/**
|
||||
* Allows reading the frames flowing through the MediaStreamTrack provided to the constructor.
|
||||
*/
|
||||
readonly readable: ReadableStream<T>;
|
||||
/** Allows sending control signals to the MediaStreamTrack provided to the constructor. */
|
||||
readonly writableControl: WritableStream<MediaStreamTrackSignal>;
|
||||
}
|
||||
|
||||
declare var MediaStreamTrackProcessor: {
|
||||
prototype: MediaStreamTrackProcessor<any>;
|
||||
|
||||
/** Constructor overrides based on the type of track. */
|
||||
new (
|
||||
init: MediaStreamTrackProcessorInit & { track: MediaStreamAudioTrack },
|
||||
): MediaStreamTrackProcessor<AudioData>;
|
||||
new (
|
||||
init: MediaStreamTrackProcessorInit & { track: MediaStreamVideoTrack },
|
||||
): MediaStreamTrackProcessor<VideoFrame>;
|
||||
};
|
||||
|
||||
interface MediaStreamTrackProcessorInit {
|
||||
track: MediaStreamTrack;
|
||||
/**
|
||||
* If media frames are not read from MediaStreamTrackProcessor.readable quickly enough, the
|
||||
* MediaStreamTrackProcessor will internally buffer up to maxBufferSize of the frames produced
|
||||
* by the track. If the internal buffer is full, each time the track produces a new frame, the
|
||||
* oldest frame in the buffer will be dropped and the new frame will be added to the buffer.
|
||||
*/
|
||||
maxBufferSize?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes video frames as input, and emits control signals that result from subsequent processing.
|
||||
*/
|
||||
interface MediaStreamTrackGenerator<T extends AudioData | VideoFrame>
|
||||
extends MediaStreamTrack {
|
||||
/**
|
||||
* Allows writing media frames to the MediaStreamTrackGenerator, which is itself a
|
||||
* MediaStreamTrack. When a frame is written to writable, the frame’s close() method is
|
||||
* automatically invoked, so that its internal resources are no longer accessible from
|
||||
* JavaScript.
|
||||
*/
|
||||
readonly writable: WritableStream<T>;
|
||||
/**
|
||||
* Allows reading control signals sent from any sinks connected to the
|
||||
* MediaStreamTrackGenerator.
|
||||
*/
|
||||
readonly readableControl: ReadableStream<MediaStreamTrackSignal>;
|
||||
}
|
||||
|
||||
type MediaStreamAudioTrackGenerator = MediaStreamTrackGenerator<AudioData> &
|
||||
MediaStreamAudioTrack;
|
||||
type MediaStreamVideoTrackGenerator = MediaStreamTrackGenerator<VideoFrame> &
|
||||
MediaStreamVideoTrack;
|
||||
|
||||
declare var MediaStreamTrackGenerator: {
|
||||
prototype: MediaStreamTrackGenerator<any>;
|
||||
|
||||
/** Constructor overrides based on the type of track. */
|
||||
new (
|
||||
init: MediaStreamTrackGeneratorInit & {
|
||||
kind: "audio";
|
||||
signalTarget?: MediaStreamAudioTrack | undefined;
|
||||
},
|
||||
): MediaStreamAudioTrackGenerator;
|
||||
new (
|
||||
init: MediaStreamTrackGeneratorInit & {
|
||||
kind: "video";
|
||||
signalTarget?: MediaStreamVideoTrack | undefined;
|
||||
},
|
||||
): MediaStreamVideoTrackGenerator;
|
||||
};
|
||||
|
||||
interface MediaStreamTrackGeneratorInit {
|
||||
kind: MediaStreamTrackGeneratorKind;
|
||||
/**
|
||||
* (Optional) track to which the MediaStreamTrackGenerator will automatically forward control
|
||||
* signals. If signalTarget is provided and signalTarget.kind and kind do not match, the
|
||||
* MediaStreamTrackGenerator’s constructor will raise an exception.
|
||||
*/
|
||||
signalTarget?: MediaStreamTrack | undefined;
|
||||
}
|
||||
|
||||
type MediaStreamTrackGeneratorKind = "audio" | "video";
|
||||
|
||||
type MediaStreamTrackSignalType = "request-frame";
|
||||
|
||||
interface MediaStreamTrackSignal {
|
||||
signalType: MediaStreamTrackSignalType;
|
||||
}
|
||||
745
src/@types/dom-webcodecs.d.ts
vendored
745
src/@types/dom-webcodecs.d.ts
vendored
@@ -1,745 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// The contents of this file below the line are copied from
|
||||
// @types/dom-webcodecs, which is inlined here into Element Call so that we can
|
||||
// apply the patch https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72625
|
||||
// which is needed for TypeScript 5.8 compatibility. Once that PR is merged and
|
||||
// released, we can remove this file and return to depending on
|
||||
// @types/dom-webcodecs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// This project is licensed under the MIT license.
|
||||
// Copyrights are respective of each contributor listed at the beginning of each definition file.
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
// Versioning:
|
||||
// Until the WebCodecs spec is finalized, the major version number is 0. I have chosen to use minor
|
||||
// version 1 to denote the API as defined by the IDL files from the Chromium repo at
|
||||
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/webcodecs.
|
||||
// Please use a version number above 0.1 if using the spec at https://w3c.github.io/webcodecs/ as
|
||||
// the source.
|
||||
|
||||
// The declarations in webcodecs.generated.d.ts have been generated using the code in
|
||||
// https://github.com/yume-chan/webcodecs-lib-generator. See
|
||||
// https://github.com/yume-chan/webcodecs-lib-generator/blob/main/README.md for more detail.
|
||||
|
||||
// The following declarations are copied from
|
||||
// https://github.com/microsoft/TypeScript-DOM-lib-generator/blob/a75338e1ea8a958bf08a5745141d2ab8f14ba2ca/baselines/dom.generated.d.ts
|
||||
// and modified to expand the types to include VideoFrame.
|
||||
|
||||
/** Shim for OffscreenCanvas, which was removed in TS 4.4 */
|
||||
interface OffscreenCanvas extends EventTarget {}
|
||||
|
||||
/**
|
||||
* Replaces CanvasImageSource; only applies if WebCodecs is available.
|
||||
*/
|
||||
type CanvasImageSourceWebCodecs =
|
||||
| HTMLOrSVGImageElement
|
||||
| HTMLVideoElement
|
||||
| HTMLCanvasElement
|
||||
| ImageBitmap
|
||||
| OffscreenCanvas
|
||||
| VideoFrame;
|
||||
|
||||
interface CanvasRenderingContext2D {
|
||||
drawImage(image: CanvasImageSourceWebCodecs, dx: number, dy: number): void;
|
||||
drawImage(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dw: number,
|
||||
dh: number,
|
||||
): void;
|
||||
drawImage(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dw: number,
|
||||
dh: number,
|
||||
): void;
|
||||
createPattern(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
repetition: string | null,
|
||||
): CanvasPattern | null;
|
||||
}
|
||||
|
||||
interface OffscreenCanvasRenderingContext2D {
|
||||
drawImage(image: CanvasImageSourceWebCodecs, dx: number, dy: number): void;
|
||||
drawImage(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dw: number,
|
||||
dh: number,
|
||||
): void;
|
||||
drawImage(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dw: number,
|
||||
dh: number,
|
||||
): void;
|
||||
createPattern(
|
||||
image: CanvasImageSourceWebCodecs,
|
||||
repetition: string | null,
|
||||
): CanvasPattern | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces ImageBitmapSource; only applies if WebCodecs is available.
|
||||
*/
|
||||
type ImageBitmapSourceWebCodecs = CanvasImageSourceWebCodecs | Blob | ImageData;
|
||||
|
||||
declare function createImageBitmap(
|
||||
image: ImageBitmapSourceWebCodecs,
|
||||
options?: ImageBitmapOptions,
|
||||
): Promise<ImageBitmap>;
|
||||
declare function createImageBitmap(
|
||||
image: ImageBitmapSourceWebCodecs,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
options?: ImageBitmapOptions,
|
||||
): Promise<ImageBitmap>;
|
||||
|
||||
/**
|
||||
* Replaces TexImageSource; only applies if WebCodecs is available.
|
||||
*/
|
||||
type TexImageSourceWebCodecs =
|
||||
| ImageBitmap
|
||||
| ImageData
|
||||
| HTMLImageElement
|
||||
| HTMLCanvasElement
|
||||
| HTMLVideoElement
|
||||
| OffscreenCanvas
|
||||
| VideoFrame;
|
||||
|
||||
interface WebGLRenderingContextOverloads {
|
||||
texImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
internalformat: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
texSubImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
xoffset: GLint,
|
||||
yoffset: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WebGL2RenderingContextBase {
|
||||
texImage3D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
internalformat: GLint,
|
||||
width: GLsizei,
|
||||
height: GLsizei,
|
||||
depth: GLsizei,
|
||||
border: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
texSubImage3D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
xoffset: GLint,
|
||||
yoffset: GLint,
|
||||
zoffset: GLint,
|
||||
width: GLsizei,
|
||||
height: GLsizei,
|
||||
depth: GLsizei,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WebGL2RenderingContextOverloads {
|
||||
texImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
internalformat: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
texImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
internalformat: GLint,
|
||||
width: GLsizei,
|
||||
height: GLsizei,
|
||||
border: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
texSubImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
xoffset: GLint,
|
||||
yoffset: GLint,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
texSubImage2D(
|
||||
target: GLenum,
|
||||
level: GLint,
|
||||
xoffset: GLint,
|
||||
yoffset: GLint,
|
||||
width: GLsizei,
|
||||
height: GLsizei,
|
||||
format: GLenum,
|
||||
type: GLenum,
|
||||
source: TexImageSourceWebCodecs,
|
||||
): void;
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
/// webcodecs APIs
|
||||
/////////////////////////////
|
||||
|
||||
interface AudioDataCopyToOptions {
|
||||
format?: AudioSampleFormat | undefined;
|
||||
frameCount?: number | undefined;
|
||||
frameOffset?: number | undefined;
|
||||
planeIndex: number;
|
||||
}
|
||||
|
||||
interface AudioDataInit {
|
||||
data: AllowSharedBufferSource;
|
||||
format: AudioSampleFormat;
|
||||
numberOfChannels: number;
|
||||
numberOfFrames: number;
|
||||
sampleRate: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface AudioDecoderConfig {
|
||||
codec: string;
|
||||
description?: AllowSharedBufferSource | undefined;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
interface AudioDecoderInit {
|
||||
error: WebCodecsErrorCallback;
|
||||
output: AudioDataOutputCallback;
|
||||
}
|
||||
|
||||
interface AudioDecoderSupport {
|
||||
config?: AudioDecoderConfig;
|
||||
supported?: boolean;
|
||||
}
|
||||
|
||||
interface AudioEncoderConfig {
|
||||
bitrate?: number | undefined;
|
||||
codec: string;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
interface AudioEncoderInit {
|
||||
error: WebCodecsErrorCallback;
|
||||
output: EncodedAudioChunkOutputCallback;
|
||||
}
|
||||
|
||||
interface AudioEncoderSupport {
|
||||
config?: AudioEncoderConfig;
|
||||
supported?: boolean;
|
||||
}
|
||||
|
||||
interface AvcEncoderConfig {
|
||||
format?: AvcBitstreamFormat | undefined;
|
||||
}
|
||||
|
||||
interface EncodedAudioChunkInit {
|
||||
data: AllowSharedBufferSource;
|
||||
duration?: number | undefined;
|
||||
timestamp: number;
|
||||
type: EncodedAudioChunkType;
|
||||
}
|
||||
|
||||
interface EncodedAudioChunkMetadata {
|
||||
decoderConfig?: AudioDecoderConfig | undefined;
|
||||
}
|
||||
|
||||
interface EncodedVideoChunkInit {
|
||||
data: AllowSharedBufferSource;
|
||||
duration?: number | undefined;
|
||||
timestamp: number;
|
||||
type: EncodedVideoChunkType;
|
||||
}
|
||||
|
||||
interface EncodedVideoChunkMetadata {
|
||||
decoderConfig?: VideoDecoderConfig | undefined;
|
||||
temporalLayerId?: number | undefined;
|
||||
}
|
||||
|
||||
interface ImageDecodeOptions {
|
||||
completeFramesOnly?: boolean | undefined;
|
||||
frameIndex?: number | undefined;
|
||||
}
|
||||
|
||||
interface ImageDecodeResult {
|
||||
complete: boolean;
|
||||
image: VideoFrame;
|
||||
}
|
||||
|
||||
interface ImageDecoderInit {
|
||||
colorSpaceConversion?: ColorSpaceConversion | undefined;
|
||||
data: ImageBufferSource;
|
||||
desiredHeight?: number | undefined;
|
||||
desiredWidth?: number | undefined;
|
||||
preferAnimation?: boolean | undefined;
|
||||
premultiplyAlpha?: PremultiplyAlpha | undefined;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface PlaneLayout {
|
||||
offset: number;
|
||||
stride: number;
|
||||
}
|
||||
|
||||
interface VideoColorSpaceInit {
|
||||
fullRange?: boolean | null | undefined;
|
||||
matrix?: VideoMatrixCoefficients | null | undefined;
|
||||
primaries?: VideoColorPrimaries | null | undefined;
|
||||
transfer?: VideoTransferCharacteristics | null | undefined;
|
||||
}
|
||||
|
||||
interface VideoDecoderConfig {
|
||||
codec: string;
|
||||
codedHeight?: number | undefined;
|
||||
codedWidth?: number | undefined;
|
||||
colorSpace?: VideoColorSpaceInit | undefined;
|
||||
description?: AllowSharedBufferSource | undefined;
|
||||
displayAspectHeight?: number | undefined;
|
||||
displayAspectWidth?: number | undefined;
|
||||
hardwareAcceleration?: HardwarePreference | undefined;
|
||||
optimizeForLatency?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface VideoDecoderInit {
|
||||
error: WebCodecsErrorCallback;
|
||||
output: VideoFrameOutputCallback;
|
||||
}
|
||||
|
||||
interface VideoDecoderSupport {
|
||||
config?: VideoDecoderConfig;
|
||||
supported?: boolean;
|
||||
}
|
||||
|
||||
interface VideoEncoderConfig {
|
||||
alpha?: AlphaOption | undefined;
|
||||
avc?: AvcEncoderConfig | undefined;
|
||||
bitrate?: number | undefined;
|
||||
bitrateMode?: VideoEncoderBitrateMode | undefined;
|
||||
codec: string;
|
||||
displayHeight?: number | undefined;
|
||||
displayWidth?: number | undefined;
|
||||
framerate?: number | undefined;
|
||||
hardwareAcceleration?: HardwarePreference | undefined;
|
||||
height: number;
|
||||
latencyMode?: LatencyMode | undefined;
|
||||
scalabilityMode?: string | undefined;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface VideoEncoderEncodeOptions {
|
||||
keyFrame?: boolean;
|
||||
}
|
||||
|
||||
interface VideoEncoderInit {
|
||||
error: WebCodecsErrorCallback;
|
||||
output: EncodedVideoChunkOutputCallback;
|
||||
}
|
||||
|
||||
interface VideoEncoderSupport {
|
||||
config?: VideoEncoderConfig;
|
||||
supported?: boolean;
|
||||
}
|
||||
|
||||
interface VideoFrameBufferInit {
|
||||
codedHeight: number;
|
||||
codedWidth: number;
|
||||
colorSpace?: VideoColorSpaceInit | undefined;
|
||||
displayHeight?: number | undefined;
|
||||
displayWidth?: number | undefined;
|
||||
duration?: number | undefined;
|
||||
format: VideoPixelFormat;
|
||||
layout?: PlaneLayout[] | undefined;
|
||||
timestamp: number;
|
||||
visibleRect?: DOMRectInit | undefined;
|
||||
}
|
||||
|
||||
interface VideoFrameCopyToOptions {
|
||||
layout?: PlaneLayout[] | undefined;
|
||||
rect?: DOMRectInit | undefined;
|
||||
}
|
||||
|
||||
interface VideoFrameInit {
|
||||
alpha?: AlphaOption | undefined;
|
||||
displayHeight?: number | undefined;
|
||||
displayWidth?: number | undefined;
|
||||
duration?: number | undefined;
|
||||
timestamp?: number | undefined;
|
||||
visibleRect?: DOMRectInit | undefined;
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
readonly duration: number;
|
||||
readonly format: AudioSampleFormat | null;
|
||||
readonly numberOfChannels: number;
|
||||
readonly numberOfFrames: number;
|
||||
readonly sampleRate: number;
|
||||
readonly timestamp: number;
|
||||
allocationSize(options: AudioDataCopyToOptions): number;
|
||||
clone(): AudioData;
|
||||
close(): void;
|
||||
copyTo(
|
||||
destination: AllowSharedBufferSource,
|
||||
options: AudioDataCopyToOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare var AudioData: {
|
||||
prototype: AudioData;
|
||||
new (init: AudioDataInit): AudioData;
|
||||
};
|
||||
|
||||
interface AudioDecoderEventMap {
|
||||
dequeue: Event;
|
||||
}
|
||||
|
||||
/** Available only in secure contexts. */
|
||||
interface AudioDecoder {
|
||||
readonly decodeQueueSize: number;
|
||||
readonly state: CodecState;
|
||||
ondequeue: ((this: AudioDecoder, ev: Event) => any) | null;
|
||||
close(): void;
|
||||
configure(config: AudioDecoderConfig): void;
|
||||
decode(chunk: EncodedAudioChunk): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
addEventListener<K extends keyof AudioDecoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: AudioDecoder, ev: AudioDecoderEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof AudioDecoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: AudioDecoder, ev: AudioDecoderEventMap[K]) => any,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare var AudioDecoder: {
|
||||
prototype: AudioDecoder;
|
||||
new (init: AudioDecoderInit): AudioDecoder;
|
||||
isConfigSupported(config: AudioDecoderConfig): Promise<AudioDecoderSupport>;
|
||||
};
|
||||
|
||||
interface AudioEncoderEventMap {
|
||||
dequeue: Event;
|
||||
}
|
||||
|
||||
/** Available only in secure contexts. */
|
||||
interface AudioEncoder {
|
||||
readonly encodeQueueSize: number;
|
||||
readonly state: CodecState;
|
||||
ondequeue: ((this: AudioEncoder, ev: Event) => any) | null;
|
||||
close(): void;
|
||||
configure(config: AudioEncoderConfig): void;
|
||||
encode(data: AudioData): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
addEventListener<K extends keyof AudioEncoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: AudioEncoder, ev: AudioEncoderEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof AudioEncoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: AudioEncoder, ev: AudioEncoderEventMap[K]) => any,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare var AudioEncoder: {
|
||||
prototype: AudioEncoder;
|
||||
new (init: AudioEncoderInit): AudioEncoder;
|
||||
isConfigSupported(config: AudioEncoderConfig): Promise<AudioEncoderSupport>;
|
||||
};
|
||||
|
||||
interface EncodedAudioChunk {
|
||||
readonly byteLength: number;
|
||||
readonly duration: number | null;
|
||||
readonly timestamp: number;
|
||||
readonly type: EncodedAudioChunkType;
|
||||
copyTo(destination: AllowSharedBufferSource): void;
|
||||
}
|
||||
|
||||
declare var EncodedAudioChunk: {
|
||||
prototype: EncodedAudioChunk;
|
||||
new (init: EncodedAudioChunkInit): EncodedAudioChunk;
|
||||
};
|
||||
|
||||
interface EncodedVideoChunk {
|
||||
readonly byteLength: number;
|
||||
readonly duration: number | null;
|
||||
readonly timestamp: number;
|
||||
readonly type: EncodedVideoChunkType;
|
||||
copyTo(destination: AllowSharedBufferSource): void;
|
||||
}
|
||||
|
||||
declare var EncodedVideoChunk: {
|
||||
prototype: EncodedVideoChunk;
|
||||
new (init: EncodedVideoChunkInit): EncodedVideoChunk;
|
||||
};
|
||||
|
||||
/** Available only in secure contexts. */
|
||||
interface ImageDecoder {
|
||||
readonly complete: boolean;
|
||||
readonly completed: Promise<void>;
|
||||
readonly tracks: ImageTrackList;
|
||||
readonly type: string;
|
||||
close(): void;
|
||||
decode(options?: ImageDecodeOptions): Promise<ImageDecodeResult>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
// declare var ImageDecoder: {
|
||||
// prototype: ImageDecoder;
|
||||
// new(init: ImageDecoderInit): ImageDecoder;
|
||||
// isTypeSupported(type: string): Promise<boolean>;
|
||||
// };
|
||||
|
||||
// interface ImageTrack {
|
||||
// readonly animated: boolean;
|
||||
// readonly frameCount: number;
|
||||
// readonly repetitionCount: number;
|
||||
// selected: boolean;
|
||||
// }
|
||||
|
||||
// declare var ImageTrack: {
|
||||
// prototype: ImageTrack;
|
||||
// new(): ImageTrack;
|
||||
// };
|
||||
|
||||
// interface ImageTrackList {
|
||||
// readonly length: number;
|
||||
// readonly ready: Promise<void>;
|
||||
// readonly selectedIndex: number;
|
||||
// readonly selectedTrack: ImageTrack | null;
|
||||
// [index: number]: ImageTrack;
|
||||
// }
|
||||
|
||||
// declare var ImageTrackList: {
|
||||
// prototype: ImageTrackList;
|
||||
// new(): ImageTrackList;
|
||||
// };
|
||||
|
||||
interface VideoColorSpace {
|
||||
readonly fullRange: boolean | null;
|
||||
readonly matrix: VideoMatrixCoefficients | null;
|
||||
readonly primaries: VideoColorPrimaries | null;
|
||||
readonly transfer: VideoTransferCharacteristics | null;
|
||||
toJSON(): VideoColorSpaceInit;
|
||||
}
|
||||
|
||||
declare var VideoColorSpace: {
|
||||
prototype: VideoColorSpace;
|
||||
new (init?: VideoColorSpaceInit): VideoColorSpace;
|
||||
};
|
||||
|
||||
interface VideoDecoderEventMap {
|
||||
dequeue: Event;
|
||||
}
|
||||
|
||||
/** Available only in secure contexts. */
|
||||
interface VideoDecoder {
|
||||
readonly decodeQueueSize: number;
|
||||
readonly state: CodecState;
|
||||
ondequeue: ((this: VideoDecoder, ev: Event) => any) | null;
|
||||
close(): void;
|
||||
configure(config: VideoDecoderConfig): void;
|
||||
decode(chunk: EncodedVideoChunk): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
addEventListener<K extends keyof VideoDecoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: VideoDecoder, ev: VideoDecoderEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof VideoDecoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: VideoDecoder, ev: VideoDecoderEventMap[K]) => any,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare var VideoDecoder: {
|
||||
prototype: VideoDecoder;
|
||||
new (init: VideoDecoderInit): VideoDecoder;
|
||||
isConfigSupported(config: VideoDecoderConfig): Promise<VideoDecoderSupport>;
|
||||
};
|
||||
|
||||
interface VideoEncoderEventMap {
|
||||
dequeue: Event;
|
||||
}
|
||||
|
||||
/** Available only in secure contexts. */
|
||||
interface VideoEncoder {
|
||||
readonly encodeQueueSize: number;
|
||||
readonly state: CodecState;
|
||||
close(): void;
|
||||
ondequeue: ((this: VideoEncoder, ev: Event) => any) | null;
|
||||
configure(config: VideoEncoderConfig): void;
|
||||
encode(frame: VideoFrame, options?: VideoEncoderEncodeOptions): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
addEventListener<K extends keyof VideoEncoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: VideoEncoder, ev: VideoEncoderEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof VideoEncoderEventMap>(
|
||||
type: K,
|
||||
listener: (this: VideoEncoder, ev: VideoEncoderEventMap[K]) => any,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare var VideoEncoder: {
|
||||
prototype: VideoEncoder;
|
||||
new (init: VideoEncoderInit): VideoEncoder;
|
||||
isConfigSupported(config: VideoEncoderConfig): Promise<VideoEncoderSupport>;
|
||||
};
|
||||
|
||||
interface VideoFrame {
|
||||
readonly codedHeight: number;
|
||||
readonly codedRect: DOMRectReadOnly | null;
|
||||
readonly codedWidth: number;
|
||||
readonly colorSpace: VideoColorSpace;
|
||||
readonly displayHeight: number;
|
||||
readonly displayWidth: number;
|
||||
readonly duration: number | null;
|
||||
readonly format: VideoPixelFormat | null;
|
||||
readonly timestamp: number;
|
||||
readonly visibleRect: DOMRectReadOnly | null;
|
||||
allocationSize(options?: VideoFrameCopyToOptions): number;
|
||||
clone(): VideoFrame;
|
||||
close(): void;
|
||||
copyTo(
|
||||
destination: AllowSharedBufferSource,
|
||||
options?: VideoFrameCopyToOptions,
|
||||
): Promise<PlaneLayout[]>;
|
||||
}
|
||||
|
||||
declare var VideoFrame: {
|
||||
prototype: VideoFrame;
|
||||
new (source: CanvasImageSource, init?: VideoFrameInit): VideoFrame;
|
||||
new (data: AllowSharedBufferSource, init: VideoFrameBufferInit): VideoFrame;
|
||||
};
|
||||
|
||||
interface AudioDataOutputCallback {
|
||||
(output: AudioData): void;
|
||||
}
|
||||
|
||||
interface EncodedAudioChunkOutputCallback {
|
||||
(output: EncodedAudioChunk, metadata: EncodedAudioChunkMetadata): void;
|
||||
}
|
||||
|
||||
interface EncodedVideoChunkOutputCallback {
|
||||
(chunk: EncodedVideoChunk, metadata: EncodedVideoChunkMetadata): void;
|
||||
}
|
||||
|
||||
interface VideoFrameOutputCallback {
|
||||
(output: VideoFrame): void;
|
||||
}
|
||||
|
||||
interface WebCodecsErrorCallback {
|
||||
(error: DOMException): void;
|
||||
}
|
||||
|
||||
// type AllowSharedBufferSource = ArrayBuffer | ArrayBufferView;
|
||||
// type BitrateMode = "constant" | "variable";
|
||||
// type ImageBufferSource = ArrayBuffer | ArrayBufferView | ReadableStream;
|
||||
// type AlphaOption = "discard" | "keep";
|
||||
// type AudioSampleFormat = "f32" | "f32-planar" | "s16" | "s16-planar" | "s32" | "s32-planar" | "u8" | "u8-planar";
|
||||
// type AvcBitstreamFormat = "annexb" | "avc";
|
||||
// type CodecState = "closed" | "configured" | "unconfigured";
|
||||
// type EncodedAudioChunkType = "delta" | "key";
|
||||
// type EncodedVideoChunkType = "delta" | "key";
|
||||
type HardwarePreference =
|
||||
| "no-preference"
|
||||
| "prefer-hardware"
|
||||
| "prefer-software";
|
||||
// type LatencyMode = "quality" | "realtime";
|
||||
// type VideoColorPrimaries = "bt470bg" | "bt709" | "smpte170m";
|
||||
// type VideoMatrixCoefficients = "bt470bg" | "bt709" | "rgb" | "smpte170m";
|
||||
// type VideoPixelFormat = "BGRA" | "BGRX" | "I420" | "I420A" | "I422" | "I444" | "NV12" | "RGBA" | "RGBX";
|
||||
// type VideoTransferCharacteristics = "bt709" | "iec61966-2-1" | "smpte170m";
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
.error > h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error > p {
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ErrorView: FC<Props> = ({
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<BigIcon className={styles.icon}>
|
||||
<Icon />
|
||||
<Icon aria-hidden />
|
||||
</BigIcon>
|
||||
<Heading as="h1" weight="semibold" size="md">
|
||||
{title}
|
||||
|
||||
@@ -10,7 +10,7 @@ import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
||||
import styles from "./FullScreenView.module.css";
|
||||
@@ -67,7 +67,7 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
) : (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorIcon}
|
||||
Icon={ErrorSolidIcon}
|
||||
title={t("error.generic")}
|
||||
rageshake
|
||||
fatal
|
||||
|
||||
@@ -124,9 +124,15 @@ export interface UrlParams {
|
||||
*/
|
||||
password: string | null;
|
||||
/**
|
||||
* Whether we the app should use per participant keys for E2EE.
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
@@ -173,6 +179,7 @@ export interface UrlParams {
|
||||
* The Sentry DSN. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
sentryDsn: string | null;
|
||||
|
||||
/**
|
||||
* The Sentry environment. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
@@ -281,6 +288,11 @@ export const getUrlParams = (
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
allowIceFallback: parser.getFlagParam("allowIceFallback"),
|
||||
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
|
||||
controlledAudioDevices: parser.getFlagParam(
|
||||
"controlledAudioDevices",
|
||||
// the deprecated property name
|
||||
parser.getFlagParam("controlledMediaDevices"),
|
||||
),
|
||||
skipLobby: parser.getFlagParam(
|
||||
"skipLobby",
|
||||
isWidget && intent === UserIntent.StartNewCall,
|
||||
|
||||
@@ -119,7 +119,7 @@ export const UserMenu: FC<Props> = ({
|
||||
key={key}
|
||||
Icon={Icon}
|
||||
label={label}
|
||||
data-test-id={dataTestid}
|
||||
data-testid={dataTestid}
|
||||
onSelect={() => onAction(key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`the content is rendered when the modal is open 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-:r4:"
|
||||
class="overlay animate modal dialog _glass_1x9g9_17"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate modal dialog _glass_sepwu_8"
|
||||
data-state="open"
|
||||
id="radix-:r3:"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -17,8 +17,8 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
class="header"
|
||||
>
|
||||
<h2
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
id="radix-:r4:"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
My modal
|
||||
</h2>
|
||||
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
|
||||
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-:ra:"
|
||||
aria-labelledby="radix-«ra»"
|
||||
class="overlay modal drawer"
|
||||
data-state="open"
|
||||
data-vaul-animate="true"
|
||||
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
data-vaul-drawer=""
|
||||
data-vaul-drawer-direction="bottom"
|
||||
data-vaul-snap-points="false"
|
||||
id="radix-:r9:"
|
||||
id="radix-«r9»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
class="handle"
|
||||
/>
|
||||
<h2
|
||||
id="radix-:ra:"
|
||||
id="radix-«ra»"
|
||||
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
|
||||
>
|
||||
My modal
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-:r4:"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate toast"
|
||||
data-state="open"
|
||||
id="radix-:r3:"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<h3
|
||||
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45"
|
||||
id="radix-:r4:"
|
||||
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
Hello world!
|
||||
</h3>
|
||||
|
||||
@@ -107,13 +107,13 @@ export class RageshakeSpanProcessor implements SpanProcessor {
|
||||
startTime,
|
||||
duration,
|
||||
references:
|
||||
span.parentSpanId === undefined
|
||||
span.parentSpanContext?.spanId === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
refType: "CHILD_OF",
|
||||
traceID: traceId,
|
||||
spanID: span.parentSpanId,
|
||||
spanID: span.parentSpanContext?.spanId,
|
||||
},
|
||||
],
|
||||
tags: dumpAttributes(span.attributes),
|
||||
|
||||
@@ -9,8 +9,8 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":rb5:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
aria-labelledby="«rb5»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -26,7 +26,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -43,8 +43,8 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r7m:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
aria-labelledby="«r7m»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -60,7 +60,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -74,8 +74,8 @@ exports[`Can lower hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r36:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
aria-labelledby="«r36»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -91,7 +91,7 @@ exports[`Can lower hand 1`] = `
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -108,8 +108,8 @@ exports[`Can open menu 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r0:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
aria-labelledby="«r0»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -125,7 +125,7 @@ exports[`Can open menu 1`] = `
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -139,8 +139,8 @@ exports[`Can raise hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r1j:"
|
||||
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
aria-labelledby="«r1j»"
|
||||
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -155,7 +155,7 @@ exports[`Can raise hand 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
|
||||
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.943 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 1.999 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ConfigOptions {
|
||||
api_key: string;
|
||||
api_host: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Sentry endpoint to which crash data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
@@ -22,6 +23,7 @@ export interface ConfigOptions {
|
||||
DSN: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rageshake server to which feedback and debug logs will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
@@ -66,6 +68,7 @@ export interface ConfigOptions {
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
@@ -86,6 +89,7 @@ export interface ConfigOptions {
|
||||
* Defines whether participants should start with audio enabled by default.
|
||||
*/
|
||||
enable_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Defines whether participants should start with video enabled by default.
|
||||
*/
|
||||
@@ -109,19 +113,38 @@ export interface ConfigOptions {
|
||||
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
|
||||
* when someone leaves a call.
|
||||
*/
|
||||
wait_for_key_rotation_ms?: number;
|
||||
/** @deprecated use wait_for_key_rotation_ms instead */
|
||||
key_rotation_on_leave_delay?: number;
|
||||
|
||||
/**
|
||||
* How often (in milliseconds) keep-alive messages should be sent to the server for
|
||||
* the MatrixRTC membership event.
|
||||
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
|
||||
* that the server waits before sending the leave MatrixRTC membership event.
|
||||
*/
|
||||
delayed_leave_event_delay_ms?: number;
|
||||
/** @deprecated use delayed_leave_event_delay_ms instead */
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
|
||||
/**
|
||||
* The time interval (in milliseconds) at which the client sends membership keep-alive
|
||||
* messages to the server by restarting the timer for the delayed leave event.
|
||||
*/
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
/** @deprecated use delayed_leave_event_restart_ms instead */
|
||||
membership_keep_alive_period?: number;
|
||||
|
||||
/**
|
||||
* How long (in milliseconds) after the last keep-alive the server should expire the
|
||||
* MatrixRTC membership event.
|
||||
* How long we wait before retrying after a network error on any of the requests.
|
||||
*/
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
network_error_retry_ms?: number;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
*
|
||||
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||
*/
|
||||
membership_event_expiry_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,63 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
export interface Controls {
|
||||
canEnterPip: () => boolean;
|
||||
enablePip: () => void;
|
||||
disablePip: () => void;
|
||||
canEnterPip(): boolean;
|
||||
enablePip(): void;
|
||||
disablePip(): void;
|
||||
/** @deprecated use setAvailableAudioDevices instead*/
|
||||
setAvailableOutputDevices(devices: OutputDevice[]): void;
|
||||
setAvailableAudioDevices(devices: OutputDevice[]): void;
|
||||
/** @deprecated use setAudioDevice instead*/
|
||||
setOutputDevice(id: string): void;
|
||||
setAudioDevice(id: string): void;
|
||||
/** @deprecated use onAudioDeviceSelect instead*/
|
||||
onOutputDeviceSelect?: (id: string) => void;
|
||||
onAudioDeviceSelect?: (id: string) => void;
|
||||
onAudioPlaybackStarted?: () => void;
|
||||
/** @deprecated use setAudioEnabled instead*/
|
||||
setOutputEnabled(enabled: boolean): void;
|
||||
setAudioEnabled(enabled: boolean): void;
|
||||
/** @deprecated use showNativeAudioDevicePicker instead*/
|
||||
showNativeOutputDevicePicker?: () => void;
|
||||
showNativeAudioDevicePicker?: () => void;
|
||||
}
|
||||
|
||||
export const setPipEnabled$ = new Subject<boolean>();
|
||||
export interface OutputDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
forEarpiece?: boolean;
|
||||
isEarpiece?: boolean;
|
||||
isSpeaker?: boolean;
|
||||
isExternalHeadset?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* If pipMode is enabled, EC will render a adapted call view layout.
|
||||
*/
|
||||
export const setPipEnabled$ = new Subject<boolean>();
|
||||
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
|
||||
// We want the devices that have been set during loading to be available immediately once loaded.
|
||||
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
|
||||
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
|
||||
// We want the device that has been set during loading to be available immediately once loaded.
|
||||
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
/**
|
||||
* This allows the os to mute the call if the user
|
||||
* presses the volume down button when it is at the minimum volume.
|
||||
*
|
||||
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
|
||||
*/
|
||||
export const setAudioEnabled$ = new Subject<boolean>();
|
||||
let playbackStartedEmitted = false;
|
||||
export const setPlaybackStarted = (): void => {
|
||||
if (!playbackStartedEmitted) {
|
||||
playbackStartedEmitted = true;
|
||||
window.controls.onAudioPlaybackStarted?.();
|
||||
}
|
||||
};
|
||||
window.controls = {
|
||||
canEnterPip(): boolean {
|
||||
return setPipEnabled$.observed;
|
||||
@@ -27,4 +74,28 @@ window.controls = {
|
||||
if (!setPipEnabled$.observed) throw new Error("No call is running");
|
||||
setPipEnabled$.next(false);
|
||||
},
|
||||
setAvailableAudioDevices(devices: OutputDevice[]): void {
|
||||
availableOutputDevices$.next(devices);
|
||||
},
|
||||
setAudioDevice(id: string): void {
|
||||
outputDevice$.next(id);
|
||||
},
|
||||
setAudioEnabled(enabled: boolean): void {
|
||||
if (!setAudioEnabled$.observed)
|
||||
throw new Error(
|
||||
"Output controls are disabled. No setAudioEnabled$ observer",
|
||||
);
|
||||
setAudioEnabled$.next(enabled);
|
||||
},
|
||||
|
||||
// wrappers for the deprecated controls fields
|
||||
setOutputEnabled(enabled: boolean): void {
|
||||
this.setAudioEnabled(enabled);
|
||||
},
|
||||
setAvailableOutputDevices(devices: OutputDevice[]): void {
|
||||
this.setAvailableAudioDevices(devices);
|
||||
},
|
||||
setOutputDevice(id: string): void {
|
||||
this.setAudioDevice(id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client";
|
||||
import { BaseKeyProvider } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type MatrixRTCSession,
|
||||
@@ -44,20 +44,29 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
): void => {
|
||||
createKeyMaterialFromBuffer(encryptionKey).then(
|
||||
(keyMaterial) => {
|
||||
this.onSetEncryptionKey(keyMaterial, participantId, encryptionKeyIndex);
|
||||
crypto.subtle
|
||||
.importKey("raw", encryptionKey, "HKDF", false, [
|
||||
"deriveBits",
|
||||
"deriveKey",
|
||||
])
|
||||
.then(
|
||||
(keyMaterial) => {
|
||||
this.onSetEncryptionKey(
|
||||
keyMaterial,
|
||||
participantId,
|
||||
encryptionKeyIndex,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
},
|
||||
);
|
||||
logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,58 +6,70 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||
import { type UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
/**
|
||||
* This setter will update the state for all `useRoomSharedKey` hooks
|
||||
* if the password is different from the one in local storage or if its not yet in the local storage.
|
||||
*/
|
||||
export function saveKeyForRoom(roomId: string, password: string): void {
|
||||
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||
if (
|
||||
localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) !== password
|
||||
)
|
||||
setLocalStorageItemReactive(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
password,
|
||||
);
|
||||
}
|
||||
|
||||
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||
`room-shared-key-${roomId}`;
|
||||
|
||||
const useInternalRoomSharedKey = (roomId: string): string | null => {
|
||||
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||
const roomSharedKey = useLocalStorage(key)[0];
|
||||
/**
|
||||
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
||||
* @param roomId
|
||||
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
|
||||
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
|
||||
*/
|
||||
const useRoomSharedKey = (
|
||||
roomId: string,
|
||||
setInitialValue?: string,
|
||||
): [string | null, setKey: (key: string) => void] => {
|
||||
const [roomSharedKey, setRoomSharedKey] = useLocalStorage(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
);
|
||||
useEffect(() => {
|
||||
// If setInitialValue is available, update the local storage (usually the password from the url).
|
||||
// This will update roomSharedKey but wont update the returned value since
|
||||
// that already defaults to setInitialValue.
|
||||
if (setInitialValue) setRoomSharedKey(setInitialValue);
|
||||
}, [setInitialValue, setRoomSharedKey]);
|
||||
|
||||
return roomSharedKey;
|
||||
// make sure we never return the initial null value from `useLocalStorage`
|
||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||
};
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
saveKeyFromUrlParams(getUrlParams());
|
||||
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||
return localStorage.getItem(key);
|
||||
const { roomId: urlRoomId, password } = getUrlParams();
|
||||
if (roomId !== urlRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
urlRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
function saveKeyFromUrlParams(urlParams: UrlParams): void {
|
||||
if (!urlParams.password || !urlParams.roomId) return;
|
||||
|
||||
// Take the key from the URL and save it.
|
||||
// It's important to always use the room ID specified in the URL
|
||||
// when saving keys rather than whatever the current room ID might be,
|
||||
// in case we've moved to a different room but the URL hasn't changed.
|
||||
saveKeyForRoom(urlParams.roomId, urlParams.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the room password from the URL if one is present, saving it in localstorage
|
||||
* and returning it in a tuple with the corresponding room ID from the URL.
|
||||
* @returns A tuple of the roomId and password from the URL if the URL has both,
|
||||
* otherwise [undefined, undefined]
|
||||
*/
|
||||
const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
|
||||
const urlParams = useUrlParams();
|
||||
|
||||
useEffect(() => saveKeyFromUrlParams(urlParams), [urlParams]);
|
||||
|
||||
return urlParams.roomId && urlParams.password
|
||||
? [urlParams.roomId, urlParams.password]
|
||||
: [undefined, undefined];
|
||||
};
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
@@ -66,12 +78,11 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
|
||||
// make sure we've extracted the key from the URL first
|
||||
// (and we still need to take the value it returns because
|
||||
// the effect won't run in time for it to save to localstorage in
|
||||
// time for us to read it out again).
|
||||
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
getKeyForRoom(roomId) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
@@ -80,15 +91,10 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: storedPassword,
|
||||
};
|
||||
if (urlRoomId === roomId)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: passwordFromUrl,
|
||||
};
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return { kind: E2eeType.PER_PARTICIPANT };
|
||||
}
|
||||
return { kind: E2eeType.NONE };
|
||||
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||
}, [room, storedPassword]);
|
||||
return e2eeSystem;
|
||||
}
|
||||
|
||||
@@ -81,8 +81,9 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
||||
}
|
||||
|
||||
const roomIsJoinable = (room: Room): boolean => {
|
||||
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
const password = getKeyForRoom(room.roomId);
|
||||
if (!room.hasEncryptionStateEvent() && !password) {
|
||||
// if we have a non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
// in case this key also does not exists we cannot join the room.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,11 @@ export class Initializer {
|
||||
lookup: () => getUrlParams().lang ?? undefined,
|
||||
});
|
||||
|
||||
// Synchronise the HTML lang attribute with the i18next language
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
await i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
|
||||
107
src/livekit/MatrixAudioRenderer.test.tsx
Normal file
107
src/livekit/MatrixAudioRenderer.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
getTrackReferenceId,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import { type RemoteAudioTrack } from "livekit-client";
|
||||
import { type ReactNode } from "react";
|
||||
import { useTracks } from "@livekit/components-react";
|
||||
|
||||
import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "./MediaDevicesContext";
|
||||
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import { mockTrack } from "../utils/test";
|
||||
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
||||
return (
|
||||
<audio data-testid={"audio"}>
|
||||
{getTrackReferenceId(props.trackRef)}
|
||||
</audio>
|
||||
);
|
||||
},
|
||||
useTracks: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const tracks = [mockTrack("test:123")];
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
|
||||
it("should render for member", () => {
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const memberships = [
|
||||
{ sender: "othermember", deviceId: "123" },
|
||||
] as CallMembership[];
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MatrixAudioRenderer members={memberships} />,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
);
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined);
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1);
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(testAudioContext.gain.gain.value).toEqual(1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(0);
|
||||
});
|
||||
|
||||
it("should setup audioContext gain and pan", () => {
|
||||
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
|
||||
pan: 1,
|
||||
volume: 0.1,
|
||||
});
|
||||
render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalled();
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled();
|
||||
|
||||
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(1);
|
||||
});
|
||||
224
src/livekit/MatrixAudioRenderer.tsx
Normal file
224
src/livekit/MatrixAudioRenderer.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import * as controls from "../controls";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The list of participants to render audio for.
|
||||
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
|
||||
* that are not expected to be in the rtc session.
|
||||
*/
|
||||
members: CallMembership[];
|
||||
/**
|
||||
* If set to `true`, mutes all audio tracks rendered by the component.
|
||||
* @remarks
|
||||
* If set to `true`, the server will stop sending audio track data to the client.
|
||||
*/
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
|
||||
* It takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
* It also takes care of the earpiece audio configuration for iOS devices.
|
||||
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <MatrixAudioRenderer />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function MatrixAudioRenderer({
|
||||
members,
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const validIdentities = useMemo(
|
||||
() =>
|
||||
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)),
|
||||
[members],
|
||||
);
|
||||
|
||||
const loggedInvalidIdentities = useRef(new Set<string>());
|
||||
|
||||
/**
|
||||
* Log an invalid livekit track identity.
|
||||
* A invalid identity is one that does not match any of the matrix rtc members.
|
||||
*
|
||||
* @param identity The identity of the track that is invalid
|
||||
* @param validIdentities The list of valid identities
|
||||
*/
|
||||
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
|
||||
if (loggedInvalidIdentities.current.has(identity)) return;
|
||||
logger.warn(
|
||||
`Audio track ${identity} has no matching matrix call member`,
|
||||
`current members: ${Array.from(validIdentities.values())}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
loggedInvalidIdentities.current.add(identity);
|
||||
};
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.Unknown,
|
||||
],
|
||||
{
|
||||
updateOnlyOn: [],
|
||||
onlySubscribed: true,
|
||||
},
|
||||
).filter((ref) => {
|
||||
const isValid = validIdentities?.has(ref.participant.identity);
|
||||
if (!isValid && !ref.participant.isLocal)
|
||||
logInvalid(ref.participant.identity, validIdentities);
|
||||
return (
|
||||
!ref.participant.isLocal &&
|
||||
ref.publication.kind === Track.Kind.Audio &&
|
||||
isValid
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
}, [tracks, validIdentities]);
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
// responsible for mimicking earpiece audio on iPhones.
|
||||
// The Safari audio devices enumeration does not expose an earpiece audio device.
|
||||
// We alternatively use the audioContext pan node to only use one of the stereo channels.
|
||||
|
||||
// This component does get additionally complicated because of a Safari bug.
|
||||
// (see: https://bugs.webkit.org/show_bug.cgi?id=251532
|
||||
// and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878
|
||||
// and https://bugs.webkit.org/show_bug.cgi?id=231105)
|
||||
//
|
||||
// AudioContext gets stopped if the webview gets moved into the background.
|
||||
// Once the phone is in standby audio playback will stop.
|
||||
// So we can only use the pan trick only works is the phone is not in standby.
|
||||
// If earpiece mode is not used we do not use audioContext to allow standby playback.
|
||||
// shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback.
|
||||
|
||||
const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig();
|
||||
const shouldUseAudioContext = stereoPan !== 0;
|
||||
|
||||
// initialize the potentially used audio context.
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
const ctx = new AudioContext();
|
||||
setAudioContext(ctx);
|
||||
return (): void => {
|
||||
void ctx.close();
|
||||
};
|
||||
}, []);
|
||||
const audioNodes = useMemo(
|
||||
() => ({
|
||||
gain: audioContext?.createGain(),
|
||||
pan: audioContext?.createStereoPanner(),
|
||||
}),
|
||||
[audioContext],
|
||||
);
|
||||
|
||||
// Simple effects to update the gain and pan node based on the props
|
||||
useEffect(() => {
|
||||
if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan;
|
||||
}, [audioNodes.pan, stereoPan]);
|
||||
useEffect(() => {
|
||||
if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor;
|
||||
}, [audioNodes.gain, volumeFactor]);
|
||||
|
||||
return (
|
||||
// We add all audio elements into one <div> for the browser developer tool experience/tidyness.
|
||||
<div style={{ display: "none" }}>
|
||||
{tracks.map((trackRef) => (
|
||||
<AudioTrackWithAudioNodes
|
||||
key={getTrackReferenceId(trackRef)}
|
||||
trackRef={trackRef}
|
||||
muted={muted}
|
||||
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
||||
audioNodes={audioNodes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StereoPanAudioTrackProps {
|
||||
muted?: boolean;
|
||||
audioContext?: AudioContext;
|
||||
audioNodes: {
|
||||
gain?: GainNode;
|
||||
pan?: StereoPannerNode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
|
||||
* It main purpose is to remount the AudioTrack component when switching from
|
||||
* audiooContext to normal audio playback.
|
||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
function AudioTrackWithAudioNodes({
|
||||
trackRef,
|
||||
muted,
|
||||
audioContext,
|
||||
audioNodes,
|
||||
...props
|
||||
}: StereoPanAudioTrackProps &
|
||||
AudioTrackProps &
|
||||
React.RefAttributes<HTMLAudioElement>): ReactNode {
|
||||
// This is used to unmount/remount the AudioTrack component.
|
||||
// Mounting needs to happen after the audioContext is set.
|
||||
// (adding the audio context when already mounted did not work outside strict mode)
|
||||
const [trackReady, setTrackReady] = useReactiveState(
|
||||
() => false,
|
||||
// We only want the track to reset once both (audioNodes and audioContext) are set.
|
||||
// for unsetting the audioContext its enough if one of the the is undefined.
|
||||
[audioContext && audioNodes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackRef || trackReady) return;
|
||||
const track = trackRef.publication.track as RemoteAudioTrack;
|
||||
const useContext = audioContext && audioNodes.gain && audioNodes.pan;
|
||||
track.setAudioContext(useContext ? audioContext : undefined);
|
||||
track.setWebAudioPlugins(
|
||||
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
||||
);
|
||||
setTrackReady(true);
|
||||
controls.setPlaybackStarted();
|
||||
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
||||
|
||||
return (
|
||||
trackReady && <AudioTrack trackRef={trackRef} muted={muted} {...props} />
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2023-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
@@ -17,29 +17,43 @@ import {
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { map, startWith } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import {
|
||||
useSetting,
|
||||
audioInput as audioInputSetting,
|
||||
audioOutput as audioOutputSetting,
|
||||
videoInput as videoInputSetting,
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
type Setting,
|
||||
} from "../settings/settings";
|
||||
import { outputDevice$, availableOutputDevices$ } from "../controls";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
export const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
| { type: "number"; number: number }
|
||||
| { type: "earpiece" }
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
export interface MediaDevice {
|
||||
export interface MediaDeviceHandle {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available: Map<string, DeviceLabel>;
|
||||
selectedId: string | undefined;
|
||||
/**
|
||||
* An additional device configuration that makes us use only one channel of the
|
||||
* output device and a reduced volume.
|
||||
*/
|
||||
useAsEarpiece: boolean | undefined;
|
||||
/**
|
||||
* The group ID of the selected device.
|
||||
*/
|
||||
@@ -50,23 +64,69 @@ export interface MediaDevice {
|
||||
select: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export interface MediaDevices {
|
||||
audioInput: MediaDevice;
|
||||
audioOutput: MediaDevice;
|
||||
videoInput: MediaDevice;
|
||||
interface InputDevices {
|
||||
audioInput: MediaDeviceHandle;
|
||||
videoInput: MediaDeviceHandle;
|
||||
startUsingDeviceNames: () => void;
|
||||
stopUsingDeviceNames: () => void;
|
||||
usingNames: boolean;
|
||||
}
|
||||
|
||||
function useMediaDevice(
|
||||
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
|
||||
audioOutput: MediaDeviceHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable that represents if we should display the devices menu for iOS.
|
||||
* This implies the following
|
||||
* - hide any input devices (they do not work anyhow on ios)
|
||||
* - Show a button to show the native output picker instead.
|
||||
* - Only show the earpiece toggle option if the earpiece is available:
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
|
||||
map((v) => v || navigator.userAgent.includes("iPhone")),
|
||||
);
|
||||
|
||||
function useSelectedId(
|
||||
available: Map<string, DeviceLabel>,
|
||||
preferredId: string | undefined,
|
||||
): string | undefined {
|
||||
return useMemo(() => {
|
||||
if (available.size) {
|
||||
// If the preferred device is available, use it. Or if every available
|
||||
// device ID is falsy, the browser is probably just being paranoid about
|
||||
// fingerprinting and we should still try using the preferred device.
|
||||
// Worst case it is not available and the browser will gracefully fall
|
||||
// back to some other device for us when requesting the media stream.
|
||||
// Otherwise, select the first available device.
|
||||
return (preferredId !== undefined && available.has(preferredId)) ||
|
||||
(available.size === 1 && available.has(""))
|
||||
? preferredId
|
||||
: available.keys().next().value;
|
||||
}
|
||||
return undefined;
|
||||
}, [available, preferredId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get access to a mediaDevice handle for a kind. This allows to list
|
||||
* the available devices, read and set the selected device.
|
||||
* @param kind Audio input, output or video output.
|
||||
* @param setting The setting this handle's selection should be synced with.
|
||||
* @param usingNames If the hook should query device names for the associated
|
||||
* list.
|
||||
* @returns A handle for the chosen kind.
|
||||
*/
|
||||
function useMediaDeviceHandle(
|
||||
kind: MediaDeviceKind,
|
||||
setting: Setting<string | undefined>,
|
||||
usingNames: boolean,
|
||||
): MediaDevice {
|
||||
// Make sure we don't needlessly reset to a device observer without names,
|
||||
// once permissions are already given
|
||||
): MediaDeviceHandle {
|
||||
const hasRequestedPermissions = useRef(false);
|
||||
const requestPermissions = usingNames || hasRequestedPermissions.current;
|
||||
// Make sure we don't needlessly reset to a device observer without names,
|
||||
// once permissions are already given
|
||||
hasRequestedPermissions.current ||= usingNames;
|
||||
|
||||
// We use a bare device observer here rather than one of the fancy device
|
||||
@@ -81,7 +141,16 @@ function useMediaDevice(
|
||||
kind,
|
||||
() => logger.error("Error creating MediaDeviceObserver"),
|
||||
requestPermissions,
|
||||
).pipe(startWith([])),
|
||||
).pipe(
|
||||
startWith([]),
|
||||
// This Observable emits new values whenever the browser fires a
|
||||
// MediaDevices 'devicechange' event. One would think, innocently, that
|
||||
// a 'devicechange' event means the devices have changed. But as of the
|
||||
// time of writing, we are seeing mobile Safari firing spurious
|
||||
// 'devicechange' events (where no change has actually occurred) when
|
||||
// we call MediaDevices.getUserMedia. So, filter by deep equality.
|
||||
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
|
||||
),
|
||||
[kind, requestPermissions],
|
||||
);
|
||||
const available = useObservableEagerState(
|
||||
@@ -102,11 +171,13 @@ function useMediaDevice(
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
// We also create this if we do not have any available devices, so that
|
||||
// we can use the default or the earpiece.
|
||||
if (
|
||||
kind === "audiooutput" &&
|
||||
available.size &&
|
||||
!available.has("") &&
|
||||
!available.has("default")
|
||||
!available.has("default") &&
|
||||
available.size
|
||||
)
|
||||
available = new Map([
|
||||
["", { type: "default", name: availableRaw[0]?.label || null }],
|
||||
@@ -118,26 +189,13 @@ function useMediaDevice(
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
[kind, deviceObserver$],
|
||||
[deviceObserver$, kind],
|
||||
),
|
||||
);
|
||||
|
||||
const [preferredId, select] = useSetting(setting);
|
||||
const selectedId = useMemo(() => {
|
||||
if (available.size) {
|
||||
// If the preferred device is available, use it. Or if every available
|
||||
// device ID is falsy, the browser is probably just being paranoid about
|
||||
// fingerprinting and we should still try using the preferred device.
|
||||
// Worst case it is not available and the browser will gracefully fall
|
||||
// back to some other device for us when requesting the media stream.
|
||||
// Otherwise, select the first available device.
|
||||
return (preferredId !== undefined && available.has(preferredId)) ||
|
||||
(available.size === 1 && available.has(""))
|
||||
? preferredId
|
||||
: available.keys().next().value;
|
||||
}
|
||||
return undefined;
|
||||
}, [available, preferredId]);
|
||||
const selectedId = useSelectedId(available, preferredId);
|
||||
|
||||
const selectedGroupId = useObservableEagerState(
|
||||
useMemo(
|
||||
() =>
|
||||
@@ -155,6 +213,7 @@ function useMediaDevice(
|
||||
() => ({
|
||||
available,
|
||||
selectedId,
|
||||
useAsEarpiece: false,
|
||||
selectedGroupId,
|
||||
select,
|
||||
}),
|
||||
@@ -162,12 +221,14 @@ function useMediaDevice(
|
||||
);
|
||||
}
|
||||
|
||||
export const deviceStub: MediaDevice = {
|
||||
export const deviceStub: MediaDeviceHandle = {
|
||||
available: new Map(),
|
||||
selectedId: undefined,
|
||||
selectedGroupId: undefined,
|
||||
select: () => {},
|
||||
useAsEarpiece: false,
|
||||
};
|
||||
|
||||
export const devicesStub: MediaDevices = {
|
||||
audioInput: deviceStub,
|
||||
audioOutput: deviceStub,
|
||||
@@ -178,26 +239,17 @@ export const devicesStub: MediaDevices = {
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
function useInputDevices(): InputDevices {
|
||||
// Counts the number of callers currently using device names.
|
||||
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
|
||||
const usingNames = numCallersUsingNames > 0;
|
||||
|
||||
const audioInput = useMediaDevice(
|
||||
const audioInput = useMediaDeviceHandle(
|
||||
"audioinput",
|
||||
audioInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const audioOutput = useMediaDevice(
|
||||
"audiooutput",
|
||||
audioOutputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const videoInput = useMediaDevice(
|
||||
const videoInput = useMediaDeviceHandle(
|
||||
"videoinput",
|
||||
videoInputSetting,
|
||||
usingNames,
|
||||
@@ -212,17 +264,52 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
[setNumCallersUsingNames],
|
||||
);
|
||||
|
||||
return {
|
||||
audioInput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
usingNames,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
const {
|
||||
audioInput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
usingNames,
|
||||
} = useInputDevices();
|
||||
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
|
||||
const webViewAudioOutput = useMediaDeviceHandle(
|
||||
"audiooutput",
|
||||
audioOutputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const controlledAudioOutput = useControlledOutput();
|
||||
|
||||
const context: MediaDevices = useMemo(
|
||||
() => ({
|
||||
audioInput,
|
||||
audioOutput,
|
||||
audioOutput: controlledAudioDevices
|
||||
? controlledAudioOutput
|
||||
: webViewAudioOutput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
audioOutput,
|
||||
controlledAudioDevices,
|
||||
controlledAudioOutput,
|
||||
webViewAudioOutput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
@@ -236,6 +323,80 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function useControlledOutput(): MediaDeviceHandle {
|
||||
const { available } = useObservableEagerState(
|
||||
useObservable(() => {
|
||||
const outputDeviceData$ = availableOutputDevices$.pipe(
|
||||
map((devices) => {
|
||||
const deviceForEarpiece = devices.find((d) => d.forEarpiece);
|
||||
const deviceMapTuple: [string, DeviceLabel][] = devices.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: DeviceLabel = { type: "name", name };
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
if (isSpeaker) deviceLabel = { type: "default", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
);
|
||||
return {
|
||||
devicesMap: new Map<string, DeviceLabel>(deviceMapTuple),
|
||||
deviceForEarpiece,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return combineLatest(
|
||||
[outputDeviceData$, iosDeviceMenu$],
|
||||
({ devicesMap, deviceForEarpiece }, iosShowEarpiece) => {
|
||||
let available = devicesMap;
|
||||
if (iosShowEarpiece && !!deviceForEarpiece) {
|
||||
available = new Map([
|
||||
...devicesMap.entries(),
|
||||
[EARPIECE_CONFIG_ID, { type: "earpiece" }],
|
||||
]);
|
||||
}
|
||||
return { available, deviceForEarpiece };
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
|
||||
useEffect(() => {
|
||||
const subscription = outputDevice$.subscribe((id) => {
|
||||
if (id) setPreferredId(id);
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [setPreferredId]);
|
||||
|
||||
const selectedId = useSelectedId(available, preferredId);
|
||||
|
||||
const [asEarpiece, setAsEarpiece] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
// This information is probably only of interest if the earpiece mode has been
|
||||
// selected - for example, Element X iOS listens to this to determine whether it
|
||||
// should enable the proximity sensor.
|
||||
if (selectedId) {
|
||||
window.controls.onAudioDeviceSelect?.(selectedId);
|
||||
// Call deprecated method for backwards compatibility.
|
||||
window.controls.onOutputDeviceSelect?.(selectedId);
|
||||
}
|
||||
setAsEarpiece(selectedId === EARPIECE_CONFIG_ID);
|
||||
}, [selectedId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
available: available,
|
||||
selectedId,
|
||||
selectedGroupId: undefined,
|
||||
select: setPreferredId,
|
||||
useAsEarpiece: asEarpiece,
|
||||
}),
|
||||
[available, selectedId, setPreferredId, asEarpiece],
|
||||
);
|
||||
}
|
||||
|
||||
export const useMediaDevices = (): MediaDevices =>
|
||||
useContext(MediaDevicesContext);
|
||||
|
||||
@@ -255,3 +416,30 @@ export const useMediaDeviceNames = (
|
||||
return context.stopUsingDeviceNames;
|
||||
}
|
||||
}, [context, enabled]);
|
||||
|
||||
/**
|
||||
* A convenience hook to get the audio node configuration for the earpiece.
|
||||
* It will check the `useAsEarpiece` of the `audioOutput` device and return
|
||||
* the appropriate pan and volume values.
|
||||
*
|
||||
* @returns pan and volume values for the earpiece audio node configuration.
|
||||
*/
|
||||
export const useEarpieceAudioConfig = (): {
|
||||
pan: number;
|
||||
volume: number;
|
||||
} => {
|
||||
const { audioOutput } = useMediaDevices();
|
||||
// We use only the right speaker (pan = 1) for the earpiece.
|
||||
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
|
||||
const pan = useMemo(
|
||||
() => (audioOutput.useAsEarpiece ? 1 : 0),
|
||||
[audioOutput.useAsEarpiece],
|
||||
);
|
||||
// We also do lower the volume by a factor of 10 to optimize for the usecase where
|
||||
// a user is holding the phone to their ear.
|
||||
const volume = useMemo(
|
||||
() => (audioOutput.useAsEarpiece ? 0.1 : 1),
|
||||
[audioOutput.useAsEarpiece],
|
||||
);
|
||||
return { pan, volume };
|
||||
};
|
||||
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
supportsBackgroundProcessors,
|
||||
type BackgroundOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type JSX,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { test, vi } from "vitest";
|
||||
import { describe, expect, test, vi, vitest } from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { defer, sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { useECConnectionState } from "./useECConnectionState";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
@@ -57,7 +58,7 @@ test.each<[string, ConnectionError]>([
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState({}, false, mockRoom, sfuConfig);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
|
||||
@@ -73,3 +74,111 @@ test.each<[string, ConnectionError]>([
|
||||
screen.getByText("Insufficient capacity");
|
||||
},
|
||||
);
|
||||
|
||||
describe("Leaking connection prevention", () => {
|
||||
function createTestComponent(mockRoom: Room): FC {
|
||||
const TestComponent: FC = () => {
|
||||
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const connect = useCallback(
|
||||
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
|
||||
[],
|
||||
);
|
||||
useECConnectionState("default", false, mockRoom, sfuConfig);
|
||||
return <button onClick={connect}>Connect</button>;
|
||||
};
|
||||
return TestComponent;
|
||||
}
|
||||
|
||||
test("Should cancel pending connections when the component is unmounted", async () => {
|
||||
const connectCall = vi.fn();
|
||||
const pendingConnection = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: async () => {
|
||||
connectCall.call(undefined);
|
||||
return await pendingConnection.promise;
|
||||
},
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: () => [],
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(connectCall).toHaveBeenCalled();
|
||||
// unmount while the connection is pending
|
||||
unmount();
|
||||
|
||||
// resolve the pending connection
|
||||
pendingConnection.resolve();
|
||||
|
||||
await vitest.waitUntil(
|
||||
() => {
|
||||
return disconnectMock.mock.calls.length > 0;
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
interval: 100,
|
||||
},
|
||||
);
|
||||
|
||||
// There should be some cleaning up to avoid leaking an open connection
|
||||
expect(disconnectMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Should cancel about to open but not yet opened connection", async () => {
|
||||
const createTracksCall = vi.fn();
|
||||
const pendingCreateTrack = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
connect: connectMock,
|
||||
disconnect: disconnectMock,
|
||||
localParticipant: {
|
||||
getTrackPublication: () => {},
|
||||
createTracks: async () => {
|
||||
createTracksCall.call(undefined);
|
||||
await pendingCreateTrack.promise;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
const TestComponent = createTestComponent(mockRoom);
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(createTracksCall).toHaveBeenCalled();
|
||||
// unmount while createTracks is pending
|
||||
unmount();
|
||||
|
||||
// resolve createTracks
|
||||
pendingCreateTrack.resolve();
|
||||
|
||||
// Yield to the event loop to let the connection attempt finish
|
||||
await sleep(100);
|
||||
|
||||
// The operation should have been aborted before even calling connect.
|
||||
expect(connectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AudioCaptureOptions,
|
||||
ConnectionError,
|
||||
ConnectionState,
|
||||
type LocalTrack,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
InsufficientCapacityError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { AbortHandle } from "../utils/abortHandle.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -59,7 +59,8 @@ async function doConnect(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
audioEnabled: boolean,
|
||||
audioOptions: AudioCaptureOptions,
|
||||
initialDeviceId: string | undefined,
|
||||
abortHandle: AbortHandle,
|
||||
): Promise<void> {
|
||||
// Always create an audio track manually.
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
@@ -82,19 +83,40 @@ async function doConnect(
|
||||
let preCreatedAudioTrack: LocalTrack | undefined;
|
||||
try {
|
||||
const audioTracks = await livekitRoom!.localParticipant.createTracks({
|
||||
audio: audioOptions,
|
||||
audio: { deviceId: initialDeviceId },
|
||||
});
|
||||
|
||||
if (audioTracks.length < 1) {
|
||||
logger.info("Tried to pre-create local audio track but got no tracks");
|
||||
} else {
|
||||
preCreatedAudioTrack = audioTracks[0];
|
||||
}
|
||||
// There was a yield point previously (awaiting for the track to be created) so we need to check
|
||||
// if the operation was cancelled and stop connecting if needed.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Pre-created microphone track");
|
||||
} catch (e) {
|
||||
logger.error("Failed to pre-create microphone track", e);
|
||||
}
|
||||
|
||||
if (!audioEnabled) await preCreatedAudioTrack?.mute();
|
||||
if (!audioEnabled) {
|
||||
await preCreatedAudioTrack?.mute();
|
||||
// There was a yield point. Check if the operation was cancelled and stop connecting.
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
|
||||
);
|
||||
preCreatedAudioTrack?.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// check again having awaited for the track to create
|
||||
if (
|
||||
@@ -107,9 +129,18 @@ async function doConnect(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Connecting & publishing");
|
||||
logger.info("[Lifecycle] Connecting & publishing");
|
||||
try {
|
||||
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
|
||||
if (abortHandle.isAborted()) {
|
||||
logger.info(
|
||||
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
|
||||
);
|
||||
livekitRoom?.disconnect().catch((err) => {
|
||||
logger.error("Failed to disconnect from SFU", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
preCreatedAudioTrack?.stop();
|
||||
logger.debug("Stopped precreated audio tracks.");
|
||||
@@ -137,13 +168,16 @@ async function connectAndPublish(
|
||||
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
|
||||
try {
|
||||
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
||||
// Due to stability issues on Firefox we are testing the effect of different
|
||||
// timeouts, and allow these values to be set through the console
|
||||
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
|
||||
websocketTimeout: window.websocketTimeout ?? 45000,
|
||||
});
|
||||
logger.info(`[Lifecycle] ... connected to livekit room`);
|
||||
} catch (e) {
|
||||
logger.error("[Lifecycle] Failed to connect", e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
@@ -184,7 +218,7 @@ async function connectAndPublish(
|
||||
}
|
||||
|
||||
export function useECConnectionState(
|
||||
initialAudioOptions: AudioCaptureOptions,
|
||||
initialDeviceId: string | undefined,
|
||||
initialAudioEnabled: boolean,
|
||||
livekitRoom?: Room,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -247,6 +281,22 @@ export function useECConnectionState(
|
||||
|
||||
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
|
||||
|
||||
// Protection against potential leaks, where the component to be unmounted and there is
|
||||
// still a pending doConnect promise. This would lead the user to still be in the call even
|
||||
// if the component is unmounted.
|
||||
const abortHandlesBag = useRef(new Set<AbortHandle>());
|
||||
|
||||
// This is a cleanup function that will be called when the component is about to be unmounted.
|
||||
// It will cancel all abortHandles in the bag
|
||||
useEffect(() => {
|
||||
const bag = abortHandlesBag.current;
|
||||
return (): void => {
|
||||
bag.forEach((handle) => {
|
||||
handle.abort();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Id we are transitioning from a valid config to another valid one, we need
|
||||
// to explicitly switch focus
|
||||
useEffect(() => {
|
||||
@@ -273,11 +323,14 @@ export function useECConnectionState(
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
setIsInDoConnect(true);
|
||||
const abortHandle = new AbortHandle();
|
||||
abortHandlesBag.current.add(abortHandle);
|
||||
doConnect(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
initialAudioEnabled,
|
||||
initialAudioOptions,
|
||||
initialDeviceId,
|
||||
abortHandle,
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e instanceof ElementCallError) {
|
||||
@@ -286,14 +339,17 @@ export function useECConnectionState(
|
||||
setError(new UnknownCallError(e));
|
||||
} else logger.error("Failed to connect to SFU", e);
|
||||
})
|
||||
.finally(() => setIsInDoConnect(false));
|
||||
.finally(() => {
|
||||
abortHandlesBag.current.delete(abortHandle);
|
||||
setIsInDoConnect(false);
|
||||
});
|
||||
}
|
||||
|
||||
currentSFUConfig.current = Object.assign({}, sfuConfig);
|
||||
}, [
|
||||
sfuConfig,
|
||||
livekitRoom,
|
||||
initialAudioOptions,
|
||||
initialDeviceId,
|
||||
initialAudioEnabled,
|
||||
doFocusSwitch,
|
||||
]);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RoomOptions,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -24,11 +24,7 @@ import { map } from "rxjs";
|
||||
import { defaultLiveKitOptions } from "./options";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { type MuteStates } from "../room/MuteStates";
|
||||
import {
|
||||
type MediaDevice,
|
||||
type MediaDevices,
|
||||
useMediaDevices,
|
||||
} from "./MediaDevicesContext";
|
||||
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
|
||||
import {
|
||||
type ECConnectionState,
|
||||
useECConnectionState,
|
||||
@@ -40,41 +36,95 @@ import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "./TrackProcessorContext";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
connState: ECConnectionState;
|
||||
}
|
||||
|
||||
export function useLiveKit(
|
||||
export function useLivekit(
|
||||
rtcSession: MatrixRTCSession,
|
||||
muteStates: MuteStates,
|
||||
sfuConfig: SFUConfig | undefined,
|
||||
e2eeSystem: EncryptionSystem,
|
||||
): UseLivekitResult {
|
||||
const e2eeOptions = useMemo((): E2EEManagerOptions | undefined => {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
|
||||
const initialMuteStates = useInitial(() => muteStates);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const initialDevices = useInitial(() => devices);
|
||||
|
||||
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
||||
// to setMicrophoneEnabled/setCameraEnabled
|
||||
const audioMuteUpdating = useRef(false);
|
||||
const videoMuteUpdating = useRef(false);
|
||||
// Store the current button mute state that gets passed to this hook via props.
|
||||
// We need to store it for awaited code that relies on the current value.
|
||||
const buttonEnabled = useRef({
|
||||
audio: initialMuteStates.audio.enabled,
|
||||
video: initialMuteStates.video.enabled,
|
||||
});
|
||||
|
||||
const { processor } = useTrackProcessor();
|
||||
|
||||
// Only ever create the room once via useInitial.
|
||||
const room = useInitial(() => {
|
||||
logger.info("[LivekitRoom] Create LiveKit room");
|
||||
|
||||
let e2ee: E2EEManagerOptions | undefined;
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
logger.info("Created MatrixKeyProvider (per participant)");
|
||||
return {
|
||||
e2ee = {
|
||||
keyProvider: new MatrixKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
||||
|
||||
return {
|
||||
e2ee = {
|
||||
keyProvider: new ExternalE2EEKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
}
|
||||
}, [e2eeSystem]);
|
||||
|
||||
const roomOptions: RoomOptions = {
|
||||
...defaultLiveKitOptions,
|
||||
videoCaptureDefaults: {
|
||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||
deviceId: initialDevices.videoInput.selectedId,
|
||||
processor,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||
deviceId: initialDevices.audioInput.selectedId,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: initialDevices.audioOutput.selectedId,
|
||||
},
|
||||
e2ee,
|
||||
};
|
||||
// We have to create the room manually here due to a bug inside
|
||||
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
const room = new Room(roomOptions);
|
||||
room.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
||||
logger.error("Failed to set E2EE enabled on room", e);
|
||||
});
|
||||
|
||||
return room;
|
||||
});
|
||||
|
||||
// Setup and update the keyProvider which was create by `createRoom`
|
||||
useEffect(() => {
|
||||
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||
const e2eeOptions = room.options.e2ee;
|
||||
if (
|
||||
e2eeSystem.kind === E2eeType.NONE ||
|
||||
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
||||
)
|
||||
return;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||
@@ -85,55 +135,7 @@ export function useLiveKit(
|
||||
logger.error("Failed to set shared key for E2EE", e);
|
||||
});
|
||||
}
|
||||
}, [e2eeOptions, e2eeSystem, rtcSession]);
|
||||
|
||||
const initialMuteStates = useRef<MuteStates>(muteStates);
|
||||
const devices = useMediaDevices();
|
||||
const initialDevices = useRef<MediaDevices>(devices);
|
||||
|
||||
const { processor } = useTrackProcessor();
|
||||
const initialProcessor = useInitial(() => processor);
|
||||
const roomOptions = useMemo(
|
||||
(): RoomOptions => ({
|
||||
...defaultLiveKitOptions,
|
||||
videoCaptureDefaults: {
|
||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||
deviceId: initialDevices.current.videoInput.selectedId,
|
||||
processor: initialProcessor,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||
deviceId: initialDevices.current.audioInput.selectedId,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: initialDevices.current.audioOutput.selectedId,
|
||||
},
|
||||
e2ee: e2eeOptions,
|
||||
}),
|
||||
[e2eeOptions, initialProcessor],
|
||||
);
|
||||
|
||||
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
||||
// to setMicrophoneEnabled/setCameraEnabled
|
||||
const audioMuteUpdating = useRef(false);
|
||||
const videoMuteUpdating = useRef(false);
|
||||
// Store the current button mute state that gets passed to this hook via props.
|
||||
// We need to store it for awaited code that relies on the current value.
|
||||
const buttonEnabled = useRef({
|
||||
audio: initialMuteStates.current.audio.enabled,
|
||||
video: initialMuteStates.current.video.enabled,
|
||||
});
|
||||
|
||||
// We have to create the room manually here due to a bug inside
|
||||
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
const room = useMemo(() => {
|
||||
const r = new Room(roomOptions);
|
||||
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
||||
logger.error("Failed to set E2EE enabled on room", e);
|
||||
});
|
||||
return r;
|
||||
}, [roomOptions, e2eeSystem]);
|
||||
}, [room.options.e2ee, e2eeSystem, rtcSession]);
|
||||
|
||||
// Sync the requested track processors with LiveKit
|
||||
useTrackProcessorSync(
|
||||
@@ -155,10 +157,8 @@ export function useLiveKit(
|
||||
);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
{
|
||||
deviceId: initialDevices.current.audioInput.selectedId,
|
||||
},
|
||||
initialMuteStates.current.audio.enabled,
|
||||
initialDevices.audioInput.selectedId,
|
||||
initialMuteStates.audio.enabled,
|
||||
room,
|
||||
sfuConfig,
|
||||
);
|
||||
@@ -305,8 +305,15 @@ export function useLiveKit(
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the requested devices with LiveKit's devices
|
||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
||||
const syncDevice = (kind: MediaDeviceKind, device: MediaDevice): void => {
|
||||
if (
|
||||
room !== undefined &&
|
||||
connectionState === ConnectionState.Connected &&
|
||||
!controlledAudioDevices
|
||||
) {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
device: MediaDeviceHandle,
|
||||
): void => {
|
||||
const id = device.selectedId;
|
||||
|
||||
// Detect if we're trying to use chrome's default device, in which case
|
||||
@@ -362,7 +369,7 @@ export function useLiveKit(
|
||||
syncDevice("audiooutput", devices.audioOutput);
|
||||
syncDevice("videoinput", devices.videoInput);
|
||||
}
|
||||
}, [room, devices, connectionState]);
|
||||
}, [room, devices, connectionState, controlledAudioDevices]);
|
||||
|
||||
return {
|
||||
connState: connectionState,
|
||||
@@ -29,7 +29,7 @@ window.setLKLogLevel = setLKLogLevel;
|
||||
initRageshake().catch((e) => {
|
||||
logger.error("Failed to initialize rageshake", e);
|
||||
});
|
||||
setLKLogLevel("warn");
|
||||
setLKLogLevel("info");
|
||||
setLKLogExtension((level, msg, context) => {
|
||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
||||
global.mx_rage_logger.log(level, "livekit", msg, context);
|
||||
|
||||
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import {
|
||||
SimpleSpanProcessor,
|
||||
type SpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
||||
import opentelemetry, { type Tracer } from "@opentelemetry/api";
|
||||
import { Resource } from "@opentelemetry/resources";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
|
||||
@@ -59,34 +62,34 @@ export class ElementCallOpenTelemetry {
|
||||
collectorUrl: string | undefined,
|
||||
rageshakeUrl: string | undefined,
|
||||
) {
|
||||
// This is how we can make Jaeger show a reasonable service in the dropdown on the left.
|
||||
const providerConfig = {
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME,
|
||||
}),
|
||||
};
|
||||
this._provider = new WebTracerProvider(providerConfig);
|
||||
const spanProcessors: SpanProcessor[] = [];
|
||||
|
||||
if (collectorUrl) {
|
||||
logger.info("Enabling OTLP collector with URL " + collectorUrl);
|
||||
this.otlpExporter = new OTLPTraceExporter({
|
||||
url: collectorUrl,
|
||||
});
|
||||
this._provider.addSpanProcessor(
|
||||
new SimpleSpanProcessor(this.otlpExporter),
|
||||
);
|
||||
spanProcessors.push(new SimpleSpanProcessor(this.otlpExporter));
|
||||
} else {
|
||||
logger.info("OTLP collector disabled");
|
||||
}
|
||||
|
||||
if (rageshakeUrl) {
|
||||
this.rageshakeProcessor = new RageshakeSpanProcessor();
|
||||
this._provider.addSpanProcessor(this.rageshakeProcessor);
|
||||
spanProcessors.push(this.rageshakeProcessor);
|
||||
}
|
||||
|
||||
this._provider.addSpanProcessor(new PosthogSpanProcessor());
|
||||
opentelemetry.trace.setGlobalTracerProvider(this._provider);
|
||||
spanProcessors.push(new PosthogSpanProcessor());
|
||||
|
||||
this._provider = new WebTracerProvider({
|
||||
resource: resourceFromAttributes({
|
||||
// This is how we can make Jaeger show a reasonable service in the dropdown on the left.
|
||||
[ATTR_SERVICE_NAME]: SERVICE_NAME,
|
||||
}),
|
||||
spanProcessors,
|
||||
});
|
||||
|
||||
opentelemetry.trace.setGlobalTracerProvider(this._provider);
|
||||
this._tracer = opentelemetry.trace.getTracer(
|
||||
// This is not the serviceName shown in jaeger
|
||||
"my-element-call-otl-tracer",
|
||||
|
||||
@@ -47,12 +47,15 @@ export const callEventAudioSounds = prefetchSounds({
|
||||
|
||||
export function CallEventAudioRenderer({
|
||||
vm,
|
||||
muted,
|
||||
}: {
|
||||
vm: CallViewModel;
|
||||
muted?: boolean;
|
||||
}): ReactNode {
|
||||
const audioEngineCtx = useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
latencyHint: "interactive",
|
||||
muted,
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
ErrorIcon,
|
||||
ErrorSolidIcon,
|
||||
HostIcon,
|
||||
OfflineIcon,
|
||||
WebBrowserIcon,
|
||||
@@ -63,7 +63,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
icon = WebBrowserIcon;
|
||||
break;
|
||||
default:
|
||||
icon = ErrorIcon;
|
||||
icon = ErrorSolidIcon;
|
||||
}
|
||||
|
||||
const actions: { label: string; onClick: () => void }[] = [];
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useAudioContext } from "../useAudioContext";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import {
|
||||
flushPromises,
|
||||
mockEmitter,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRtcMembership,
|
||||
@@ -129,6 +130,7 @@ function createGroupCallView(
|
||||
getMxcAvatarUrl: () => null,
|
||||
getCanonicalAlias: () => null,
|
||||
currentState: {
|
||||
...mockEmitter(),
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
@@ -42,7 +43,10 @@ import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
saveKeyForRoom,
|
||||
useRoomEncryptionSystem,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
@@ -54,19 +58,19 @@ import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
ElementCallError,
|
||||
ErrorCode,
|
||||
RTCSessionError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||
import {
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
|
||||
useNewMembershipManager as useNewMembershipManagerSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -103,12 +107,14 @@ export const GroupCallView: FC<Props> = ({
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
latencyHint: "interactive",
|
||||
muted: muteAllAudio,
|
||||
}),
|
||||
);
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
@@ -118,6 +124,13 @@ export const GroupCallView: FC<Props> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info("[Lifecycle] GroupCallView Component mounted");
|
||||
return (): void => {
|
||||
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
return (): void => {
|
||||
@@ -128,14 +141,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
(error) => {
|
||||
setExternalError(
|
||||
new RTCSessionError(
|
||||
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
|
||||
error.message ?? error,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) => setExternalError(new ConnectionLostError()),
|
||||
);
|
||||
useEffect(() => {
|
||||
// Sanity check the room object
|
||||
@@ -156,6 +162,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
|
||||
// Save the password once we start the groupCallView
|
||||
const { password: passwordFromUrl } = useUrlParams();
|
||||
useEffect(() => {
|
||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
||||
}, [passwordFromUrl, room.roomId]);
|
||||
|
||||
usePageTitle(roomName);
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
|
||||
265
src/room/InCallView.test.tsx
Normal file
265
src/room/InCallView.test.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedFunction,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { act, render, type RenderResult } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { ConnectionState, type LocalParticipant } from "livekit-client";
|
||||
import { of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { InCallView } from "./InCallView";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
type MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import {
|
||||
developerMode as developerModeSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
} from "../settings/settings";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
|
||||
// vi.hoisted(() => {
|
||||
// localStorage = {} as unknown as Storage;
|
||||
// });
|
||||
vi.hoisted(
|
||||
() =>
|
||||
(global.ImageData = class MockImageData {
|
||||
public data: number[] = [];
|
||||
} as unknown as typeof ImageData),
|
||||
);
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
vi.mock("../tile/GridTile");
|
||||
vi.mock("../tile/SpotlightTile");
|
||||
vi.mock("@livekit/components-react");
|
||||
vi.mock("../e2ee/sharedKeyManagement");
|
||||
vi.mock("../livekit/MatrixAudioRenderer");
|
||||
vi.mock("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
}));
|
||||
|
||||
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
const localParticipant = mockLocalParticipant({
|
||||
identity: "@local:example.org:AAAAAA",
|
||||
});
|
||||
const remoteParticipant = mockRemoteParticipant({
|
||||
identity: "@alice:example.org:AAAAAA",
|
||||
});
|
||||
const carol = mockMatrixRoomMember(localRtcMember);
|
||||
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
||||
|
||||
const roomId = "!foo:bar";
|
||||
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// MatrixAudioRenderer is tested separately.
|
||||
(
|
||||
MatrixAudioRenderer as MockedFunction<typeof MatrixAudioRenderer>
|
||||
).mockImplementation((_props) => {
|
||||
return <div>mocked: MatrixAudioRenderer</div>;
|
||||
});
|
||||
(
|
||||
useLocalParticipant as MockedFunction<typeof useLocalParticipant>
|
||||
).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isScreenShareEnabled: false,
|
||||
localParticipant: localRtcMember as unknown as LocalParticipant,
|
||||
}) as unknown as ReturnType<typeof useLocalParticipant>,
|
||||
);
|
||||
useRoomEncryptionSystemMock =
|
||||
useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock;
|
||||
useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE });
|
||||
});
|
||||
|
||||
function createInCallView(): RenderResult & {
|
||||
rtcSession: MockRTCSession;
|
||||
} {
|
||||
const client = {
|
||||
getUser: () => null,
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getRoom: (rId) => (rId === roomId ? room : null),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const room = mockMatrixRoom({
|
||||
relations: {
|
||||
getChildEventsForEvent: () =>
|
||||
vi.mocked({
|
||||
getRelations: () => [],
|
||||
}),
|
||||
} as unknown as RelationsContainer,
|
||||
client,
|
||||
roomId,
|
||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
getMxcAvatarUrl: () => null,
|
||||
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
|
||||
getCanonicalAlias: () => null,
|
||||
currentState: {
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
} as MuteStates;
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{
|
||||
localParticipant,
|
||||
},
|
||||
{
|
||||
remoteParticipants$: of([remoteParticipant]),
|
||||
},
|
||||
);
|
||||
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
|
||||
|
||||
rtcSession.joined = true;
|
||||
const renderResult = render(
|
||||
<BrowserRouter>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext.Provider value={livekitRoom}>
|
||||
<InCallView
|
||||
client={client}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
connState={ConnectionState.Connected}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
...renderResult,
|
||||
rtcSession,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InCallView", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders", () => {
|
||||
const { container } = createInCallView();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("toDevice label", () => {
|
||||
it("is shown if setting activated and room encrypted", () => {
|
||||
useRoomEncryptionSystemMock.mockReturnValue({
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
});
|
||||
useExperimentalToDeviceTransportSetting.setValue(true);
|
||||
developerModeSetting.setValue(true);
|
||||
const { getByText } = createInCallView();
|
||||
expect(getByText("using to Device key transport")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown in unenecrypted room", () => {
|
||||
useRoomEncryptionSystemMock.mockReturnValue({
|
||||
kind: E2eeType.NONE,
|
||||
});
|
||||
useExperimentalToDeviceTransportSetting.setValue(true);
|
||||
developerModeSetting.setValue(true);
|
||||
const { queryByText } = createInCallView();
|
||||
expect(
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden once fallback was triggered", async () => {
|
||||
useRoomEncryptionSystemMock.mockReturnValue({
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
});
|
||||
useExperimentalToDeviceTransportSetting.setValue(true);
|
||||
developerModeSetting.setValue(true);
|
||||
const { rtcSession, queryByText } = createInCallView();
|
||||
expect(queryByText("using to Device key transport")).toBeInTheDocument();
|
||||
expect(rtcSession).toBeDefined();
|
||||
await act(() =>
|
||||
rtcSession.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, {
|
||||
toDevice: true,
|
||||
room: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
it("is not shown if setting is disabled", () => {
|
||||
useExperimentalToDeviceTransportSetting.setValue(false);
|
||||
developerModeSetting.setValue(true);
|
||||
useRoomEncryptionSystemMock.mockReturnValue({
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
});
|
||||
const { queryByText } = createInCallView();
|
||||
expect(
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
it("is not shown if developer mode is disabled", () => {
|
||||
useExperimentalToDeviceTransportSetting.setValue(true);
|
||||
developerModeSetting.setValue(false);
|
||||
useRoomEncryptionSystemMock.mockReturnValue({
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
});
|
||||
const { queryByText } = createInCallView();
|
||||
expect(
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RoomAudioRenderer,
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
} from "@livekit/components-react";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { ConnectionState, type Room } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -56,7 +52,7 @@ import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useLivekit } from "../livekit/useLivekit.ts";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
@@ -73,7 +69,10 @@ import {
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
useRoomEncryptionSystem,
|
||||
type EncryptionSystem,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
@@ -96,12 +95,16 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
developerMode as developerModeSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
import { ConnectionLostError } from "../utils/errors.ts";
|
||||
import { useTypedEventEmitter } from "../useEvents.ts";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -114,7 +117,7 @@ export interface ActiveCallProps
|
||||
|
||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
|
||||
const { livekitRoom, connState } = useLiveKit(
|
||||
const { livekitRoom, connState } = useLivekit(
|
||||
props.rtcSession,
|
||||
props.muteStates,
|
||||
sfuConfig,
|
||||
@@ -127,13 +130,25 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const [vm, setVm] = useState<CallViewModel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
|
||||
);
|
||||
return (): void => {
|
||||
livekitRoom?.disconnect().catch((e) => {
|
||||
logger.error("Failed to disconnect from livekit room", e);
|
||||
});
|
||||
logger.info(
|
||||
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
|
||||
);
|
||||
livekitRoom
|
||||
?.disconnect()
|
||||
.then(() => {
|
||||
logger.info(
|
||||
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [livekitRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
@@ -220,19 +235,35 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const [toDeviceEncryptionSetting] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const [showToDeviceEncryption, setShowToDeviceEncryption] = useState(
|
||||
() => toDeviceEncryptionSetting,
|
||||
);
|
||||
useEffect(() => {
|
||||
setShowToDeviceEncryption(toDeviceEncryptionSetting);
|
||||
}, [toDeviceEncryptionSetting]);
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
|
||||
// This seems like it might be enough logic to use move it into the call view model?
|
||||
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
RoomAndToDeviceEvents.EnabledTransportsChanged,
|
||||
(enabled) => setShowToDeviceEncryption(enabled.to_device),
|
||||
(enabled) => setDidFallbackToRoomKey(enabled.room),
|
||||
);
|
||||
|
||||
const [developerMode] = useSetting(developerModeSetting);
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
() =>
|
||||
developerMode &&
|
||||
useExperimentalToDeviceTransport &&
|
||||
encryptionSystem.kind === E2eeType.PER_PARTICIPANT &&
|
||||
!didFallbackToRoomKey,
|
||||
[
|
||||
developerMode,
|
||||
useExperimentalToDeviceTransport,
|
||||
encryptionSystem.kind,
|
||||
didFallbackToRoomKey,
|
||||
],
|
||||
);
|
||||
|
||||
const toggleMicrophone = useCallback(
|
||||
@@ -693,10 +724,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
<RoomAudioRenderer />
|
||||
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} />
|
||||
<ReactionsAudioRenderer vm={vm} />
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import useClipboard from "react-use-clipboard";
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
@@ -42,18 +42,17 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
);
|
||||
const [, setCopied] = useClipboard(url);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
||||
|
||||
const onButtonClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCopied();
|
||||
copy(url);
|
||||
onDismiss();
|
||||
setToastOpen(true);
|
||||
},
|
||||
[setCopied, onDismiss],
|
||||
[url, onDismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useMemo, useState, type JSX } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type JSX,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
@@ -46,6 +53,7 @@ import {
|
||||
useTrackProcessorSync,
|
||||
} from "../livekit/TrackProcessorContext";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -72,6 +80,13 @@ export const LobbyView: FC<Props> = ({
|
||||
onShareClick,
|
||||
waitingForInvite,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
logger.info("[Lifecycle] GroupCallView Component mounted");
|
||||
return (): void => {
|
||||
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(matrixInfo.roomName);
|
||||
|
||||
@@ -145,13 +160,14 @@ export const LobbyView: FC<Props> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
muteStates.video.setEnabled?.(false);
|
||||
latestMuteStates.current.audio.setEnabled?.(false);
|
||||
latestMuteStates.current.video.setEnabled?.(false);
|
||||
},
|
||||
[muteStates.audio, muteStates.video],
|
||||
[latestMuteStates],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
|
||||
@@ -14,7 +14,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import {
|
||||
type DeviceLabel,
|
||||
type MediaDevice,
|
||||
type MediaDeviceHandle,
|
||||
type MediaDevices,
|
||||
MediaDevicesContext,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
@@ -73,12 +73,13 @@ const mockCamera: MediaDeviceInfo = {
|
||||
},
|
||||
};
|
||||
|
||||
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice {
|
||||
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
|
||||
return {
|
||||
available,
|
||||
selectedId: "",
|
||||
selectedGroupId: "",
|
||||
select: (): void => {},
|
||||
useAsEarpiece: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
type MediaDevice,
|
||||
type MediaDeviceHandle,
|
||||
useMediaDevices,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
@@ -53,14 +53,14 @@ export interface MuteStates {
|
||||
}
|
||||
|
||||
function useMuteState(
|
||||
device: MediaDevice,
|
||||
device: MediaDeviceHandle,
|
||||
enabledByDefault: () => boolean,
|
||||
): MuteState {
|
||||
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
|
||||
// Determine the default value once devices are actually connected
|
||||
(prev) =>
|
||||
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
|
||||
[device],
|
||||
[device.available.size],
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -70,7 +70,7 @@ function useMuteState(
|
||||
enabled: enabled ?? false,
|
||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||
},
|
||||
[device, enabled, setEnabled],
|
||||
[device.available.size, enabled, setEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import { act, type ReactNode } from "react";
|
||||
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import {
|
||||
playReactionsSound,
|
||||
soundEffectVolumeSetting,
|
||||
playReactionsSound as playReactionsSoundSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
} from "../settings/settings";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
@@ -50,7 +50,7 @@ vitest.mock("../soundUtils");
|
||||
|
||||
afterEach(() => {
|
||||
vitest.resetAllMocks();
|
||||
playReactionsSound.setValue(playReactionsSound.defaultValue);
|
||||
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
|
||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ beforeEach(() => {
|
||||
|
||||
test("preloads all audio elements", () => {
|
||||
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
|
||||
playReactionsSound.setValue(true);
|
||||
playReactionsSoundSetting.setValue(true);
|
||||
render(<TestComponent vm={vm} />);
|
||||
expect(prefetchSounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -84,7 +84,7 @@ test("will play an audio sound when there is a reaction", () => {
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
playReactionsSoundSetting.setValue(true);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
@@ -110,7 +110,7 @@ test("will play the generic audio sound when there is soundless reaction", () =>
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
playReactionsSoundSetting.setValue(true);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
@@ -136,7 +136,7 @@ test("will play multiple audio sounds when there are multiple different reaction
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
playReactionsSoundSetting.setValue(true);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
|
||||
@@ -24,8 +24,10 @@ const soundMap = Object.fromEntries([
|
||||
|
||||
export function ReactionsAudioRenderer({
|
||||
vm,
|
||||
muted,
|
||||
}: {
|
||||
vm: CallViewModel;
|
||||
muted?: boolean;
|
||||
}): ReactNode {
|
||||
const [shouldPlay] = useSetting(playReactionsSound);
|
||||
const [soundCache, setSoundCache] = useState<ReturnType<
|
||||
@@ -34,6 +36,7 @@ export function ReactionsAudioRenderer({
|
||||
const audioEngineCtx = useAudioContext({
|
||||
sounds: soundCache,
|
||||
latencyHint: "interactive",
|
||||
muted,
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
|
||||
@@ -108,10 +108,11 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -119,12 +120,12 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.5 3.5a1 1 0 1 0-1.414 1.414l4.113 4.114a5.484 5.484 0 0 0-.184 1.069A5.002 5.002 0 0 0 7 20h10.172l1.914 1.914A1 1 0 0 0 20.5 20.5l-.979-.979.004-.002L7.145 7.14l-.002.004L3.5 3.5Zm18.5 12a4.48 4.48 0 0 1-.928 2.738L8.637 5.803A5.474 5.474 0 0 1 11.5 5a5.49 5.49 0 0 1 4.25 2.008 4 4 0 0 1 4.187 4.708A4.497 4.497 0 0 1 22 15.5Z"
|
||||
d="M3.5 3.5a1 1 0 1 0-1.414 1.414l4.113 4.114a5.5 5.5 0 0 0-.184 1.07A5.002 5.002 0 0 0 7 20h10.172l1.914 1.914A1 1 0 1 0 20.5 20.5l-.979-.979.004-.001L7.145 7.14l-.002.003zm18.5 12a4.48 4.48 0 0 1-.928 2.738L8.637 5.803A5.47 5.47 0 0 1 11.5 5a5.49 5.49 0 0 1 4.25 2.008 4 4 0 0 1 4.187 4.708A4.5 4.5 0 0 1 22 15.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Connection lost
|
||||
</h1>
|
||||
@@ -135,7 +136,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -258,10 +259,11 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -269,17 +271,17 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.712 6.713A.968.968 0 0 1 16 7a.968.968 0 0 1-.713-.287A.967.967 0 0 1 15 6c0-.283.096-.52.287-.713A.968.968 0 0 1 16 5a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713Z"
|
||||
d="M16.713 6.713A.97.97 0 0 1 16 7a.97.97 0 0 1-.713-.287A.97.97 0 0 1 15 6q0-.424.287-.713A.97.97 0 0 1 16 5q.424 0 .712.287Q17 5.576 17 6t-.288.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H6Zm12 2H6v4h12V4ZM6 12v-2h12v2H6Zm0 2v2h12v-2H6Zm0 6v-2h12v2H6Z"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm12 2H6v4h12zM6 12v-2h12v2zm0 2v2h12v-2zm0 6v-2h12v2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Call is not supported
|
||||
</h1>
|
||||
@@ -287,7 +289,7 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_i91xf_17"
|
||||
class="_button_vczzf_8"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -410,10 +412,11 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -421,17 +424,17 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.712 6.713A.968.968 0 0 1 16 7a.968.968 0 0 1-.713-.287A.967.967 0 0 1 15 6c0-.283.096-.52.287-.713A.968.968 0 0 1 16 5a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713Z"
|
||||
d="M16.713 6.713A.97.97 0 0 1 16 7a.97.97 0 0 1-.713-.287A.97.97 0 0 1 15 6q0-.424.287-.713A.97.97 0 0 1 16 5q.424 0 .712.287Q17 5.576 17 6t-.288.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H6Zm12 2H6v4h12V4ZM6 12v-2h12v2H6Zm0 2v2h12v-2H6Zm0 6v-2h12v2H6Z"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm12 2H6v4h12zM6 12v-2h12v2zm0 2v2h12v-2zm0 6v-2h12v2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Call is not supported
|
||||
</h1>
|
||||
@@ -439,7 +442,7 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -562,10 +565,11 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -573,17 +577,17 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.712 6.713A.968.968 0 0 1 16 7a.968.968 0 0 1-.713-.287A.967.967 0 0 1 15 6c0-.283.096-.52.287-.713A.968.968 0 0 1 16 5a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713Z"
|
||||
d="M16.713 6.713A.97.97 0 0 1 16 7a.97.97 0 0 1-.713-.287A.97.97 0 0 1 15 6q0-.424.287-.713A.97.97 0 0 1 16 5q.424 0 .712.287Q17 5.576 17 6t-.288.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H6Zm12 2H6v4h12V4ZM6 12v-2h12v2H6Zm0 2v2h12v-2H6Zm0 6v-2h12v2H6Z"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm12 2H6v4h12zM6 12v-2h12v2zm0 2v2h12v-2zm0 6v-2h12v2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Call is not supported
|
||||
</h1>
|
||||
@@ -591,7 +595,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
|
||||
</p>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -714,10 +718,11 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -725,12 +730,12 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.5 3.5a1 1 0 1 0-1.414 1.414l4.113 4.114a5.484 5.484 0 0 0-.184 1.069A5.002 5.002 0 0 0 7 20h10.172l1.914 1.914A1 1 0 0 0 20.5 20.5l-.979-.979.004-.002L7.145 7.14l-.002.004L3.5 3.5Zm18.5 12a4.48 4.48 0 0 1-.928 2.738L8.637 5.803A5.474 5.474 0 0 1 11.5 5a5.49 5.49 0 0 1 4.25 2.008 4 4 0 0 1 4.187 4.708A4.497 4.497 0 0 1 22 15.5Z"
|
||||
d="M3.5 3.5a1 1 0 1 0-1.414 1.414l4.113 4.114a5.5 5.5 0 0 0-.184 1.07A5.002 5.002 0 0 0 7 20h10.172l1.914 1.914A1 1 0 1 0 20.5 20.5l-.979-.979.004-.001L7.145 7.14l-.002.003zm18.5 12a4.48 4.48 0 0 1-.928 2.738L8.637 5.803A5.47 5.47 0 0 1 11.5 5a5.49 5.49 0 0 1 4.25 2.008 4 4 0 0 1 4.187 4.708A4.5 4.5 0 0 1 22 15.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Connection lost
|
||||
</h1>
|
||||
@@ -741,7 +746,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -864,10 +869,11 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -875,12 +881,12 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
|
||||
d="M4 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18V6q0-.824.587-1.412A1.93 1.93 0 0 1 4 4h16q.824 0 1.413.588Q22 5.175 22 6v12q0 .824-.587 1.413A1.93 1.93 0 0 1 20 20zm0-2h16V8H4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Incompatible browser
|
||||
</h1>
|
||||
@@ -888,7 +894,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.
|
||||
</p>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -1011,10 +1017,11 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
class="error"
|
||||
>
|
||||
<div
|
||||
class="_content_md016_17 icon"
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1022,12 +1029,12 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
|
||||
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Insufficient capacity
|
||||
</h1>
|
||||
@@ -1035,7 +1042,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.
|
||||
</p>
|
||||
<button
|
||||
class="_button_i91xf_17 homeLink"
|
||||
class="_button_vczzf_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
181
src/room/__snapshots__/InCallView.test.tsx.snap
Normal file
181
src/room/__snapshots__/InCallView.test.tsx.snap
Normal file
@@ -0,0 +1,181 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InCallView > rendering > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="inRoom"
|
||||
>
|
||||
<div
|
||||
class="header filler"
|
||||
/>
|
||||
<div>
|
||||
mocked: MatrixAudioRenderer
|
||||
</div>
|
||||
<div
|
||||
class="scrollingGrid grid"
|
||||
>
|
||||
<div
|
||||
class="layer"
|
||||
>
|
||||
<div
|
||||
class="container slot"
|
||||
data-id="1"
|
||||
>
|
||||
<div
|
||||
class="slot local slot"
|
||||
data-block-alignment="start"
|
||||
data-id="0"
|
||||
data-inline-alignment="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixedGrid grid"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
/>
|
||||
<div
|
||||
class="footer"
|
||||
>
|
||||
<div
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r0»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r5»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«ra»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.731 2C13.432 2 14 2.568 14 3.269c0 .578.396 1.074.935 1.286q.128.052.253.106c.531.23 1.162.16 1.572-.25a1.27 1.27 0 0 1 1.794 0l1.034 1.035a1.27 1.27 0 0 1 0 1.794c-.41.41-.48 1.04-.248 1.572l.105.253c.212.539.708.935 1.286.935.701 0 1.269.568 1.269 1.269v1.462c0 .701-.568 1.269-1.269 1.269-.578 0-1.074.396-1.287.935q-.05.128-.104.253c-.232.531-.161 1.162.248 1.572a1.27 1.27 0 0 1 0 1.794l-1.034 1.034a1.27 1.27 0 0 1-1.794 0c-.41-.41-1.04-.48-1.572-.248a8 8 0 0 1-.253.105c-.539.212-.935.708-.935 1.286 0 .701-.568 1.269-1.269 1.269H11.27c-.702 0-1.27-.568-1.27-1.269 0-.578-.396-1.074-.935-1.287a8 8 0 0 1-.253-.104c-.531-.232-1.162-.161-1.572.248a1.27 1.27 0 0 1-1.794 0l-1.034-1.034a1.27 1.27 0 0 1 0-1.794c.41-.41.48-1.04.249-1.572a8 8 0 0 1-.106-.253C4.343 14.396 3.847 14 3.27 14 2.568 14 2 13.432 2 12.731V11.27c0-.702.568-1.27 1.269-1.27.578 0 1.074-.396 1.286-.935q.052-.128.106-.253c.23-.531.16-1.162-.25-1.572a1.27 1.27 0 0 1 0-1.794l1.035-1.034a1.27 1.27 0 0 1 1.794 0c.41.41 1.04.48 1.572.249a8 8 0 0 1 .253-.106c.539-.212.935-.708.935-1.286C10 2.568 10.568 2 11.269 2zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rf»"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="«rk»"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
/>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 5h14v8h-5a1 1 0 0 0-1 1v5H5zm10 14v-4h4v4zM5 21h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="«rp»"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="grid"
|
||||
/>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
/**
|
||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||
* This logic is specific to livekit foci where the whole call must use one
|
||||
@@ -22,38 +24,27 @@ import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
export function useActiveLivekitFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const [activeFocus, setActiveFocus] = useState(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
});
|
||||
const activeFocus = useTypedEventEmitterState(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
useCallback(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
}, [rtcSession]),
|
||||
);
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
const newActiveFocus = rtcSession.getActiveFocus();
|
||||
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||
const prevActiveFocus = useRef(activeFocus);
|
||||
useEffect(() => {
|
||||
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
||||
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`,
|
||||
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
|
||||
);
|
||||
setActiveFocus(newActiveFocus);
|
||||
prevActiveFocus.current = activeFocus;
|
||||
}
|
||||
}, [activeFocus, rtcSession]);
|
||||
|
||||
useEffect(() => {
|
||||
rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return activeFocus;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk";
|
||||
import { useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
export function useRoomName(room: Room): string {
|
||||
const [, setNumUpdates] = useState(0);
|
||||
// Whenever the name changes, force an update
|
||||
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1));
|
||||
return room.name;
|
||||
return useTypedEventEmitterState(
|
||||
room,
|
||||
RoomEvent.Name,
|
||||
useCallback(() => room.name, [room]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
/**
|
||||
* A React hook for values computed from room state.
|
||||
@@ -16,14 +21,17 @@ import { useTypedEventEmitter } from "../useEvents";
|
||||
* @param f A mapping from the current room state to the computed value.
|
||||
* @returns The computed value.
|
||||
*/
|
||||
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => {
|
||||
const [numUpdates, setNumUpdates] = useState(0);
|
||||
useTypedEventEmitter(
|
||||
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
|
||||
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
|
||||
// clear how to reactively track the current state of the room without it
|
||||
const currentState = useTypedEventEmitterState(
|
||||
room,
|
||||
RoomStateEvent.Update,
|
||||
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]),
|
||||
RoomEvent.CurrentStateUpdated,
|
||||
useCallback(() => room.currentState, [room]),
|
||||
);
|
||||
// We want any change to the update counter to trigger an update here
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => f(room.currentState), [room, f, numUpdates]);
|
||||
};
|
||||
return useTypedEventEmitterState(
|
||||
currentState,
|
||||
RoomStateEvent.Update,
|
||||
useCallback(() => f(currentState), [f, currentState]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,11 +121,18 @@ export async function enterRTCSession(
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
membershipServerSideExpiryTimeout:
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
membershipKeepAlivePeriod:
|
||||
delayedLeaveEventRestartMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
|
||||
matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
delayedLeaveEventDelayMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||
makeKeyDelay:
|
||||
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
|
||||
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
membershipEventExpiryMs:
|
||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||
useExperimentalToDeviceTransport,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
showNonMemberTiles as showNonMemberTilesSetting,
|
||||
showConnectionStats as showConnectionStatsSetting,
|
||||
useNewMembershipManagerSetting,
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
useNewMembershipManager as useNewMembershipManagerSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
muteAllAudio as muteAllAudioSetting,
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
} from "./settings";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
@@ -45,10 +47,16 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
|
||||
useNewMembershipManagerSetting,
|
||||
);
|
||||
|
||||
const [alwaysShowIphoneEarpiece, setAlwaysShowIphoneEarpiece] = useSetting(
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
const [
|
||||
useExperimentalToDeviceTransport,
|
||||
setUseExperimentalToDeviceTransport,
|
||||
] = useSetting(useExperimentalToDeviceTransportSetting);
|
||||
|
||||
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
|
||||
|
||||
const urlParams = useUrlParams();
|
||||
|
||||
const sfuUrl = useMemo((): URL | null => {
|
||||
@@ -175,6 +183,34 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="muteAllAudio"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.mute_all_audio")}
|
||||
checked={muteAllAudio}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setMuteAllAudio(event.target.checked);
|
||||
},
|
||||
[setMuteAllAudio],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>{" "}
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="alwaysShowIphoneEarpiece"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.always_show_iphone_earpiece")}
|
||||
checked={alwaysShowIphoneEarpiece}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setAlwaysShowIphoneEarpiece(event.target.checked);
|
||||
},
|
||||
[setAlwaysShowIphoneEarpiece],
|
||||
)}
|
||||
/>{" "}
|
||||
</FieldRow>
|
||||
{livekitRoom ? (
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -22,17 +22,20 @@ import {
|
||||
} from "@vector-im/compound-web";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { type MediaDevice } from "../livekit/MediaDevicesContext";
|
||||
import {
|
||||
EARPIECE_CONFIG_ID,
|
||||
type MediaDeviceHandle,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import styles from "./DeviceSelection.module.css";
|
||||
|
||||
interface Props {
|
||||
devices: MediaDevice;
|
||||
device: MediaDeviceHandle;
|
||||
title: string;
|
||||
numberedLabel: (number: number) => string;
|
||||
}
|
||||
|
||||
export const DeviceSelection: FC<Props> = ({
|
||||
devices,
|
||||
device,
|
||||
title,
|
||||
numberedLabel,
|
||||
}) => {
|
||||
@@ -40,12 +43,13 @@ export const DeviceSelection: FC<Props> = ({
|
||||
const groupId = useId();
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
devices.select(e.target.value);
|
||||
device.select(e.target.value);
|
||||
},
|
||||
[devices],
|
||||
[device],
|
||||
);
|
||||
|
||||
if (devices.available.size == 0) return null;
|
||||
// There is no need to show the menu if there is no choice that can be made.
|
||||
if (device.available.size <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.selection}>
|
||||
@@ -60,7 +64,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
</Heading>
|
||||
<Separator className={styles.separator} />
|
||||
<div className={styles.options}>
|
||||
{[...devices.available].map(([id, label]) => {
|
||||
{[...device.available].map(([id, label]) => {
|
||||
let labelText: ReactNode;
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
@@ -85,6 +89,16 @@ export const DeviceSelection: FC<Props> = ({
|
||||
</Trans>
|
||||
);
|
||||
break;
|
||||
case "earpiece":
|
||||
labelText = t("settings.devices.earpiece");
|
||||
break;
|
||||
}
|
||||
|
||||
let isSelected = false;
|
||||
if (device.useAsEarpiece) {
|
||||
isSelected = id === EARPIECE_CONFIG_ID;
|
||||
} else {
|
||||
isSelected = id === device.selectedId;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -93,7 +107,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
name={groupId}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={id === devices.selectedId}
|
||||
checked={isSelected}
|
||||
onChange={onChange}
|
||||
value={id}
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const optInDescription = (
|
||||
<Text size="sm">
|
||||
<Text size="sm" as="span">
|
||||
<Trans i18nKey="settings.opt_in_description">
|
||||
<AnalyticsNotice />
|
||||
<br />
|
||||
|
||||
@@ -8,8 +8,9 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
@@ -19,11 +20,12 @@ import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
import {
|
||||
useMediaDevices,
|
||||
useMediaDeviceNames,
|
||||
iosDeviceMenu$,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
useSetting,
|
||||
soundEffectVolumeSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
backgroundBlur as backgroundBlurSetting,
|
||||
developerMode,
|
||||
} from "./settings";
|
||||
@@ -34,6 +36,7 @@ import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { useSubmitRageshake } from "./submit-rageshake";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -98,29 +101,52 @@ export const SettingsModal: FC<Props> = ({
|
||||
useMediaDeviceNames(devices, open);
|
||||
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
|
||||
|
||||
const [showDeveloperSettingsTab] = useSetting(developerMode);
|
||||
|
||||
const { available: isRageshakeAvailable } = useSubmitRageshake();
|
||||
|
||||
// For controlled devices, we will not show the input section:
|
||||
// Controlled media devices are used on mobile platforms, where input and output are grouped into
|
||||
// a single device. These are called "headset" or "speaker" (or similar) but contain both input and output.
|
||||
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
||||
// rather than the input section.
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
// If we are on iOS we will show a button to open the native audio device picker.
|
||||
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$);
|
||||
|
||||
const audioTab: Tab<SettingsTab> = {
|
||||
key: "audio",
|
||||
name: t("common.audio"),
|
||||
content: (
|
||||
<>
|
||||
<Form>
|
||||
{!controlledAudioDevices && (
|
||||
<DeviceSelection
|
||||
device={devices.audioInput}
|
||||
title={t("settings.devices.microphone")}
|
||||
numberedLabel={(n) =>
|
||||
t("settings.devices.microphone_numbered", { n })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{iosDeviceMenu && controlledAudioDevices && (
|
||||
<Button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
window.controls.showNativeAudioDevicePicker?.();
|
||||
// call deprecated method for backwards compatibility.
|
||||
window.controls.showNativeOutputDevicePicker?.();
|
||||
}}
|
||||
>
|
||||
{t("settings.devices.change_device_button")}
|
||||
</Button>
|
||||
)}
|
||||
<DeviceSelection
|
||||
devices={devices.audioInput}
|
||||
title={t("settings.devices.microphone")}
|
||||
numberedLabel={(n) =>
|
||||
t("settings.devices.microphone_numbered", { n })
|
||||
}
|
||||
/>
|
||||
<DeviceSelection
|
||||
devices={devices.audioOutput}
|
||||
device={devices.audioOutput}
|
||||
title={t("settings.devices.speaker")}
|
||||
numberedLabel={(n) => t("settings.devices.speaker_numbered", { n })}
|
||||
/>
|
||||
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>{t("settings.audio_tab.effect_volume_label")}</label>
|
||||
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
||||
@@ -146,7 +172,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
<>
|
||||
<Form>
|
||||
<DeviceSelection
|
||||
devices={devices.videoInput}
|
||||
device={devices.videoInput}
|
||||
title={t("settings.devices.camera")}
|
||||
numberedLabel={(n) => t("settings.devices.camera_numbered", { n })}
|
||||
/>
|
||||
|
||||
@@ -44,6 +44,9 @@ export class Setting<T> {
|
||||
this._value$.next(value);
|
||||
localStorage.setItem(this.key, JSON.stringify(value));
|
||||
};
|
||||
public readonly getValue = (): T => {
|
||||
return this._value$.getValue();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,19 +113,26 @@ export const playReactionsSound = new Setting<boolean>(
|
||||
true,
|
||||
);
|
||||
|
||||
export const soundEffectVolumeSetting = new Setting<number>(
|
||||
export const soundEffectVolume = new Setting<number>(
|
||||
"sound-effect-volume",
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const useNewMembershipManagerSetting = new Setting<boolean>(
|
||||
export const useNewMembershipManager = new Setting<boolean>(
|
||||
"new-membership-manager",
|
||||
true,
|
||||
);
|
||||
|
||||
export const useExperimentalToDeviceTransportSetting = new Setting<boolean>(
|
||||
export const useExperimentalToDeviceTransport = new Setting<boolean>(
|
||||
"experimental-to-device-transport",
|
||||
true,
|
||||
);
|
||||
|
||||
export const muteAllAudio = new Setting<boolean>("mute-all-audio", false);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
|
||||
export const alwaysShowIphoneEarpiece = new Setting<boolean>(
|
||||
"always-show-iphone-earpiece",
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
import { isEqual } from "lodash-es";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { CallViewModel, type Layout } from "./CallViewModel";
|
||||
import {
|
||||
@@ -200,7 +200,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
// care about the most recent value for each time step, so discard these
|
||||
// extra values.
|
||||
debounceTime(0),
|
||||
distinctUntilChanged(isEqual),
|
||||
distinctUntilChanged(deepCompare),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
src/state/MuteAllAudioModel.test.ts
Normal file
36
src/state/MuteAllAudioModel.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi } from "vitest";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
||||
import { muteAllAudio$ } from "./MuteAllAudioModel";
|
||||
|
||||
test("muteAllAudio$", () => {
|
||||
const valueMock = vi.fn();
|
||||
const muteAllAudio = muteAllAudio$.subscribe((value) => {
|
||||
valueMock(value);
|
||||
});
|
||||
|
||||
setAudioEnabled$.next(false);
|
||||
setAudioEnabled$.next(true);
|
||||
muteAllAudioSetting.setValue(false);
|
||||
muteAllAudioSetting.setValue(true);
|
||||
setAudioEnabled$.next(false);
|
||||
|
||||
muteAllAudio.unsubscribe();
|
||||
|
||||
expect(valueMock).toHaveBeenCalledTimes(6);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user