mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
238 Commits
no-app-pro
...
fkwp/fix-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b0165961 | ||
|
|
7e932d27da | ||
|
|
a1e03ea389 | ||
|
|
f3234b1c3e | ||
|
|
53acd2596c | ||
|
|
cec3a799af | ||
|
|
77f4704b20 | ||
|
|
53e9d05587 | ||
|
|
b9f73e3e9a | ||
|
|
408a38a7d6 | ||
|
|
f4ff790d2c | ||
|
|
bd2de29470 | ||
|
|
069074d241 | ||
|
|
78417ff2cb | ||
|
|
1025d23ac7 | ||
|
|
7010d546bc | ||
|
|
85bdb007fc | ||
|
|
e06f717156 | ||
|
|
d50c8e0ca9 | ||
|
|
147d0f96e0 | ||
|
|
95f772df2b | ||
|
|
667723aa92 | ||
|
|
b276eeee75 | ||
|
|
58f430eb32 | ||
|
|
5ca50fdb63 | ||
|
|
7916360b52 | ||
|
|
83a9a2a86c | ||
|
|
0dc8fab256 | ||
|
|
69757b06ce | ||
|
|
a081b40fb2 | ||
|
|
88b5db6675 | ||
|
|
22f16174ef | ||
|
|
0470dabff2 | ||
|
|
5a30666939 | ||
|
|
f093946a29 | ||
|
|
6c25048b92 | ||
|
|
2e4ca9b761 | ||
|
|
42aae307f9 | ||
|
|
24c2845958 | ||
|
|
51209aa825 | ||
|
|
e68e62bf41 | ||
|
|
d1dc7cd7bb | ||
|
|
09fc38930f | ||
|
|
e79255e23b | ||
|
|
3caae52ac5 | ||
|
|
b4dffa598e | ||
|
|
df78ff6bd2 | ||
|
|
b5c1b36850 | ||
|
|
5db1a20f9f | ||
|
|
6b1b316ce4 | ||
|
|
c196e9ea10 | ||
|
|
ef1e8b5d3f | ||
|
|
d335a307bb | ||
|
|
89281c6d70 | ||
|
|
e3edf3a696 | ||
|
|
5aa45714bf | ||
|
|
62751787ca | ||
|
|
9b71070ef8 | ||
|
|
d8be069747 | ||
|
|
2ce6098085 | ||
|
|
44b6db6ff1 | ||
|
|
2f2bdfce5b | ||
|
|
82b7e243eb | ||
|
|
0d5c71bb4e | ||
|
|
0864b7d828 | ||
|
|
ca305b9da1 | ||
|
|
6317a06472 | ||
|
|
93657ee3c4 | ||
|
|
f98e6d1c08 | ||
|
|
8bd6b04451 | ||
|
|
39ef815d8b | ||
|
|
72d922468e | ||
|
|
0abc32b80e | ||
|
|
3699138eaa | ||
|
|
c1f821ca0f | ||
|
|
a7788a2bfa | ||
|
|
90ba38d2bc | ||
|
|
d9dc61a49d | ||
|
|
283c606b9d | ||
|
|
ee343935a4 | ||
|
|
032ec662e9 | ||
|
|
9fccd49d7d | ||
|
|
84ac0cc47d | ||
|
|
9f2db7cfc3 | ||
|
|
e5c11eee13 | ||
|
|
6edc4084c6 | ||
|
|
6d70247acb | ||
|
|
21b78d6dad | ||
|
|
4d8e4c66eb | ||
|
|
78e7adc4c6 | ||
|
|
5c4d6d0317 | ||
|
|
1b9682869a | ||
|
|
854cfdfe1b | ||
|
|
f355aa1ed7 | ||
|
|
9e2eef09d4 | ||
|
|
879526ce5e | ||
|
|
9525136c91 | ||
|
|
5dd1c76d1c | ||
|
|
2e149ad08c | ||
|
|
f9dc4d9b82 | ||
|
|
56bab70534 | ||
|
|
2d9802b6f5 | ||
|
|
2b46925e77 | ||
|
|
33172837e7 | ||
|
|
55de3fd216 | ||
|
|
50ad678a42 | ||
|
|
4155d4c9b3 | ||
|
|
a1d0a742b2 | ||
|
|
0e2d2c6f72 | ||
|
|
754a42ffd1 | ||
|
|
701edd9522 | ||
|
|
0127040a8c | ||
|
|
24e721f572 | ||
|
|
4211405e7b | ||
|
|
ea73e5881a | ||
|
|
bbb73b27cb | ||
|
|
ecb2a6846f | ||
|
|
2d16487ca1 | ||
|
|
d28cad3f12 | ||
|
|
92338a2e7c | ||
|
|
6ae88531ec | ||
|
|
8728676eb6 | ||
|
|
741b82b026 | ||
|
|
b58076f1b9 | ||
|
|
ab05e8d250 | ||
|
|
0894659cea | ||
|
|
84ed5943ea | ||
|
|
d6f6542945 | ||
|
|
e9fd84816e | ||
|
|
00d6e034a0 | ||
|
|
4d09651fe1 | ||
|
|
2828b92d91 | ||
|
|
ff6112826c | ||
|
|
1f443cf739 | ||
|
|
66cea929a7 | ||
|
|
4d86580542 | ||
|
|
2adb498620 | ||
|
|
d75f2563c6 | ||
|
|
938a03d0c1 | ||
|
|
8b4762fb29 | ||
|
|
eeafd81d31 | ||
|
|
2c65a75ba8 | ||
|
|
7e39889f78 | ||
|
|
96b343effc | ||
|
|
b58ad639c1 | ||
|
|
2b42b2bdc1 | ||
|
|
8eccb95fd3 | ||
|
|
9c1719a22f | ||
|
|
17ad20cd09 | ||
|
|
5657036f2f | ||
|
|
565a5ab73f | ||
|
|
0dcd761080 | ||
|
|
60fae70a60 | ||
|
|
d31a2a4fdd | ||
|
|
e8963effe2 | ||
|
|
b03524e25f | ||
|
|
832c5784d4 | ||
|
|
014f645bb3 | ||
|
|
cea32b86a3 | ||
|
|
bbda8ecdcf | ||
|
|
9fa382ed0c | ||
|
|
3fc823e049 | ||
|
|
c922936777 | ||
|
|
486b3ea45d | ||
|
|
71e389cdef | ||
|
|
d19191a2d3 | ||
|
|
505e408c47 | ||
|
|
f07bf5c520 | ||
|
|
75fb89a98a | ||
|
|
29ba0bdf13 | ||
|
|
e46be38869 | ||
|
|
0350f57275 | ||
|
|
6512a9d843 | ||
|
|
dbf76db590 | ||
|
|
3cb092f541 | ||
|
|
b59600099d | ||
|
|
7871877863 | ||
|
|
9056989f0e | ||
|
|
27a6e67ce9 | ||
|
|
68518e323e | ||
|
|
eccf119afd | ||
|
|
242c40af14 | ||
|
|
dd80679ec5 | ||
|
|
4b62ab2083 | ||
|
|
32823aef92 | ||
|
|
4295ae55d4 | ||
|
|
ab751e8c38 | ||
|
|
f7e1fa74cd | ||
|
|
a069d28f3a | ||
|
|
ed83672720 | ||
|
|
57f12d3d1c | ||
|
|
2ae1df467d | ||
|
|
2d5de234d2 | ||
|
|
3c039be7f2 | ||
|
|
8c5668bf1a | ||
|
|
400259207e | ||
|
|
c79251682d | ||
|
|
be74ebbb6c | ||
|
|
02fa29c7b2 | ||
|
|
7e1ee2eece | ||
|
|
118d11ceec | ||
|
|
438b6b8672 | ||
|
|
5f0afd3edc | ||
|
|
23eadfcc9f | ||
|
|
4d1fbf0502 | ||
|
|
270726d446 | ||
|
|
5daa110f7e | ||
|
|
07e3dc1ca4 | ||
|
|
dbeae383eb | ||
|
|
79de8dffff | ||
|
|
1b67f3d887 | ||
|
|
098a8c0c41 | ||
|
|
c59de789e5 | ||
|
|
9905afd2ad | ||
|
|
8956d55d5c | ||
|
|
16f9c107a5 | ||
|
|
c02f2bf140 | ||
|
|
251a41e7a5 | ||
|
|
8ca1b2c749 | ||
|
|
9e86d517a0 | ||
|
|
134fdeef3a | ||
|
|
9de5938e79 | ||
|
|
f75e91fc2b | ||
|
|
6be06de153 | ||
|
|
9528c4f837 | ||
|
|
5da7dd6413 | ||
|
|
2e9c8bd3ce | ||
|
|
0f14da7fbb | ||
|
|
a74c8025ac | ||
|
|
9d20ec00f1 | ||
|
|
4356e8cdb2 | ||
|
|
00cfb8bb10 | ||
|
|
cf642aa670 | ||
|
|
1c5b483a7e | ||
|
|
cbb9e7a289 | ||
|
|
4006a8d9f9 | ||
|
|
7b1eb03e2a | ||
|
|
d3a4234f4a |
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/sh
|
||||
|
||||
FILE=.links.temp-disabled.yaml
|
||||
if test -f "$FILE"; then
|
||||
# Only do the post-commit hook if the file was temp-disabled by the pre-commit hook.
|
||||
# Otherwise linking was actively (`yarn links:disable`) disabled and this hook should noop.
|
||||
mv .links.temp-disabled.yaml .links.yaml
|
||||
yarnLog=$(yarn)
|
||||
echo "[yarn-linker] The post-commit hook has re-enabled .links.yaml."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,11 +1,9 @@
|
||||
#!/usr/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
FILE=".links.yaml"
|
||||
if test -f "$FILE"; then
|
||||
mv .links.yaml .links.temp-disabled.yaml
|
||||
# echo "running yarn"
|
||||
x=$(yarn)
|
||||
y=$(git add yarn.lock)
|
||||
echo "[yarn-linker] The pre-commit hook has disabled .links.yaml and MODIFIED the yarn.lock file. Review the staged changes (the hook added yarn.lock, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
|
||||
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
|
||||
|
||||
PNPMFILE=.pnpmfile.cjs
|
||||
if test -f "$PNPMFILE"; then
|
||||
echo "[pnpm-linker] The pre-commit hook detected $PNPMFILE which implies you have linked packages in your pnpm-lock.yaml. Run pnpm links:off and commit again. See also linking.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
9
.github/workflows/build-element-call.yaml
vendored
9
.github/workflows/build-element-call.yaml
vendored
@@ -37,15 +37,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Build Element Call
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
28
.github/workflows/build.yaml
vendored
28
.github/workflows/build.yaml
vendored
@@ -85,3 +85,31 @@ jobs:
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Build Storybook
|
||||
run: pnpm run build-storybook
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: build-output-storybook
|
||||
path: storybook-static
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
10
.github/workflows/deploy-to-netlify.yaml
vendored
10
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -17,7 +17,7 @@ on:
|
||||
package:
|
||||
required: true
|
||||
type: string
|
||||
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
||||
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
with:
|
||||
step: start
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
env: Netlify
|
||||
env: ${{ inputs.package}}
|
||||
ref: ${{ inputs.deployment_ref }}
|
||||
desc: |
|
||||
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
||||
@@ -59,9 +59,13 @@ jobs:
|
||||
|
||||
- name: Add redirects file
|
||||
# We fetch from github directly as we don't bother checking out the repo
|
||||
# Not needed for storybook deployments
|
||||
if: inputs.package != 'storybook'
|
||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||
|
||||
- name: Add config file
|
||||
# Not needed for storybook deployments
|
||||
if: inputs.package != 'storybook'
|
||||
run: |
|
||||
if [ "${INPUTS_PACKAGE}" = "full" ]; then
|
||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
|
||||
@@ -78,7 +82,7 @@ jobs:
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || inputs.package == 'storybook' && format('pr{0}-storybook', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
17
.github/workflows/lint.yaml
vendored
17
.github/workflows/lint.yaml
vendored
@@ -12,20 +12,21 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Prettier
|
||||
run: "yarn run prettier:check"
|
||||
run: "pnpm run prettier:check"
|
||||
- name: i18n
|
||||
run: "yarn run i18n:check"
|
||||
run: "pnpm run i18n:check"
|
||||
- name: ESLint
|
||||
run: "yarn run lint:eslint"
|
||||
run: "pnpm run lint:eslint"
|
||||
- name: Type check
|
||||
run: "yarn run lint:types"
|
||||
run: "pnpm run lint:types"
|
||||
- name: Dead code analysis
|
||||
run: "yarn run lint:knip"
|
||||
run: "pnpm run lint:knip"
|
||||
|
||||
20
.github/workflows/pr-deploy.yaml
vendored
20
.github/workflows/pr-deploy.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
# 2. Event must be a pull_request
|
||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,6 +63,24 @@ jobs:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
netlify-storybook:
|
||||
needs: prdetails
|
||||
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'storybook build') }}
|
||||
permissions:
|
||||
deployments: write
|
||||
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||
package: storybook
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
docker:
|
||||
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
||||
needs: prdetails
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Create Checksum
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
48
.github/workflows/test-netlify.yaml
vendored
Normal file
48
.github/workflows/test-netlify.yaml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Triggers after the playwright tests have finished,
|
||||
# taking the artifact and uploading it to Netlify for easier viewing
|
||||
name: Upload End to End Test report to Netlify
|
||||
on:
|
||||
# Privilege escalation necessary to publish to Netlify
|
||||
# 🚨 We must not execute any checked out code here.
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers]
|
||||
workflows: ["Test"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
report:
|
||||
if: github.event.workflow_run.conclusion != 'cancelled'
|
||||
name: Report results
|
||||
runs-on: ubuntu-24.04
|
||||
environment: Netlify
|
||||
permissions:
|
||||
statuses: write
|
||||
deployments: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Download HTML report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
|
||||
with:
|
||||
path: playwright-report
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site_id: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
desc: Playwright Report
|
||||
deployment_env: EndToEndTests
|
||||
prefix: "e2e-"
|
||||
27
.github/workflows/test.yaml
vendored
27
.github/workflows/test.yaml
vendored
@@ -14,15 +14,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
run: "pnpm run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
env:
|
||||
@@ -42,12 +43,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||
@@ -56,10 +58,11 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 3
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
if-no-files-found: error
|
||||
retention-days: 4
|
||||
|
||||
7
.github/workflows/translations-download.yaml
vendored
7
.github/workflows/translations-download.yaml
vendored
@@ -22,11 +22,12 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
|
||||
- name: Prune i18n
|
||||
run: "rm -R locales"
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
- name: Prettier
|
||||
run: yarn prettier:format
|
||||
run: pnpm prettier:format
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
|
||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
@@ -20,4 +20,4 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,9 +21,14 @@ yarn-error.log
|
||||
!/.yarn/releases
|
||||
!/.yarn/sdks
|
||||
!/.yarn/versions
|
||||
# old yarn based linking
|
||||
/.links.yaml
|
||||
/.links.disabled.yaml
|
||||
/.links.temp-disabled.yaml
|
||||
# pnpm based linking
|
||||
/.links.cjs
|
||||
/.links.disabled.cjs
|
||||
/.links.temp-disabled.cjs
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pnpm-lock.yaml
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-docs"],
|
||||
framework: "@storybook/react-vite",
|
||||
};
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { create } from "storybook/theming";
|
||||
import { addons } from "storybook/manager-api";
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
|
||||
91
.yarn/plugins/linker.cjs
vendored
91
.yarn/plugins/linker.cjs
vendored
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
name: "linker",
|
||||
factory: (require) => ({
|
||||
hooks: {
|
||||
// Yarn's plugin system is very light on documentation. The best we have
|
||||
// for this hook is simply the type definition in
|
||||
// https://github.com/yarnpkg/berry/blob/master/packages/yarnpkg-core/sources/Plugin.ts
|
||||
registerPackageExtensions: async (config, registerPackageExtension) => {
|
||||
const { structUtils } = require("@yarnpkg/core");
|
||||
const { parseSyml } = require("@yarnpkg/parsers");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const process = require("process");
|
||||
|
||||
// Create a descriptor that we can use to target our direct dependencies
|
||||
const projectPath = config.projectCwd
|
||||
.replace(/\\/g, "/")
|
||||
.replace("/C:/", "C:/");
|
||||
const manifestPath = path.join(projectPath, "package.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const selfDescriptor = structUtils.parseDescriptor(
|
||||
`${manifest.name}@*`,
|
||||
true,
|
||||
);
|
||||
|
||||
// Load the list of linked packages
|
||||
const linksPath = path.join(projectPath, ".links.yaml");
|
||||
let linksFile;
|
||||
try {
|
||||
linksFile = fs.readFileSync(linksPath, "utf8");
|
||||
} catch (e) {
|
||||
return; // File doesn't exist, there's nothing to link
|
||||
}
|
||||
let links;
|
||||
try {
|
||||
links = parseSyml(linksFile);
|
||||
} catch (e) {
|
||||
console.error(".links.yaml has invalid syntax", e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve paths and turn them into a Yarn package extension
|
||||
const overrides = Object.fromEntries(
|
||||
Object.entries(links).map(([name, link]) => [
|
||||
name,
|
||||
`portal:${path.resolve(config.projectCwd, link)}`,
|
||||
]),
|
||||
);
|
||||
const overrideIdentHashes = new Set();
|
||||
for (const name of Object.keys(overrides))
|
||||
overrideIdentHashes.add(
|
||||
structUtils.parseDescriptor(`${name}@*`, true).identHash,
|
||||
);
|
||||
|
||||
// Extend our own package's dependencies with these local overrides
|
||||
registerPackageExtension(selfDescriptor, { dependencies: overrides });
|
||||
|
||||
// Filter out the original dependencies from the package spec so Yarn
|
||||
// actually respects the overrides
|
||||
const filterDependencies = (original) => {
|
||||
const pkg = structUtils.copyPackage(original);
|
||||
pkg.dependencies = new Map(
|
||||
Array.from(pkg.dependencies.entries()).filter(
|
||||
([, value]) => !overrideIdentHashes.has(value.identHash),
|
||||
),
|
||||
);
|
||||
return pkg;
|
||||
};
|
||||
|
||||
// Patch Yarn's own normalizePackage method to use the above filter
|
||||
const originalNormalizePackage = config.normalizePackage;
|
||||
config.normalizePackage = function (pkg, extensions) {
|
||||
return originalNormalizePackage.call(
|
||||
this,
|
||||
pkg.identHash === selfDescriptor.identHash
|
||||
? filterDependencies(pkg)
|
||||
: pkg,
|
||||
extensions,
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
plugins:
|
||||
- .yarn/plugins/linker.cjs
|
||||
20
README.md
20
README.md
@@ -108,17 +108,17 @@ recommended method for embedding Element Call.
|
||||
</p>
|
||||
|
||||
For more details on the packages, see the
|
||||
[Embedded vs. Standalone Guide](./docs/embedded-standalone.md).
|
||||
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
||||
|
||||
## 🛠️ Self-Hosting
|
||||
|
||||
For operating and deploying Element Call on your own server, refer to the
|
||||
[**Self-Hosting Guide**](./docs/self-hosting.md).
|
||||
[**Self-Hosting Guide**](./docs/self_hosting.md).
|
||||
|
||||
## 🧭 MatrixRTC Backend Discovery and Selection
|
||||
|
||||
For proper Element Call operation each site deployment needs a MatrixRTC backend
|
||||
setup as outlined in the [Self-Hosting](#self-hosting). A typical federated site
|
||||
setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site
|
||||
deployment for three different sites A, B and C is depicted below.
|
||||
|
||||
<p align="center">
|
||||
@@ -186,7 +186,7 @@ To get started clone and set up this project:
|
||||
git clone https://github.com/element-hq/element-call.git
|
||||
cd element-call
|
||||
corepack enable
|
||||
yarn
|
||||
pnpm install
|
||||
```
|
||||
|
||||
To use it, create a local config by, e.g.,
|
||||
@@ -197,7 +197,7 @@ environment as outlined in the next section out of box.
|
||||
You're now ready to launch the development server:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
See also:
|
||||
@@ -230,7 +230,7 @@ only for local development and **_never be exposed to the public Internet._**
|
||||
Run backend components:
|
||||
|
||||
```sh
|
||||
yarn backend
|
||||
pnpm backend
|
||||
# or for podman-compose
|
||||
# podman-compose -f dev-backend-docker-compose.yml up
|
||||
```
|
||||
@@ -260,13 +260,13 @@ on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
|
||||
is what will be tested.
|
||||
|
||||
The local backend environment should be running for the test to work:
|
||||
`yarn backend`
|
||||
`pnpm backend`
|
||||
|
||||
There are a few different ways to run the tests yourself. The simplest is to
|
||||
run:
|
||||
|
||||
```shell
|
||||
yarn run test:playwright
|
||||
pnpm run test:playwright
|
||||
```
|
||||
|
||||
This will run the Playwright tests once, non-interactively.
|
||||
@@ -274,7 +274,7 @@ This will run the Playwright tests once, non-interactively.
|
||||
There is a more user-friendly way to run the tests in interactive mode:
|
||||
|
||||
```shell
|
||||
yarn run test:playwright:open
|
||||
pnpm run test:playwright:open
|
||||
```
|
||||
|
||||
The easiest way to develop new test is to use the codegen feature of Playwright:
|
||||
@@ -316,7 +316,7 @@ To add a new translation key you can do these steps:
|
||||
|
||||
1. Add the new key entry to the code where the new key is used:
|
||||
`t("some_new_key")`
|
||||
1. Run `yarn i18n` to extract the new key and update the translation files. This
|
||||
1. Run `pnpm i18n` to extract the new key and update the translation files. This
|
||||
will add a skeleton entry to the `locales/en/app.json` file:
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Element-Call in widget mode
|
||||
|
||||
When running `yarn backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
|
||||
When running `pnpm backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
|
||||
In a development environment, you might prefer to just use the `element-web` repo directly, but this setup is useful for CI/CD testing.
|
||||
|
||||
## Setup
|
||||
@@ -18,7 +18,7 @@ that uses
|
||||
It is part of the existing backend setup. To start the backend, run:
|
||||
|
||||
```sh
|
||||
yarn backend
|
||||
pnpm backend
|
||||
```
|
||||
|
||||
Then open `http://localhost:8081` in your browser.
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -18,3 +18,7 @@ keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook
|
||||
|
||||
@@ -18,3 +18,7 @@ keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook
|
||||
|
||||
@@ -28,14 +28,19 @@ server {
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
# verification, which requires TLS
|
||||
location ~ ^(/_matrix|/_synapse/client) {
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location ~ ^(/_matrix|/_synapse/admin) {
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -73,10 +78,16 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location ~ ^(/_matrix|/_synapse/admin) {
|
||||
proxy_pass "http://homeserver-1:18008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -108,7 +119,7 @@ server {
|
||||
|
||||
# JWT Service running at port 6080
|
||||
proxy_pass http://jwt-auth-services/;
|
||||
|
||||
|
||||
}
|
||||
|
||||
location ^~ /livekit/sfu/ {
|
||||
@@ -128,8 +139,6 @@ server {
|
||||
# LiveKit SFU websocket connection running at port 7880
|
||||
proxy_pass http://livekit-sfu:7880/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -156,7 +165,7 @@ server {
|
||||
|
||||
# JWT Service running at port 16080
|
||||
proxy_pass http://auth-service-1:16080/;
|
||||
|
||||
|
||||
}
|
||||
|
||||
location ^~ /livekit/sfu/ {
|
||||
@@ -176,14 +185,12 @@ server {
|
||||
# LiveKit SFU websocket connection running at port 17880
|
||||
proxy_pass http://livekit-sfu-1:17880/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
# Convenience reverse proxy for the call.m.localhost domain to element call
|
||||
# running on the host either via
|
||||
# - yarn dev --host or
|
||||
# - pnpm dev --host or
|
||||
# - falling back to http (the element call docker container)
|
||||
server {
|
||||
listen 80;
|
||||
@@ -228,7 +235,6 @@ server {
|
||||
proxy_pass http://host.docker.internal:8080;
|
||||
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -260,8 +266,6 @@ server {
|
||||
proxy_ssl_verify off;
|
||||
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -293,7 +297,5 @@ server {
|
||||
proxy_ssl_verify off;
|
||||
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
image: livekit/livekit-server:v1.10.1
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -62,12 +62,15 @@ services:
|
||||
- 7882:7882/tcp
|
||||
- 50100-50200:50100-50200/udp
|
||||
volumes:
|
||||
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
|
||||
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
|
||||
environment:
|
||||
- SSL_CERT_FILE=/local_cert.pem
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
image: livekit/livekit-server:v1.10.1
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -82,7 +85,10 @@ services:
|
||||
- 17882:17882/tcp
|
||||
- 50300-50400:50300-50400/udp
|
||||
volumes:
|
||||
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
|
||||
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
|
||||
environment:
|
||||
- SSL_CERT_FILE=/local_cert.pem
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -164,6 +170,8 @@ services:
|
||||
- "8448:8448"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth-server:127.0.0.1"
|
||||
- "auth-server-1:127.0.0.1"
|
||||
depends_on:
|
||||
- synapse
|
||||
networks:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
This folder contains documentation for setup, usage, and development of Element Call.
|
||||
|
||||
- [Embedded vs standalone mode](./embedded-standalone.md)
|
||||
- [Url format and parameters](./url-params.md)
|
||||
- [Embedded vs standalone mode](./embedded_standalone.md)
|
||||
- [Url format and parameters](./url_params.md)
|
||||
- [Global JS controls](./controls.md)
|
||||
- [Self-Hosting](./self-hosting.md)
|
||||
- [Self-Hosting](./self_hosting.md)
|
||||
- [Developing with linked packages](./linking.md)
|
||||
|
||||
@@ -14,7 +14,7 @@ The table below provides a comparison of the two packages:
|
||||
| **Release artifacts** | Docker Image, Tarball | Tarball, NPM for Web, Android AAR, SwiftPM for iOS |
|
||||
| **Recommended for** | Standalone/guest access usage | Embedding within messenger apps |
|
||||
| **Responsibility for regulatory compliance** | The administrator that is deploying the app is responsible for compliance with any applicable regulations (e.g. privacy) | The developer of the messenger app is responsible for compliance |
|
||||
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url-params.md#embedded-only-parameters) if consent has been granted. |
|
||||
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url_params.md#embedded-only-parameters) if consent has been granted. |
|
||||
| **Analytics data** | Element Call will send data to the Posthog, Sentry and Open Telemetry targets specified by the administrator in the `config.json` | Element Call will send data to the Posthog and Sentry targets specified in the URL parameters by the messenger app |
|
||||
|
||||
### Using the embedded package within a messenger app
|
||||
@@ -26,7 +26,7 @@ The basics are:
|
||||
1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0`
|
||||
2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build.
|
||||
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
|
||||
4. Set any of the [embedded-only URL parameters](./url-params.md#embedded-only-parameters) that you need.
|
||||
4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need.
|
||||
|
||||
## Widget vs standalone mode
|
||||
|
||||
@@ -36,4 +36,4 @@ As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (aut
|
||||
Element Call and the hosting client are connected via the widget API.
|
||||
|
||||
Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
|
||||
](./url-params.md).
|
||||
](./url_params.md).
|
||||
@@ -1,39 +1,65 @@
|
||||
# Developing with linked packages
|
||||
## Quickstart guide
|
||||
|
||||
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. Yarn has a command for this (`yarn link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
|
||||
run
|
||||
|
||||
Instead, you can use our little 'linker' plugin. Create a file named `.links.yaml` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
|
||||
|
||||
```yaml
|
||||
matrix-js-sdk: ../path/to/matrix-js-sdk
|
||||
"@vector-im/compound-web": /home/alice/path/to/compound-web
|
||||
```bash
|
||||
./scripts/setup-linking.sh
|
||||
```
|
||||
|
||||
Then run `yarn install`.
|
||||
Read the script output:
|
||||
|
||||
```
|
||||
Setup complete.
|
||||
Update: .links.cjs to your liking
|
||||
Run: 'pnpm links:on' to test your .links.cjs
|
||||
Run: 'git commit' with links enabled to test the git pre-commit hook.
|
||||
Run: 'pnpm links:off' to be able to commit again
|
||||
Run: 'git config --local core.hooksPath ""' to allow committing with linking (not recommended)
|
||||
Run: 'rm links.cjs' & 'git config --local core.hooksPath ""' to fully revert what this script did
|
||||
```
|
||||
|
||||
# Developing with linked packages
|
||||
|
||||
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. `pnpm` has a command for this (`pnpm link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
|
||||
|
||||
Instead, create a file named `.links.cjs` in the Element Call project directory (or run `./scripts/setup-linking.sh` to create a template), listing the names and paths of any dependencies you want to link. For example:
|
||||
|
||||
```cjs
|
||||
// Packages to link to local checkouts
|
||||
module.exports = {
|
||||
"matrix-js-sdk": "../your/path/matrix-js-sdk",
|
||||
"matrix-widget-api": "../your/path/matrix-widget-api",
|
||||
};
|
||||
```
|
||||
|
||||
Then run `pnpm links:on`. (this will activate the pnpm file + run `pnpm install` to setup the linking)
|
||||
|
||||
## Hooks
|
||||
|
||||
Changes in `.links.yaml` will also update `yarn.lock` when `yarn` is executed. The lockfile will then contain the local
|
||||
Changes in `.links.cjs` will also update `pnpm-lock.yaml` when `pnpm install` is executed. The lockfile will then contain the local
|
||||
version of the package which would not work on others dev setups or the github CI.
|
||||
One always needs to run:
|
||||
|
||||
One always needs to remove the pnpm `readPackage` script (the `.pnpmfile.cjs`) and run:
|
||||
|
||||
```bash
|
||||
mv .links.yaml .links.disabled.yaml
|
||||
yarn
|
||||
pnpm install
|
||||
```
|
||||
|
||||
before committing a change.
|
||||
|
||||
To make it more convenient to work with this linking system we added git hooks for your conviniece.
|
||||
A `pre-commit` hook will run `mv .links.yaml .links.disabled.yaml`, `yarn` and `git add yarn.lock` if it detects
|
||||
a `.links.yaml` file and abort the commit.
|
||||
You will than need to check if the resulting changes are appropriate and commit again.
|
||||
To make this less of a foot gun we added a git hook.
|
||||
A `pre-commit` hook will check if linking is currently used. If it detects
|
||||
a `.pnpmfile.cjs` file it will abort the commit with an explanatory message.
|
||||
You will than need to run `pnpm links:off` and commit again.
|
||||
|
||||
A `post-commit` hook will setup the linking as it was
|
||||
before if a `.links.disabled.yaml` is present. It runs `mv .links.disabled.yaml .links.yaml` and `yarn`.
|
||||
|
||||
To activate the hooks automatically configure git with
|
||||
To activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done):
|
||||
|
||||
```bash
|
||||
git config --local core.hooksPath .githooks/
|
||||
git config --local core.hooksPath .githooks
|
||||
```
|
||||
|
||||
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
|
||||
|
||||
## Background
|
||||
|
||||
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
|
||||
|
||||
30
docs/linking_concept_reasoning.md
Normal file
30
docs/linking_concept_reasoning.md
Normal file
@@ -0,0 +1,30 @@
|
||||
### Why do we not enable .pnpmfile.cjs by default
|
||||
|
||||
Background: The presence of the `.pnpmfile.cjs` adds a field to the `pnpm-lock.yaml` called: `pnpmfileChecksum`. This field is a checksum of the content of the `.pnpmfile.cjs` file.
|
||||
`pnpm install --frozen-lockfile` **fails** if there is a `.pnpmfile.cjs` but no `pnpmfileChecksum` or vice versa (or on mismatch).
|
||||
|
||||
_TLDR: running with `--ignore-pnpmfile` will fail if `pnpmfileChecksum` is present._
|
||||
|
||||
#### `pnpmfileChecksum` + renovate bot
|
||||
|
||||
When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. This means that the `pnpmfileChecksum` in the lockfile will be **empty**.
|
||||
This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR)
|
||||
From here we have two possible paths:
|
||||
|
||||
- ignore `.pnpmfile.cjs` in all CI builds CI will also fail if we accidently add it locally.
|
||||
- fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`.
|
||||
|
||||
Ignoring in all CI builds means that CI will always fail if we enable the linking system.
|
||||
This is annoying but can be worked around with the git hook we provide that at least lets us know that we are
|
||||
commiting with enabled linking.
|
||||
Only if we remember setting it back/disbale linking (or let ourselves remember by the git hook) the CI will work.
|
||||
|
||||
#### Summary
|
||||
|
||||
- We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`)
|
||||
- To keep it simple we set `--ignore-pnpmfile` in all of ours CI to see issues immediately.
|
||||
- The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing.
|
||||
- This way there will never be a commit with `pnpmfileChecksum` in the lockfile.
|
||||
- renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work
|
||||
- We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand.
|
||||
- `pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this.
|
||||
@@ -237,8 +237,8 @@ source. First, clone and install the package:
|
||||
git clone https://github.com/element-hq/element-call.git
|
||||
cd element-call
|
||||
corepack enable
|
||||
yarn
|
||||
yarn build
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If all went well, you can now find the build output under `dist` as a series of
|
||||
@@ -4,7 +4,7 @@ There are two formats for Element Call URLs.
|
||||
|
||||
## Link for sharing
|
||||
|
||||
Requires Element Call to be deployed in [standalone](./embedded-standalone.md) mode.
|
||||
Requires Element Call to be deployed in [standalone](./embedded_standalone.md) mode.
|
||||
|
||||
```text
|
||||
https://element_call.domain/room/#
|
||||
@@ -36,15 +36,15 @@ possible to support encryption.
|
||||
|
||||
| Package | Deployment | URL |
|
||||
| ------------------------------------ | ----------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Full](./embedded-standalone.md) | All | `https://element_call.domain/room` |
|
||||
| [Embedded](./embedded-standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
|
||||
| [Embedded](./embedded-standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
|
||||
| [Full](./embedded_standalone.md) | All | `https://element_call.domain/room` |
|
||||
| [Embedded](./embedded_standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
|
||||
| [Embedded](./embedded_standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
|
||||
|
||||
## Parameters
|
||||
|
||||
### Common Parameters
|
||||
|
||||
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
|
||||
These parameters are relevant to both [widget](./embedded_standalone.md) and [standalone](./embedded_standalone.md) modes:
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -74,7 +74,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
These parameters are only supported in [widget](./embedded-standalone.md) mode.
|
||||
These parameters are only supported in [widget](./embedded_standalone.md) mode.
|
||||
|
||||
| Name | Values | Required | Description |
|
||||
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -89,7 +89,7 @@ These parameters are only supported in [widget](./embedded-standalone.md) mode.
|
||||
|
||||
### Embedded-only parameters
|
||||
|
||||
These parameters are only supported in the [embedded](./embedded-standalone.md) package of Element Call and will be ignored in the [full](./embedded-standalone.md) package.
|
||||
These parameters are only supported in the [embedded](./embedded_standalone.md) package of Element Call and will be ignored in the [full](./embedded_standalone.md) package.
|
||||
|
||||
| Name | Values | Required | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -11,7 +11,7 @@ pushd $CURRENT_DIR > /dev/null
|
||||
function build_assets() {
|
||||
echo "Generating Element Call assets..."
|
||||
pushd ../.. > /dev/null
|
||||
yarn build
|
||||
pnpm build
|
||||
popd > /dev/null
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function copy_assets() {
|
||||
}
|
||||
|
||||
getopts :sh opt
|
||||
case $opt in
|
||||
case $opt in
|
||||
s)
|
||||
SKIP=1
|
||||
;;
|
||||
@@ -41,7 +41,7 @@ if [ ! $SKIP ]; then
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
build_assets
|
||||
else
|
||||
else
|
||||
echo "Using existing assets from ../../dist"
|
||||
fi
|
||||
copy_assets
|
||||
@@ -56,4 +56,4 @@ echo "Publishing the Android project"
|
||||
|
||||
./gradlew publishAndReleaseToMavenCentral --no-daemon
|
||||
|
||||
popd > /dev/null
|
||||
popd > /dev/null
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||
/>
|
||||
<title><%- brand %></title>
|
||||
<script>
|
||||
|
||||
9
knip.ts
9
knip.ts
@@ -18,6 +18,7 @@ export default {
|
||||
// https://docs.docker.com/compose/migrate/
|
||||
"docker-compose",
|
||||
],
|
||||
ignoreFiles: ["scripts/.pnpmfile.cjs"],
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
@@ -30,16 +31,10 @@ export default {
|
||||
"@types/content-type",
|
||||
"@types/sdp-transform",
|
||||
"@types/uuid",
|
||||
// We obviously use this, but if the package has been linked with yarn link,
|
||||
// We obviously use this, but if the package has been linked with pnpm link,
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// Yarn plugins are allowed to depend on packages provided by the Yarn
|
||||
// runtime. These shouldn't be listed in package.json, because plugins
|
||||
// should work before Yarn even installs dependencies for the first time.
|
||||
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
|
||||
"@yarnpkg/core",
|
||||
"@yarnpkg/parsers",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
},
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"layout_switch_label": "Layout",
|
||||
"lobby": {
|
||||
"ask_to_join": "Request to join call",
|
||||
"join_as_guest": "Join as guest",
|
||||
|
||||
59
package.json
59
package.json
@@ -3,23 +3,23 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "yarn dev:full",
|
||||
"dev": "pnpm dev:full",
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"build": "yarn build:full",
|
||||
"build": "pnpm 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",
|
||||
"build:sdk:development": "yarn build:sdk --mode development",
|
||||
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "yarn build:sdk",
|
||||
"build:full:production": "pnpm build:full",
|
||||
"build:full:development": "pnpm build:full --mode development",
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "pnpm build:embedded",
|
||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
|
||||
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
|
||||
"lint:eslint": "eslint --max-warnings 0 src playwright",
|
||||
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
|
||||
"lint:knip": "knip",
|
||||
@@ -31,9 +31,9 @@
|
||||
"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",
|
||||
"links:disable": "mv .links.yaml .links.disabled.yaml",
|
||||
"test:playwright:open": "pnpm test:playwright --ui",
|
||||
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
|
||||
"links:off": "rm .pnpmfile.cjs & pnpm install",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
@@ -81,7 +81,7 @@
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.0.0",
|
||||
"@vector-im/compound-web": "^9.3.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
@@ -107,10 +107,10 @@
|
||||
"i18next-parser": "^9.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.86.0",
|
||||
"livekit-client": "^2.13.0",
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -132,24 +132,29 @@
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@livekit/components-core>rxjs": "^7.8.1",
|
||||
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
|
||||
@@ -7,11 +7,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { join } from "path";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Needed by the synapse admin API called in fixtures
|
||||
process.env.NODE_EXTRA_CA_CERTS = join(
|
||||
__dirname,
|
||||
"backend/dev_tls_local-ca.crt",
|
||||
);
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -68,6 +79,11 @@ export default defineConfig({
|
||||
firefoxUserPrefs: {
|
||||
"permissions.default.microphone": 1,
|
||||
"permissions.default.camera": 1,
|
||||
// Equivalent to Chromium's --use-fake-device-for-media-stream:
|
||||
// feeds a synthetic media stream so getUserMedia and
|
||||
// enumerateDevices work on CI runners without real hardware.
|
||||
"media.navigator.streams.fake": true,
|
||||
"media.navigator.permission.disabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,3 +58,41 @@ test("Start a new call then leave and show the feedback screen", async ({
|
||||
page.getByRole("link", { name: "Not now, return to home screen" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("BugFix: When unmuting in lobby, you had to click twice to unmute in call", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("DoubleUnMute");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill("me");
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
const microphoneButton = page.getByTestId("incall_mute");
|
||||
const cameraButton = page.getByTestId("incall_videomute");
|
||||
|
||||
// Wait for devices to enumerate before the button enables.
|
||||
await expect(microphoneButton).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
await microphoneButton.click();
|
||||
await cameraButton.click();
|
||||
|
||||
// Should be muted now
|
||||
await expect(microphoneButton).toHaveAccessibleName("Unmute microphone");
|
||||
await expect(cameraButton).toHaveAccessibleName("Start video");
|
||||
|
||||
// Create the call and join
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// Give sometime for the all to be connected
|
||||
// Check the number of participants
|
||||
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
|
||||
|
||||
// Click again on the mute button. it should unmute
|
||||
await microphoneButton.click();
|
||||
await expect(microphoneButton).toHaveAccessibleName("Mute microphone");
|
||||
await cameraButton.click();
|
||||
await expect(cameraButton).toHaveAccessibleName("Stop video");
|
||||
});
|
||||
|
||||
@@ -102,8 +102,10 @@ test("Should show error screen if call creation is restricted", async ({
|
||||
|
||||
// Then if the socket connection fails, livekit will try to validate the token!
|
||||
// Livekit will not auto_create anymore and will return a 404 error.
|
||||
// Note the regex is required as livekit-client is nowasays trying two
|
||||
// differnt APIs
|
||||
await page.route(
|
||||
"**/badurltotricktest/livekit/sfu/rtc/validate?**",
|
||||
/.*\/badurltotricktest\/livekit\/sfu\/rtc(\/v1)?\/validate?.*/,
|
||||
async (route) =>
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
|
||||
@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
await ewPage1
|
||||
.getByRole("button", { name: "Invite to this room", exact: true })
|
||||
.click();
|
||||
.click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
|
||||
).toBeVisible();
|
||||
@@ -104,6 +106,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").click();
|
||||
await ewPage1.getByRole("button", { name: "Invite" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await expect(
|
||||
@@ -126,6 +129,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
|
||||
await ewPage1.getByRole("button", { name: "Go" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModalDM(ewPage1);
|
||||
|
||||
// Wait and send the first message to create the DM
|
||||
await expect(
|
||||
|
||||
@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
|
||||
mobileTest(
|
||||
"Test earpiece overlay in controlledAudioDevices mode",
|
||||
async ({ asMobile, browser }) => {
|
||||
test.slow(); // Triples the timeout
|
||||
const { creatorPage, inviteLink } = asMobile;
|
||||
|
||||
// ========
|
||||
@@ -98,10 +97,12 @@ mobileTest(
|
||||
|
||||
// Open settings to select earpiece
|
||||
await guestPage.getByRole("button", { name: "Settings" }).click();
|
||||
await guestPage.getByText("Handset", { exact: true }).click();
|
||||
await guestPage
|
||||
.getByRole("radio", { name: "Handset", exact: true })
|
||||
.click();
|
||||
|
||||
// dismiss settings
|
||||
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
|
||||
await guestPage.locator("#root").press("Escape");
|
||||
|
||||
await guestPage.pause();
|
||||
await expect(
|
||||
|
||||
@@ -9,7 +9,9 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(browserName === "firefox", "Browser independent");
|
||||
// Use reduce motion to disable animations that are making the tests a bit flaky
|
||||
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const creatorPage = await creatorContext.newPage();
|
||||
@@ -91,8 +93,10 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
// the creator leaves the call
|
||||
await creatorPage.getByTestId("incall_leave").click();
|
||||
|
||||
await guestCPage.waitForTimeout(2000);
|
||||
// https://github.com/element-hq/element-call/issues/3344
|
||||
// The app used to request a new jwt token then to reconnect to the SFU
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
|
||||
await guestCPage.waitForTimeout(6000);
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ async function setupTwoUserSpaCall(
|
||||
await page.goto("/");
|
||||
|
||||
let androlHasSentStickyEvent = false;
|
||||
|
||||
const androlResolver = Promise.withResolvers<void>();
|
||||
await interceptEventSend(
|
||||
page,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
@@ -36,6 +36,7 @@ async function setupTwoUserSpaCall(
|
||||
(req) => {
|
||||
androlHasSentStickyEvent =
|
||||
androlHasSentStickyEvent || isStickySend(req.url());
|
||||
androlResolver.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -53,6 +54,7 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
await interceptEventSend(
|
||||
guestPage,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
@@ -60,6 +62,7 @@ async function setupTwoUserSpaCall(
|
||||
(req) => {
|
||||
pevaraHasSentStickyEvent =
|
||||
pevaraHasSentStickyEvent || isStickySend(req.url());
|
||||
pevaraResolver.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -70,7 +73,9 @@ async function setupTwoUserSpaCall(
|
||||
"2_0",
|
||||
);
|
||||
// Assert both sides have sent sticky membership events
|
||||
await androlResolver.promise;
|
||||
expect(androlHasSentStickyEvent).toEqual(true);
|
||||
await pevaraResolver.promise;
|
||||
expect(pevaraHasSentStickyEvent).toEqual(true);
|
||||
|
||||
return { guestPage };
|
||||
@@ -110,8 +115,12 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3);
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
function isStickySend(url: string): boolean {
|
||||
|
||||
@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await page.locator("video").all();
|
||||
expect(videoElements.length).toBe(count);
|
||||
// There should be `count` video elements, visible and autoplaying
|
||||
await expect(page.locator("video")).toHaveCount(count);
|
||||
|
||||
const blockDisplayCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(count);
|
||||
await expect(async () => {
|
||||
const videoBlockCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(videoBlockCount).toBe(count);
|
||||
}).toPass({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
|
||||
142
playwright/utils/synapse-admin.ts
Normal file
142
playwright/utils/synapse-admin.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
/**
|
||||
* Response from Synapse registration API
|
||||
*/
|
||||
export interface SynapseRegistrationResponse {
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
home_server: string;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for interacting with Synapse Admin API
|
||||
* This provides fast user registration without going through the UI
|
||||
*
|
||||
* @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html
|
||||
*/
|
||||
export class SynapseAdmin {
|
||||
public constructor(
|
||||
private baseUrl: string = "https://synapse.m.localhost",
|
||||
private sharedSecret: string = "test_shared_secret_for_local_dev_only",
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a user using the Synapse Admin API
|
||||
* This is much faster than going through the UI registration flow
|
||||
*
|
||||
* @param username - The username (localpart) for the new user
|
||||
* @param password - The password for the new user
|
||||
* @param displayName - Optional display name (defaults to username)
|
||||
* @param admin - Whether the user should be an admin (defaults to false)
|
||||
* @returns Registration response containing access token and user ID
|
||||
*/
|
||||
public async registerUser(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
admin: boolean = false,
|
||||
): Promise<SynapseRegistrationResponse> {
|
||||
// Get a nonce first
|
||||
const nonce = await this.getNonce();
|
||||
|
||||
// Generate the HMAC
|
||||
const mac = this.generateMac(username, password, admin, nonce);
|
||||
|
||||
// Make the registration request
|
||||
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nonce,
|
||||
username,
|
||||
password,
|
||||
displayname: displayName || username,
|
||||
admin,
|
||||
mac,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to register user ${username}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nonce for registration
|
||||
* The nonce is required for the HMAC calculation
|
||||
*
|
||||
* @returns A nonce string
|
||||
*/
|
||||
private async getNonce(): Promise<string> {
|
||||
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get nonce: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC for shared secret registration
|
||||
* This is the authentication mechanism for the admin API
|
||||
*
|
||||
* @param username - The username
|
||||
* @param password - The password
|
||||
* @param admin - Whether the user is an admin
|
||||
* @param nonce - The nonce from the server
|
||||
* @returns The HMAC hex string
|
||||
*/
|
||||
private generateMac(
|
||||
username: string,
|
||||
password: string,
|
||||
admin: boolean,
|
||||
nonce: string,
|
||||
): string {
|
||||
const mac = createHmac("sha1", this.sharedSecret);
|
||||
mac.update(nonce);
|
||||
mac.update("\x00");
|
||||
mac.update(username);
|
||||
mac.update("\x00");
|
||||
mac.update(password);
|
||||
mac.update("\x00");
|
||||
mac.update(admin ? "admin" : "notadmin");
|
||||
|
||||
return mac.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SynapseAdmin instance for a different homeserver
|
||||
*
|
||||
* @param baseUrl - The base URL of the homeserver
|
||||
* @param sharedSecret - The shared secret (defaults to test secret)
|
||||
* @returns A new SynapseAdmin instance
|
||||
*/
|
||||
public static forHomeserver(
|
||||
baseUrl: string,
|
||||
sharedSecret: string = "test_shared_secret_for_local_dev_only",
|
||||
): SynapseAdmin {
|
||||
return new SynapseAdmin(baseUrl, sharedSecret);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,12 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
test.slow();
|
||||
|
||||
const [florian, timo] = await Promise.all([
|
||||
addUser("florian", HOST1),
|
||||
addUser("timo", HOST2),
|
||||
]);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// await florian.page.pause();
|
||||
|
||||
@@ -75,18 +75,11 @@ widgetTest(
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.slow();
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
@@ -65,40 +66,26 @@ widgetTest(
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
// Wait for "Waiting for media..." to disappear (with timeout)
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000, // Maximum time to wait
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
|
||||
await timo.page.waitForTimeout(3000);
|
||||
const blockDisplayCount = await timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(
|
||||
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
// increase the timeouts, it is a long test and it is annoying to retry from the beginning for a single timeout.
|
||||
test.slow();
|
||||
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // We are registering multiple users here, give it more time
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
const robin = await addUser("Robin", HOST1);
|
||||
const halfshot = await addUser("Halfshot", HOST1);
|
||||
const florian = await addUser("florian", HOST1);
|
||||
const [valere, timo, robin, halfshot, florian] = await Promise.all([
|
||||
addUser("Valere", HOST1),
|
||||
addUser("Timo", HOST1),
|
||||
addUser("Robin", HOST1),
|
||||
addUser("Halfshot", HOST1),
|
||||
addUser("florian", HOST1),
|
||||
]);
|
||||
|
||||
const roomName = "Group Call Room";
|
||||
await TestHelpers.createRoom(roomName, valere.page, [
|
||||
@@ -47,52 +50,55 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}
|
||||
await Promise.all(
|
||||
[timo, robin, halfshot, florian].map(async (user) => {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}),
|
||||
);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
// No lobby, should start with video on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await Promise.all(
|
||||
[timo, robin, halfshot, florian].map(async (user) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
for (const user of [valere, timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
|
||||
for (const participant of [valere, timo, robin, halfshot, florian]) {
|
||||
// Check the names are correct
|
||||
await expect(frame.getByText(participant.displayName)).toBeVisible();
|
||||
}
|
||||
await Promise.all(
|
||||
[valere, timo, robin, halfshot, florian].map(async (user) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// There is no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(5000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
await Promise.all(
|
||||
[valere, timo, robin, halfshot, florian].map(async (user) => {
|
||||
// Check the names are correct
|
||||
await expect(frame.getByText(user.displayName)).toBeVisible();
|
||||
}),
|
||||
);
|
||||
expect(blockDisplayCount).toBe(5);
|
||||
}
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
// Use a bigger timeout here
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(5);
|
||||
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 5);
|
||||
}),
|
||||
);
|
||||
|
||||
// Quickly test muting one participant to see it reflects and that our asserts works
|
||||
const florianFrame = florian.page
|
||||
@@ -108,28 +114,16 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Start video");
|
||||
await expect(florianVideoButton).not.toBeChecked();
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
{
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
await expect(frame.locator("video")).toHaveCount(5, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// out of 5 ONLY 4 are visible (display:block) !!
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
expect(blockDisplayCount).toBe(4);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 4);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
|
||||
const callRoom = "CallRoom";
|
||||
|
||||
@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
// check that the video is on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
).toBeVisible({
|
||||
// Increase timeout, as this expect was flaky
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(1);
|
||||
|
||||
const pipVideo = videoElements[0];
|
||||
await expect(pipVideo).toHaveCSS("object-fit", "cover");
|
||||
|
||||
152
playwright/widget/screen-share.test.ts
Normal file
152
playwright/widget/screen-share.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // We are registering multiple users here, give it more time
|
||||
|
||||
const [alice, bob, carol] = await Promise.all([
|
||||
addUser("Alice", HOST1),
|
||||
addUser("Bob", HOST1),
|
||||
addUser("Carol", HOST1),
|
||||
]);
|
||||
|
||||
const roomName = "Meeting Room";
|
||||
await TestHelpers.createRoom(roomName, alice.page, [bob.mxId, carol.mxId]);
|
||||
|
||||
for (const user of [bob, carol]) {
|
||||
// Accept the invite
|
||||
// This isn't super stable to get this as this super generic locator,
|
||||
// but it works for now.
|
||||
await TestHelpers.acceptRoomInvite(roomName, user.page);
|
||||
}
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(alice.page, false);
|
||||
await expect(
|
||||
alice.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(alice.page);
|
||||
|
||||
for (const user of [bob, carol]) {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}
|
||||
|
||||
for (const user of [alice, bob, carol]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// Expect 3 video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(3, {
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// await alice.page.pause();
|
||||
|
||||
await alice.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByRole("switch", { name: "Share screen" })
|
||||
.click();
|
||||
|
||||
// await alice.page.pause();
|
||||
|
||||
for (const user of [alice, bob, carol]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// Expect 4 (3 + screen share) video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(4, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
// Alice should be in grid mode as she is local sharing
|
||||
{
|
||||
const frame = alice.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByRole("radio", { name: "Grid" })).toBeChecked();
|
||||
}
|
||||
|
||||
// Others should have switched to spotlight
|
||||
for (const user of [bob, carol]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
await expect(frame.getByRole("radio", { name: "Spotlight" })).toBeChecked();
|
||||
}
|
||||
// await alice.page.pause();
|
||||
// await bob.page.pause();
|
||||
|
||||
// Let's start another screen share from bob
|
||||
await bob.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByRole("switch", { name: "Share screen" })
|
||||
.click();
|
||||
|
||||
{
|
||||
const frame = carol.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// Expect 5 (2 + screen share) video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(5, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(2);
|
||||
|
||||
// Expect 2 indicators at the bottom
|
||||
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
|
||||
|
||||
// Check the first indicator is visible
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").first(),
|
||||
).toHaveAttribute("data-visible", "true");
|
||||
|
||||
await carol.page.pause();
|
||||
|
||||
// now click on next
|
||||
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await frame.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Check the second indicator is visible
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").nth(1),
|
||||
).toHaveAttribute("data-visible", "true");
|
||||
// the first one should be grayed out
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").first(),
|
||||
).toHaveAttribute("data-visible", "false");
|
||||
|
||||
// There should be a prev button now
|
||||
await expect(frame.getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
// await carol.page.pause();
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@ widgetTest.skip(
|
||||
);
|
||||
|
||||
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
test.slow(); // Triples the timeout
|
||||
test.slow();
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("incall_leave")
|
||||
.click();
|
||||
.click({ timeout: 15000 });
|
||||
|
||||
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
expect,
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
export const HOST1 = "https://app.m.localhost/#/welcome";
|
||||
@@ -26,14 +29,14 @@ export class TestHelpers {
|
||||
voice: boolean = false,
|
||||
): Promise<void> {
|
||||
const buttonName = voice ? "Voice call" : "Video call";
|
||||
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
|
||||
await page.getByRole("button", { name: buttonName }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Element Call" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: buttonName }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async joinCallFromLobby(page: Page): Promise<void> {
|
||||
@@ -51,15 +54,40 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
public static async joinCallInCurrentDM(
|
||||
page: Page,
|
||||
audioOnly: boolean = false,
|
||||
): Promise<void> {
|
||||
await this.joinCallInRoom(page, audioOnly, true);
|
||||
}
|
||||
|
||||
public static async joinCallInCurrentRoom(
|
||||
page: Page,
|
||||
audioOnly: boolean = false,
|
||||
): Promise<void> {
|
||||
// This is the header button that notifies about an ongoing call
|
||||
const label = audioOnly ? "Voice call started" : "Video call started";
|
||||
await expect(page.getByText(label)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Join" }).click();
|
||||
await this.joinCallInRoom(page, audioOnly, false);
|
||||
}
|
||||
|
||||
public static async joinCallInRoom(
|
||||
page: Page,
|
||||
audioOnly: boolean = false,
|
||||
isDM: boolean = false,
|
||||
): Promise<void> {
|
||||
// XXX This using the notification toast to join the room.
|
||||
// Not the button in the header
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const label = isDM
|
||||
? audioOnly
|
||||
? "Incoming voice call"
|
||||
: "Incoming video call"
|
||||
: "Group call started";
|
||||
await expect(page.getByText(label)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Join" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,97 +102,90 @@ export class TestHelpers {
|
||||
clientHandle: JSHandle<MatrixClient>;
|
||||
mxId: string;
|
||||
}> {
|
||||
// Determine which homeserver to use based on the host
|
||||
const synapseBaseUrl =
|
||||
host === HOST2
|
||||
? "https://synapse.othersite.m.localhost"
|
||||
: "https://synapse.m.localhost";
|
||||
|
||||
// Register user via Synapse Admin API to speed things up
|
||||
const synapseAdmin = SynapseAdmin.forHomeserver(synapseBaseUrl);
|
||||
const credentials = await synapseAdmin.registerUser(
|
||||
username,
|
||||
PASSWORD,
|
||||
username,
|
||||
);
|
||||
|
||||
// STEP 2: Open browser and login
|
||||
const userContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
await page.goto(host);
|
||||
await page.getByRole("link", { name: "Create Account" }).click();
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).click();
|
||||
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();
|
||||
|
||||
await page.getByRole("link", { name: "Sign in" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||
).toBeVisible({
|
||||
// Increase timeout as registration can be slow :/
|
||||
timeout: 15_000,
|
||||
// Increase timeout here :/ flaky
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await this.maybeDismissBrowserNotSupportedToast(page);
|
||||
await this.maybeDismissServiceWorkerWarningToast(page);
|
||||
await this.dismissStartupToasts(page);
|
||||
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const mxId = (await clientHandle.evaluate(
|
||||
(cli: MatrixClient) => cli.getUserId(),
|
||||
clientHandle,
|
||||
))!;
|
||||
|
||||
const mxId = credentials.user_id;
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
private static async maybeDismissBrowserNotSupportedToast(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const browserUnsupportedToast = page
|
||||
.getByText("Element does not support this browser")
|
||||
.locator("..")
|
||||
.locator("..");
|
||||
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
|
||||
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
|
||||
// dismiss (if part of expected toats)
|
||||
public static async dismissStartupToasts(page: Page): Promise<void> {
|
||||
const expectedToasts = [
|
||||
{ title: "Failed to load service worker", button: "OK" },
|
||||
{ title: "Back up your chats", button: "Dismiss" },
|
||||
{ title: "Element does not support this browser", button: "Dismiss" },
|
||||
];
|
||||
|
||||
// Dismiss incompatible browser toast
|
||||
const dismissButton = browserUnsupportedToast.getByRole("button", {
|
||||
name: "Dismiss",
|
||||
});
|
||||
try {
|
||||
await expect(dismissButton).toBeVisible({ timeout: 700 });
|
||||
await dismissButton.click();
|
||||
} catch {
|
||||
// dismissButton not visible, continue as normal
|
||||
}
|
||||
}
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
|
||||
private static async maybeDismissServiceWorkerWarningToast(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const toast = page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByText("Failed to load service worker");
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await toast.waitFor({ state: "visible", timeout: 700 });
|
||||
const title = await toast.locator(".mx_Toast_title h2").textContent();
|
||||
|
||||
try {
|
||||
await expect(toast).toBeVisible({ timeout: 700 });
|
||||
await page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByRole("button", { name: "OK" })
|
||||
.click();
|
||||
} catch {
|
||||
// toast not visible, continue as normal
|
||||
}
|
||||
}
|
||||
// Find the matching toast config
|
||||
const toastConfig = expectedToasts.find((t) =>
|
||||
title?.includes(t.title),
|
||||
);
|
||||
|
||||
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
|
||||
const toast = page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByText("Back up your chats");
|
||||
|
||||
try {
|
||||
await expect(toast).toBeVisible({ timeout: 700 });
|
||||
await page
|
||||
.locator(".mx_Toast_toast")
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
} catch {
|
||||
// toast not visible, continue as normal
|
||||
if (toastConfig) {
|
||||
await toast.getByRole("button", { name: toastConfig.button }).click();
|
||||
} else {
|
||||
// Unknown toast. We don't want to act on unknown toasts
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// No toast visible, exit loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +199,16 @@ export class TestHelpers {
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
|
||||
// Invite users if any
|
||||
if (andInvite.length > 0) {
|
||||
@@ -199,6 +224,7 @@ export class TestHelpers {
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModal(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,14 +237,17 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
|
||||
await page.getByRole("option", { name: roomName }).click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("heading", { name: roomName }),
|
||||
).toBeVisible();
|
||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,8 +262,12 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await page.getByRole("button", { name: "Video call" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
@@ -308,4 +341,52 @@ export class TestHelpers {
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Invite new contacts to this" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Invite" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModalDM(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Start a chat with this new contact?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async expectVisibleVideoCount(
|
||||
frame: FrameLocator,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) =>
|
||||
window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
.toBe(count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
|
||||
@@ -43,7 +41,7 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -113,8 +111,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
@@ -136,7 +132,7 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -200,8 +196,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
|
||||
14000
pnpm-lock.yaml
generated
Normal file
14000
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
|
||||
onlyBuiltDependencies:
|
||||
- "matrix-js-sdk"
|
||||
@@ -54,8 +54,8 @@
|
||||
"matchFileNames": ["embedded/**/*"]
|
||||
},
|
||||
{
|
||||
"groupName": "Yarn",
|
||||
"matchDepNames": ["yarn"]
|
||||
"groupName": "Pnpm",
|
||||
"matchDepNames": ["pnpm"]
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
|
||||
62
scripts/.pnpmfile.cjs
Normal file
62
scripts/.pnpmfile.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
// DONT RUN THIS FILE MANUALLY
|
||||
// This file is intended to be used with `pnpm links:on` and `pnpm links:off` which will copy this file to the project root.
|
||||
// See docs/linking.md for details.
|
||||
//
|
||||
//
|
||||
// Created based on https://github.com/element-hq/element-call/blob/60fae70a60e3697eb41210ccf1e400cab37df7c8/.yarn/plugins/linker.cjs
|
||||
// and the following prompt history:
|
||||
// - Can you convert this yarn plugin into a pnpm plugin.
|
||||
// - The goal is to not have modifications to the package.json and lock files so that we do not track links on gh.
|
||||
// This seems to modify the package.json file.
|
||||
// What can we do with pnpm to have the link inforamtion in a seperate file
|
||||
// - why do you cache the loaded links. When does this file get executed?
|
||||
// Do we need this optimization.
|
||||
// How do we guarantee, that we aleays use the most recent content from the links file?
|
||||
//
|
||||
// Manual transition to cjs. Claude proposed manual yaml parsing.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadLinks() {
|
||||
try {
|
||||
return require(path.join(__dirname, ".links.cjs"));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPackage(pkg, context) {
|
||||
const links = loadLinks();
|
||||
if (!links) return pkg;
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
|
||||
);
|
||||
if (pkg.name !== manifest.name) return pkg;
|
||||
|
||||
for (const [name, linkPath] of Object.entries(links)) {
|
||||
const resolved = `link:${path.resolve(__dirname, linkPath)}`;
|
||||
if (pkg.dependencies && pkg.dependencies[name]) {
|
||||
context.log(`Linking ${name} -> ${resolved}`);
|
||||
pkg.dependencies[name] = resolved;
|
||||
} else if (pkg.devDependencies && pkg.devDependencies[name]) {
|
||||
context.log(`Linking ${name} -> ${resolved}`);
|
||||
pkg.devDependencies[name] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hooks: {
|
||||
readPackage,
|
||||
},
|
||||
};
|
||||
@@ -5,5 +5,5 @@ set -ex
|
||||
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
|
||||
|
||||
corepack enable
|
||||
yarn install
|
||||
yarn run build
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/sh
|
||||
if [ -n "$USE_DOCKER" ]; then
|
||||
set -ex
|
||||
yarn build
|
||||
pnpm 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
|
||||
exec pnpm dev --host
|
||||
fi
|
||||
|
||||
26
scripts/setup-linking.sh
Executable file
26
scripts/setup-linking.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
|
||||
|
||||
LINKSFILE=.links.cjs
|
||||
echo "Checking for existing linking configuration in $LINKSFILE..."
|
||||
if test -f "$LINKSFILE"; then
|
||||
echo "Linking configuration found in $LINKSFILE."
|
||||
else
|
||||
echo "No $LINKSFILE -> Creating $LINKSFILE with default values. Please edit this file to point to your local checkouts of the dependencies you want to link."
|
||||
echo '''// Packages to link to local checkouts
|
||||
module.exports = {
|
||||
"matrix-js-sdk": "../your/path/matrix-js-sdk",
|
||||
"matrix-widget-api": "../your/path/matrix-widget-api",
|
||||
};''' > $LINKSFILE
|
||||
fi
|
||||
echo "updating local git hookPath to .githooks"
|
||||
git config --local core.hooksPath .githooks
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Update: .links.cjs to your liking"
|
||||
echo "Run: 'pnpm links:on' to test your .links.cjs"
|
||||
echo "Run: 'git commit' with links enabled to test the git pre-commit hook."
|
||||
echo "Run: 'pnpm links:off' to be able to commit again"
|
||||
echo "Run: 'git config --local core.hooksPath \"\"' to allow committing with linking on (not recommended)"
|
||||
echo "Run: 'rm links.cjs' & 'git config --local core.hooksPath \"\"' to fully revert what this script did"
|
||||
@@ -13,8 +13,8 @@ This folder contains an example index.html file that showcases the sdk in use (h
|
||||
To get started run
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn build:sdk
|
||||
pnpm install
|
||||
pnpm build:sdk
|
||||
```
|
||||
|
||||
in the repository root.
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
.bar {
|
||||
block-size: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar > header {
|
||||
position: absolute;
|
||||
position: sticky;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 64px;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
import { platform } from "./Platform";
|
||||
@@ -49,7 +50,9 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [hidden, setHidden] = useState<boolean>(false);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const context = useMemo(
|
||||
() => ({ setTitle, setSecondaryButton, setHidden }),
|
||||
[setTitle, setHidden, setSecondaryButton],
|
||||
@@ -68,8 +71,8 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
>
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
<CollapseIcon />
|
||||
<IconButton size="24px" onClick={onBackClick}>
|
||||
<CollapseIcon aria-hidden />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</LeftNav>
|
||||
@@ -114,6 +117,10 @@ export function useAppBarHidden(hidden: boolean): void {
|
||||
if (setHidden !== undefined) {
|
||||
setHidden(hidden);
|
||||
return (): void => setHidden(false);
|
||||
} else if (platform !== "desktop") {
|
||||
logger.warn(
|
||||
"[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect",
|
||||
);
|
||||
}
|
||||
}, [setHidden, hidden]);
|
||||
}
|
||||
@@ -129,6 +136,10 @@ export function useAppBarSecondaryButton(button: ReactNode): void {
|
||||
if (setSecondaryButton !== undefined) {
|
||||
setSecondaryButton(button);
|
||||
return (): void => setSecondaryButton("");
|
||||
} else if (platform !== "desktop") {
|
||||
logger.warn(
|
||||
"[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect",
|
||||
);
|
||||
}
|
||||
}, [button, setSecondaryButton]);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={open}
|
||||
// This autofocus is a custom vault property and not the
|
||||
// standard HTML autofocus attribute.
|
||||
// It makes the Drawer.Root behave like the `DialogRoot`
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onOpenChange={onOpenChange}
|
||||
dismissible={onDismiss !== undefined}
|
||||
>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("audio")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={MicOnSolidIcon}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("video")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={VideoCallSolidIcon}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`AppBar > renders 1`] = `
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
@@ -25,6 +25,7 @@ exports[`AppBar > renders 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
QualitySurveyEventTracker,
|
||||
CallDisconnectedEventTracker,
|
||||
CallConnectDurationTracker,
|
||||
CallReconnectingTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
@@ -421,4 +422,5 @@ export class PosthogAnalytics {
|
||||
public eventQualitySurvey = new QualitySurveyEventTracker();
|
||||
public eventCallDisconnected = new CallDisconnectedEventTracker();
|
||||
public eventCallConnectDuration = new CallConnectDurationTracker();
|
||||
public eventCallReconnecting = new CallReconnectingTracker();
|
||||
}
|
||||
|
||||
237
src/analytics/PosthogEvents.test.ts
Normal file
237
src/analytics/PosthogEvents.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
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 {
|
||||
expect,
|
||||
describe,
|
||||
it,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallReconnectingTracker,
|
||||
type CallReconnectingReason,
|
||||
} from "./PosthogEvents";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
const defaultCounters = {
|
||||
roomEventEncryptionKeysSent: 10,
|
||||
roomEventEncryptionKeysReceived: 5,
|
||||
};
|
||||
|
||||
const defaultTotals = {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 500,
|
||||
};
|
||||
|
||||
function createMockRtcSession(overrides?: {
|
||||
counters?: Partial<typeof defaultCounters>;
|
||||
totals?: Partial<typeof defaultTotals>;
|
||||
}): MatrixRTCSession {
|
||||
return {
|
||||
statistics: {
|
||||
counters: { ...defaultCounters, ...overrides?.counters },
|
||||
totals: { ...defaultTotals, ...overrides?.totals },
|
||||
},
|
||||
} as unknown as MatrixRTCSession;
|
||||
}
|
||||
|
||||
describe("CallEnded", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("warns if startTime is missing when track is called", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn");
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.track("test-call-id", 2, false, mockSession);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
|
||||
);
|
||||
expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks event with correct properties when startTime is set", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date(Date.now() - 60000));
|
||||
tracker.cacheParticipantCountChanged(5);
|
||||
tracker.track("test-call-id", 3, true, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: "test-call-id",
|
||||
callParticipantsMax: 5,
|
||||
callParticipantsOnLeave: 3,
|
||||
callDuration: expect.closeTo(60, 1),
|
||||
roomEventEncryptionKeysSent: 10,
|
||||
roomEventEncryptionKeysReceived: 5,
|
||||
roomEventEncryptionKeysReceivedAverageAge: 100,
|
||||
callReconnectingCount: 0,
|
||||
callReconnectingCountSync: 0,
|
||||
callReconnectingCountMembership: 0,
|
||||
callReconnectingCountProbablyLeft: 0,
|
||||
callReconnectingCountLivekit: 0,
|
||||
},
|
||||
{ send_instantly: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks maxParticipantsCount correctly across multiple changes", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.cacheParticipantCountChanged(3);
|
||||
tracker.cacheParticipantCountChanged(7);
|
||||
tracker.cacheParticipantCountChanged(2);
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
callParticipantsMax: 7,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession({
|
||||
counters: { roomEventEncryptionKeysReceived: 0 },
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roomEventEncryptionKeysReceivedAverageAge: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession({
|
||||
counters: { roomEventEncryptionKeysReceived: 4 },
|
||||
totals: { roomEventEncryptionKeysReceivedTotalAge: 200 },
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roomEventEncryptionKeysReceivedAverageAge: 50,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes send_instantly option correctly", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ send_instantly: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes per-reason reconnecting counts in CallEnded", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.cacheReconnecting("sync");
|
||||
tracker.cacheReconnecting("sync");
|
||||
tracker.cacheReconnecting("livekit");
|
||||
tracker.cacheReconnecting("membership");
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
callReconnectingCount: 4,
|
||||
callReconnectingCountSync: 2,
|
||||
callReconnectingCountMembership: 1,
|
||||
callReconnectingCountProbablyLeft: 0,
|
||||
callReconnectingCountLivekit: 1,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CallReconnecting", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("tracks event with correct shape", () => {
|
||||
const tracker = new CallReconnectingTracker();
|
||||
tracker.track("!room:example.org", "sync", 3.5);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "CallReconnecting",
|
||||
callId: "!room:example.org",
|
||||
reason: "sync",
|
||||
reconnectDuration: 3.5,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"sync",
|
||||
"membership",
|
||||
"probablyLeft",
|
||||
"livekit",
|
||||
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
|
||||
const tracker = new CallReconnectingTracker();
|
||||
tracker.track("!room:example.org", reason, 1.0);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason, reconnectDuration: 1.0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
interface CallEnded extends IPosthogEvent {
|
||||
eventName: "CallEnded";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
callParticipantsOnLeave: number;
|
||||
callParticipantsMax: number;
|
||||
@@ -24,16 +25,43 @@ interface CallEnded extends IPosthogEvent {
|
||||
roomEventEncryptionKeysSent: number;
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
roomEventEncryptionKeysReceivedAverageAge: number;
|
||||
callReconnectingCount: number;
|
||||
callReconnectingCountSync: number;
|
||||
callReconnectingCountMembership: number;
|
||||
callReconnectingCountProbablyLeft: number;
|
||||
callReconnectingCountLivekit: number;
|
||||
}
|
||||
|
||||
export class CallEndedTracker {
|
||||
private cache: { startTime: Date; maxParticipantsCount: number } = {
|
||||
startTime: new Date(0),
|
||||
private cache: {
|
||||
startTime?: Date;
|
||||
maxParticipantsCount: number;
|
||||
reconnectingCount: number;
|
||||
reconnectingCountByReason: Record<CallReconnectingReason, number>;
|
||||
} = {
|
||||
startTime: undefined,
|
||||
maxParticipantsCount: 0,
|
||||
reconnectingCount: 0,
|
||||
reconnectingCountByReason: {
|
||||
sync: 0,
|
||||
membership: 0,
|
||||
probablyLeft: 0,
|
||||
livekit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
public cacheStartCall(time: Date): void {
|
||||
this.cache.startTime = time;
|
||||
this.cache = {
|
||||
startTime: time,
|
||||
maxParticipantsCount: 0,
|
||||
reconnectingCount: 0,
|
||||
reconnectingCountByReason: {
|
||||
sync: 0,
|
||||
membership: 0,
|
||||
probablyLeft: 0,
|
||||
livekit: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public cacheParticipantCountChanged(count: number): void {
|
||||
@@ -43,37 +71,57 @@ export class CallEndedTracker {
|
||||
);
|
||||
}
|
||||
|
||||
public cacheReconnecting(reason: CallReconnectingReason): void {
|
||||
this.cache.reconnectingCount++;
|
||||
this.cache.reconnectingCountByReason[reason]++;
|
||||
}
|
||||
|
||||
public track(
|
||||
callId: string,
|
||||
callParticipantsNow: number,
|
||||
sendInstantly: boolean,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: callId,
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
if (this.cache.startTime) {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: callId,
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
callReconnectingCount: this.cache.reconnectingCount,
|
||||
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
|
||||
callReconnectingCountMembership:
|
||||
this.cache.reconnectingCountByReason.membership,
|
||||
callReconnectingCountProbablyLeft:
|
||||
this.cache.reconnectingCountByReason.probablyLeft,
|
||||
callReconnectingCountLivekit:
|
||||
this.cache.reconnectingCountByReason.livekit,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CallStarted extends IPosthogEvent {
|
||||
eventName: "CallStarted";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -134,6 +182,7 @@ export class LoginTracker {
|
||||
interface MuteMicrophone {
|
||||
eventName: "MuteMicrophone";
|
||||
targetMuteState: "mute" | "unmute";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -150,6 +199,7 @@ export class MuteMicrophoneTracker {
|
||||
interface MuteCamera {
|
||||
eventName: "MuteCamera";
|
||||
targetMuteState: "mute" | "unmute";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -165,6 +215,7 @@ export class MuteCameraTracker {
|
||||
|
||||
interface UndecryptableToDeviceEvent {
|
||||
eventName: "UndecryptableToDeviceEvent";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -179,6 +230,7 @@ export class UndecryptableToDeviceEventTracker {
|
||||
|
||||
interface QualitySurveyEvent {
|
||||
eventName: "QualitySurvey";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
feedbackText: string;
|
||||
stars: number;
|
||||
@@ -243,3 +295,32 @@ export class CallConnectDurationTracker {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type CallReconnectingReason =
|
||||
| "sync"
|
||||
| "membership"
|
||||
| "probablyLeft"
|
||||
| "livekit";
|
||||
|
||||
interface CallReconnecting extends IPosthogEvent {
|
||||
eventName: "CallReconnecting";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
reason: CallReconnectingReason;
|
||||
reconnectDuration: number;
|
||||
}
|
||||
|
||||
export class CallReconnectingTracker {
|
||||
public track(
|
||||
callId: string,
|
||||
reason: CallReconnectingReason,
|
||||
reconnectDuration: number,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallReconnecting>({
|
||||
eventName: "CallReconnecting",
|
||||
callId,
|
||||
reason,
|
||||
reconnectDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type ComponentPropsWithoutRef, type FC } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
Button as CpdButton,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
MicOnSolidIcon,
|
||||
MicOffSolidIcon,
|
||||
@@ -16,14 +20,19 @@ import {
|
||||
VideoCallOffSolidIcon,
|
||||
EndCallIcon,
|
||||
ShareScreenSolidIcon,
|
||||
SettingsSolidIcon,
|
||||
OverflowHorizontalIcon,
|
||||
OverflowVerticalIcon,
|
||||
VolumeOnSolidIcon,
|
||||
VolumeOffSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./Button.module.css";
|
||||
import callFooterStyles from "../components/CallFooter.module.css";
|
||||
import { platform } from "../Platform";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
@@ -38,7 +47,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={Icon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
@@ -49,7 +58,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
@@ -64,7 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={Icon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
@@ -75,7 +84,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
size: "md" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -102,7 +111,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
};
|
||||
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
@@ -124,18 +133,87 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "md" | "lg";
|
||||
loudspeakerModeEnabled: boolean;
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
|
||||
loudspeakerModeEnabled,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// if the target is the earpice, we are currently in loudspeaker mode.
|
||||
const label = loudspeakerModeEnabled
|
||||
? t("settings.devices.loudspeaker")
|
||||
: t("settings.devices.handset");
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
|
||||
{...props}
|
||||
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
|
||||
aria-checked={loudspeakerModeEnabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
function classNamesForScreenWidth(
|
||||
className?: string,
|
||||
forScreenWidth?: "wide" | "narrow",
|
||||
): string {
|
||||
return classNames(className, {
|
||||
[callFooterStyles.settingsOnlyShowWide]: forScreenWidth === "wide",
|
||||
[callFooterStyles.settingsOnlyShowNarrow]: forScreenWidth === "narrow",
|
||||
});
|
||||
}
|
||||
|
||||
interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
/** If this buttons should be setup to be used in the app bar */
|
||||
showForScreenWidth?: "wide" | "narrow";
|
||||
kind?: "secondary" | "primary";
|
||||
}
|
||||
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon =
|
||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<IconButton
|
||||
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||
{...props}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "md" | "lg";
|
||||
/** If this buttons should be setup to be used in the app bar */
|
||||
showForScreenWidth?: "wide" | "narrow";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<CpdButton
|
||||
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||
iconOnly
|
||||
Icon={SettingsSolidIcon}
|
||||
kind="secondary"
|
||||
Icon={
|
||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
|
||||
}
|
||||
kind={"secondary"}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const InviteButton: FC<
|
||||
> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}>
|
||||
<Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
|
||||
{t("action.invite")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,13 @@ function TestComponent({
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
||||
<ReactionToggleButton
|
||||
reactionData={{
|
||||
reactions$: vm.reactions$,
|
||||
handsRaised$: vm.handsRaised$,
|
||||
}}
|
||||
identifier={localIdent}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -28,13 +28,14 @@ import classNames from "classnames";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
import {
|
||||
type RaisedHandInfo,
|
||||
type ReactionOption,
|
||||
ReactionSet,
|
||||
ReactionsRowSize,
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
raised: boolean;
|
||||
@@ -163,15 +164,22 @@ export function ReactionPopupMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReactionData {
|
||||
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
|
||||
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
||||
reactions$: Behavior<Record<string, ReactionOption>>;
|
||||
}
|
||||
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
reactionData: ReactionData;
|
||||
identifier: string;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
/** List of participants raising their hand */
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
identifier,
|
||||
vm,
|
||||
reactionData: { handsRaised$, reactions$ },
|
||||
...props
|
||||
}: ReactionToggleButtonProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,8 +188,8 @@ export function ReactionToggleButton({
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
||||
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(reactions$)[identifier];
|
||||
|
||||
useEffect(() => {
|
||||
// Clear whenever the reactions menu state changes.
|
||||
|
||||
@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_7m_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_36_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_0_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_1j_"
|
||||
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
37
src/components/CallFooter.mdx
Normal file
37
src/components/CallFooter.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
{/**
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
**/}
|
||||
|
||||
{/**
|
||||
This is a custom doc page overwriting the default autodocs tag.
|
||||
This can be done by using the same filename as the component
|
||||
With the help of Primary, Controls,Stories the overhead is minimal
|
||||
**/}
|
||||
|
||||
import {
|
||||
Meta,
|
||||
Primary,
|
||||
Controls,
|
||||
Stories,
|
||||
Title,
|
||||
Subtitle,
|
||||
} from "@storybook/addon-docs/blocks";
|
||||
import * as CallFooterStories from "./CallFooter.stories";
|
||||
|
||||
<Meta of={CallFooterStories} />
|
||||
|
||||
<Title> Call Footer </Title>
|
||||
|
||||
The footer compoentn contains all main interactions needed for a call.
|
||||
|
||||
<Subtitle> Mobile layouts </Subtitle>
|
||||
|
||||
This component is reactive. To properly check the mobile layout, you will need to click on the stories in the left sidebar to see the
|
||||
component on a mobile screen.
|
||||
The story summary here does not render the mobile layouts correctly.
|
||||
|
||||
<Primary />
|
||||
<Controls of={CallFooterStories.Primary} />
|
||||
<Stories />
|
||||
166
src/components/CallFooter.module.css
Normal file
166
src/components/CallFooter.module.css
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
z-index: var(--call-view-header-footer-layer);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: ". buttons layout";
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
/* Ensure that footer lies within the safe area */
|
||||
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
|
||||
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||
padding-block: var(--cpd-space-10x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.footer.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer.overlay {
|
||||
/* Note that the footer is still position: sticky in this case so that certain
|
||||
tiles can move up out of the way of the footer when visible. */
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.footer.overlay.hidden {
|
||||
display: grid;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
/* Switch to position: absolute so the footer takes up no space in the layout
|
||||
when hidden. */
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.settingsLogoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-4x);
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
padding-inline-start: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
grid-area: buttons;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-area: layout;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/*First hide the logo*/
|
||||
@media (max-width: 750px) {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsOnlyShowNarrow {
|
||||
display: none;
|
||||
}
|
||||
.settingsOnlyShowWide {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
||||
Once we exceed 500 we hide everything except the buttons.
|
||||
*/
|
||||
@media (max-width: 500px) {
|
||||
.footer {
|
||||
grid-template-areas: "buttons buttons buttons";
|
||||
}
|
||||
|
||||
.settingsOnlyShowNarrow {
|
||||
display: inherit;
|
||||
}
|
||||
.settingsOnlyShowWide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settingsLogoContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-4x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PIP custom css */
|
||||
@media (max-height: 400px) {
|
||||
.shareScreen {
|
||||
display: flex;
|
||||
}
|
||||
.footer {
|
||||
padding-block-start: var(--cpd-space-3x);
|
||||
padding-block-end: calc(
|
||||
env(safe-area-inset-bottom) + var(--cpd-space-2x)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.raiseHand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.buttons {
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
262
src/components/CallFooter.stories.tsx
Normal file
262
src/components/CallFooter.stories.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn } from "storybook/test";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "@vector-im/compound-web";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CallFooter, type FooterProps } from "./CallFooter";
|
||||
import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
|
||||
function CallFooterWrapper(props: FooterProps): ReactNode {
|
||||
return (
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions: false,
|
||||
toggleRaisedHand: async () => Promise.resolve(),
|
||||
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<CallFooter {...props} />
|
||||
</ReactionsSenderContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterWrapper,
|
||||
} satisfies Meta<typeof CallFooterWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
const reactionData = {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const fnArgType = {
|
||||
control: { type: "select" as const },
|
||||
options: ["MockedCallback", "undefined"],
|
||||
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||
};
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
hideLogo: true,
|
||||
layoutMode: "grid",
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
setLayoutMode: fn(),
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
hangup: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||
mapping: {
|
||||
NoOutputCallback: undefined,
|
||||
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
setLayoutMode: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: true,
|
||||
audioOptions: [
|
||||
{ label: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
export const WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioVideoEnabled: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioOutputSpeaker: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioOutputSwitcher: { targetOutput: "earpiece", switch: fn() },
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioOutputEarpiece: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
};
|
||||
export const WithReactions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
reactionIdentifier,
|
||||
reactionData,
|
||||
},
|
||||
};
|
||||
export const Pip: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
asPip: true,
|
||||
},
|
||||
};
|
||||
export const NoControlsWithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideControls: true,
|
||||
hideLogo: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const DebugData: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
debugTileLayout: true,
|
||||
tileStoreGeneration: 74,
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableMediaDevices: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
audioOutputSwitcher: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileLayout: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
|
||||
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const Lobby: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
openSettings: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
hideLogo: true,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButtonMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
hideLogo: true,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
308
src/components/CallFooter.tsx
Normal file
308
src/components/CallFooter.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
VideoButton,
|
||||
ShareScreenButton,
|
||||
SettingsButton,
|
||||
ReactionToggleButton,
|
||||
LoudspeakerButton,
|
||||
SettingsIconButton,
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
switch: () => void;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
/** Children will only be visible if the component is wider than 5*/
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
|
||||
audioEnabled: boolean;
|
||||
/** Also controls if the audioMute button is disabled */
|
||||
toggleAudio: (() => void) | undefined;
|
||||
videoEnabled: boolean;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
hideControls?: boolean;
|
||||
/** hide the entire footer*/
|
||||
hidden?: boolean;
|
||||
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
|
||||
asPip?: boolean;
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens.*/
|
||||
asOverlay?: boolean;
|
||||
|
||||
layoutMode?: GridMode;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode?: (mode: GridMode) => void;
|
||||
|
||||
sharingScreen?: boolean;
|
||||
toggleScreenSharing?: () => void;
|
||||
|
||||
/** Also controls if the audio button is visible */
|
||||
audioOutputSwitcher?: AudioOutputSwitcher;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings?: () => void;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup?: () => void;
|
||||
|
||||
reactionIdentifier?: string;
|
||||
reactionData?: ReactionData;
|
||||
|
||||
hideLogo?: boolean;
|
||||
// debug stuff
|
||||
debugTileLayout?: boolean;
|
||||
tileStoreGeneration?: number;
|
||||
|
||||
audioOptions?: MenuOptions[];
|
||||
videoOptions?: MenuOptions[];
|
||||
selectedAudio?: string;
|
||||
selectedVideo?: string;
|
||||
selectAudioDevice?: (deviceId: string) => void;
|
||||
selectVideoDevice?: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
ref,
|
||||
children,
|
||||
asOverlay,
|
||||
hidden,
|
||||
hideControls,
|
||||
hideLogo,
|
||||
asPip,
|
||||
layoutMode,
|
||||
setLayoutMode,
|
||||
openSettings,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
sharingScreen,
|
||||
toggleScreenSharing,
|
||||
reactionIdentifier,
|
||||
reactionData,
|
||||
audioOutputSwitcher,
|
||||
hangup,
|
||||
debugTileLayout,
|
||||
tileStoreGeneration,
|
||||
|
||||
audioOptions,
|
||||
videoOptions,
|
||||
selectedAudio,
|
||||
selectedVideo,
|
||||
selectAudioDevice,
|
||||
selectVideoDevice,
|
||||
}) => {
|
||||
const buttons: JSX.Element[] = [];
|
||||
const buttonSize = asPip ? "md" : "lg";
|
||||
const showSettingsButton =
|
||||
openSettings !== undefined && !asPip && !hideControls;
|
||||
const showLayoutSwitcher = !asPip && !hideControls;
|
||||
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
|
||||
const showLogo = !hideLogo && !asPip;
|
||||
if (showSettingsButton) {
|
||||
// add the settings button to the center group of buttons, so it will be visible on small screens.
|
||||
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
|
||||
buttons.push(
|
||||
<SettingsButton
|
||||
key="settings"
|
||||
showForScreenWidth="narrow"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-bottom-center"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onMuteClick={toggleAudio}
|
||||
data-testid="incall_mute"
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioDevice}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Camera Source"}
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onMuteClick={toggleVideo}
|
||||
data-testid="incall_videomute"
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoDevice}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (toggleScreenSharing !== undefined) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
size={buttonSize}
|
||||
key="share_screen"
|
||||
className={styles.shareScreen}
|
||||
enabled={sharingScreen ?? false}
|
||||
onClick={toggleScreenSharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (reactionIdentifier && reactionData) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
reactionData={
|
||||
reactionData ?? {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
}
|
||||
}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
identifier={reactionIdentifier}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// In this PR we just move the button to the bottom bar. We do not yet update its appearance
|
||||
const audioOutputButton = useMemo(() => {
|
||||
if (audioOutputSwitcher === undefined) return null;
|
||||
return (
|
||||
<LoudspeakerButton
|
||||
size={buttonSize}
|
||||
onClick={() => audioOutputSwitcher.switch()}
|
||||
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
|
||||
/>
|
||||
);
|
||||
}, [audioOutputSwitcher, buttonSize]);
|
||||
|
||||
if (audioOutputButton) buttons.push(audioOutputButton);
|
||||
|
||||
if (hangup)
|
||||
buttons.push(
|
||||
<EndCallButton
|
||||
size={buttonSize}
|
||||
key="end_call"
|
||||
onClick={hangup}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoDebugContainer = (
|
||||
<div className={styles.logo}>
|
||||
{showLogo && (
|
||||
<>
|
||||
<LogoMark width={24} height={24} aria-hidden />
|
||||
<LogoType
|
||||
width={80}
|
||||
height={11}
|
||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: hidden,
|
||||
})}
|
||||
>
|
||||
<div className={styles.settingsLogoContainer}>
|
||||
{showSettingsButton && (
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
kind="secondary"
|
||||
showForScreenWidth="wide"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-bottom-left"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{showLogoDebugContainer && logoDebugContainer}
|
||||
</div>
|
||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
||||
<Switch<"spotlight", "grid">
|
||||
name="layoutMode"
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={styles.layout}
|
||||
value={layoutMode}
|
||||
onChange={setLayoutMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: 32px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
.containerOpen {
|
||||
background-color: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
.chevronIconOpen > svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
.menuButton {
|
||||
width: 40px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.itemIcon {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.rotate {
|
||||
animation: spinner 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { fn, userEvent, within, expect } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "SomeMenu",
|
||||
iconsAndLabels: {
|
||||
IconEnabled: AdvancedSettingsIcon,
|
||||
IconDisabled: AdvancedSettingsIcon,
|
||||
enabledLabel: "Enabled",
|
||||
disabledLabel: "Disabled",
|
||||
optionsButtonLabel: "Options",
|
||||
},
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "option 1", id: "1" },
|
||||
{ label: "option 2", id: "2" },
|
||||
],
|
||||
selectedOption: "1",
|
||||
onMuteClick: fn(),
|
||||
onSelect: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioMute: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: false,
|
||||
options: [
|
||||
{ label: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
toggles: [
|
||||
{
|
||||
label: "example toggle",
|
||||
id: "t0",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
selectedOption: "2",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
|
||||
// (both are TODO placeholders in the component). The mute button is first in the DOM.
|
||||
const muteButton = canvas.getByLabelText("Unmute microphone");
|
||||
await userEvent.click(muteButton);
|
||||
await expect(args.onMuteClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioUnmute: Story = {
|
||||
args: {
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
toggles: [],
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoMute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: false,
|
||||
options: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
toggles: [],
|
||||
selectedOption: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
toggles: [
|
||||
{
|
||||
label: "Blur Background",
|
||||
id: "background_blurring",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type JSX, useState } from "react";
|
||||
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
|
||||
describe("MediaMuteAndSwitchButton", () => {
|
||||
test("renders", () => {
|
||||
const { container } = render(
|
||||
<MediaMuteAndSwitchButton title={"Switcher"} />,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("renders correct audio and video labels", () => {
|
||||
const renderLabels = (
|
||||
type: "video" | "audio",
|
||||
enabled: boolean,
|
||||
): RenderResult => {
|
||||
return render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
iconsAndLabels={type}
|
||||
enabled={enabled}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
const renderAudioEndabled = renderLabels("audio", true);
|
||||
const renderAudioDisabled = renderLabels("audio", false);
|
||||
const renderVideoEnabled = renderLabels("video", true);
|
||||
const renderVideoDisabled = renderLabels("video", false);
|
||||
|
||||
expect(
|
||||
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoEnabled.getByRole("button", { name: "Start video" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls mute on mute press", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Mute microphone" }));
|
||||
|
||||
expect(onMute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls select callback on menu click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mic2");
|
||||
});
|
||||
test("does not call select callback on already selected menu click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders menu spinner until selection updates for the component", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const onSelectPressed = vi.fn();
|
||||
const onOptionUpdated = vi.fn();
|
||||
function Wrapper(): JSX.Element {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
onSelectPressed();
|
||||
void promise.then(() => {
|
||||
setSelectedOption(id);
|
||||
onOptionUpdated();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = render(<Wrapper />);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
|
||||
expect(onSelectPressed).toHaveBeenCalled();
|
||||
expect(onOptionUpdated).not.toHaveBeenCalled();
|
||||
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
||||
// so a spinner should appear on the mic2 item
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
|
||||
|
||||
// The currently-selected mic1 item should not have a spinner
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic1Item.querySelector(".rotate")).toBeNull();
|
||||
await act(async () => {
|
||||
// resolve the promise that acutally updates the select option.
|
||||
resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(onOptionUpdated).toHaveBeenCalled();
|
||||
// Spinner should now be gone since the selection has caught up
|
||||
const mic2ItemAfter = screen.getByRole("menuitem", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Background blur",
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("bg_blur");
|
||||
});
|
||||
|
||||
test("renders check icon to mark the selected menu item", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic2"
|
||||
/>,
|
||||
);
|
||||
|
||||
// open menu
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||
});
|
||||
});
|
||||
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentType, useState, type FC } from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOffSolidIcon,
|
||||
MicOnIcon,
|
||||
MicOnSolidIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
VideoCallOffSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: string;
|
||||
id: string;
|
||||
}
|
||||
export interface ToggleOption {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IconsAndLabels {
|
||||
/** The Icon used if the mute button is enabled */
|
||||
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
/** The Icon used if the mute button is disabled */
|
||||
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
/** The icon used for the different options */
|
||||
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
enabledLabel: string;
|
||||
disabledLabel: string;
|
||||
optionsButtonLabel: string;
|
||||
}
|
||||
|
||||
export interface MediaMuteAndSwitchButtonProps {
|
||||
/** The title used in the Switcher modal. */
|
||||
title: string;
|
||||
/** If the Mute button is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback if the mute button is clicked */
|
||||
onMuteClick?: () => void;
|
||||
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
|
||||
/** The options available for the media device selector modal */
|
||||
options?: MenuOptions[];
|
||||
/** The option that will currently be rendered as the selected option */
|
||||
selectedOption?: string;
|
||||
/**
|
||||
* The available toggles (including there current state)
|
||||
* The toggle state is not stored by this component.
|
||||
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
|
||||
*/
|
||||
toggles?: ToggleOption[];
|
||||
/**
|
||||
* For any toggle and option this method will be called.
|
||||
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||
*/
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
title,
|
||||
enabled,
|
||||
onMuteClick,
|
||||
iconsAndLabels: iconsAndLabelsWithDefaultCases,
|
||||
options,
|
||||
selectedOption,
|
||||
toggles,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
let iconsAndLabels: IconsAndLabels | undefined;
|
||||
switch (iconsAndLabelsWithDefaultCases) {
|
||||
case "video":
|
||||
iconsAndLabels = {
|
||||
IconEnabled: VideoCallSolidIcon,
|
||||
IconDisabled: VideoCallOffSolidIcon,
|
||||
IconOptions: VideoCallIcon,
|
||||
disabledLabel: t("stop_video_button_label"),
|
||||
enabledLabel: t("start_video_button_label"),
|
||||
optionsButtonLabel: t("settings.devices.microphone"),
|
||||
};
|
||||
break;
|
||||
case "audio":
|
||||
iconsAndLabels = {
|
||||
IconEnabled: MicOnSolidIcon,
|
||||
IconDisabled: MicOffSolidIcon,
|
||||
IconOptions: MicOnIcon,
|
||||
disabledLabel: t("mute_microphone_button_label"),
|
||||
enabledLabel: t("unmute_microphone_button_label"),
|
||||
optionsButtonLabel: t("settings.devices.microphone"),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
iconsAndLabels = iconsAndLabelsWithDefaultCases;
|
||||
break;
|
||||
}
|
||||
const {
|
||||
IconEnabled,
|
||||
IconDisabled,
|
||||
IconOptions,
|
||||
disabledLabel,
|
||||
enabledLabel,
|
||||
optionsButtonLabel,
|
||||
} = iconsAndLabels ?? {
|
||||
IconEnabled: undefined,
|
||||
IconDisabled: undefined,
|
||||
IconOptions: undefined,
|
||||
disabledLabel: undefined,
|
||||
enabledLabel: undefined,
|
||||
optionsButtonLabel: undefined,
|
||||
};
|
||||
{
|
||||
logger.info(
|
||||
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
|
||||
selectedOption,
|
||||
" !==",
|
||||
"option.id",
|
||||
" && ",
|
||||
plannedSelection,
|
||||
" === ",
|
||||
"option.id",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
[styles.container]: true,
|
||||
[styles.containerOpen]: menuOpen,
|
||||
})}
|
||||
>
|
||||
{/* The mute button lives inside */}
|
||||
<Button
|
||||
iconOnly
|
||||
Icon={enabled ? IconEnabled : IconDisabled}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
aria-label={enabled ? disabledLabel : enabledLabel}
|
||||
/>
|
||||
<Menu
|
||||
title={title}
|
||||
showTitle={true}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
side="top"
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
className={classNames({
|
||||
[styles.menuButton]: true,
|
||||
[styles.chevronIconOpen]: menuOpen,
|
||||
})}
|
||||
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
|
||||
kind={"tertiary"}
|
||||
size="lg"
|
||||
aria-label={optionsButtonLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{options?.map((option) => (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
label={option.label}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (option.id === selectedOption) return;
|
||||
setPlannedSelection(option.id);
|
||||
onSelect?.(option.id);
|
||||
}}
|
||||
key={option.id}
|
||||
>
|
||||
{selectedOption === option.id && (
|
||||
<CheckIcon width={24} height={24} />
|
||||
)}
|
||||
{selectedOption !== option.id && plannedSelection === option.id && (
|
||||
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
onSelect?.(toggle.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<button
|
||||
class="_button_1nw83_8 button _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<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 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -97,6 +97,13 @@ export interface ConfigOptions {
|
||||
enable_video?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
|
||||
* This allows brief sync interruptions without triggering a reconnection message.
|
||||
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
|
||||
*/
|
||||
sync_disconnect_grace_period_ms?: number;
|
||||
|
||||
/**
|
||||
* These are low level options that are used to configure the MatrixRTC session.
|
||||
* Take care when changing these options.
|
||||
@@ -155,7 +162,16 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
sync_disconnect_grace_period_ms: number;
|
||||
ssla: string;
|
||||
matrix_rtc_session: {
|
||||
wait_for_key_rotation_ms?: number;
|
||||
delayed_leave_event_delay_ms: number;
|
||||
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
network_error_retry_ms: number;
|
||||
membership_event_expiry_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
@@ -168,5 +184,10 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
features: {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
matrix_rtc_session: {
|
||||
delayed_leave_event_delay_ms: 10000,
|
||||
network_error_retry_ms: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
contain: layout style;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
margin-inline: var(--inline-content-inset);
|
||||
margin-block: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.slots {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot {
|
||||
contain: strict;
|
||||
}
|
||||
@@ -266,6 +266,20 @@ export function Grid<
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
const orientation = useSyncExternalStore(
|
||||
useCallback((onChange) => {
|
||||
// Support for the change event is experimental
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
|
||||
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
|
||||
return (): void =>
|
||||
(screen as unknown as EventTarget).removeEventListener?.(
|
||||
"change",
|
||||
onChange,
|
||||
);
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
@@ -336,10 +350,10 @@ export function Grid<
|
||||
}
|
||||
|
||||
return result;
|
||||
// The rects may change due to the grid resizing or updating to a new
|
||||
// generation, but eslint can't statically verify this
|
||||
// The rects may change due to the grid resizing, changing orientation, or
|
||||
// updating to a new generation, but eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
|
||||
|
||||
// The height of the portion of the grid visible at any given time
|
||||
const visibleHeight = useMemo(
|
||||
|
||||
@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset-block: 0;
|
||||
inset-inline: var(--cpd-space-3x);
|
||||
/* Ensure that spotlight tile lies within the safe area */
|
||||
inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
|
||||
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -19,7 +19,7 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
inset: var(--cpd-space-4x);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
|
||||
@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 135px;
|
||||
block-size: 160px;
|
||||
inset: var(--cpd-space-4x);
|
||||
/* Ensure that PiP lies within the safe area */
|
||||
inset: calc(env(safe-area-inset-top) + var(--cpd-space-4x))
|
||||
var(--content-inset-right)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x))
|
||||
var(--content-inset-left);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
|
||||
@@ -37,10 +37,20 @@ layer(compound);
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows. */
|
||||
--inline-content-inset: min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { mockConfig } from "./utils/test";
|
||||
|
||||
const sentryInitSpy = vi.fn();
|
||||
const sentryInitSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Place the mock after the spy is defined
|
||||
vi.mock("@sentry/react", () => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user