mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-24 00:38:31 +08:00
Merge branch 'livekit' into hughns/to-device-key-distribution
This commit is contained in:
commit
01c2efc7ed
6
.github/workflows/docker.yaml
vendored
6
.github/workflows/docker.yaml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
@ -48,10 +48,10 @@ jobs:
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out test private repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
with:
|
||||
repository: element-hq/static-call-participant
|
||||
ref: refs/heads/main
|
||||
|
6
.github/workflows/element-call.yaml
vendored
6
.github/workflows/element-call.yaml
vendored
@ -21,9 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist
|
||||
|
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@ -7,9 +7,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
2
.github/workflows/publish.yaml
vendored
2
.github/workflows/publish.yaml
vendored
@ -51,7 +51,7 @@ jobs:
|
||||
run: |
|
||||
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
|
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@ -9,9 +9,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@ -20,7 +20,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
|
6
.github/workflows/translations-download.yaml
vendored
6
.github/workflows/translations-download.yaml
vendored
@ -13,9 +13,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
|
||||
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7.0.5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
|
@ -44,7 +44,7 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
By default, the app expects you to have a Matrix homeserver (such as [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html)) installed locally and running on port 8008. If you wish to use a homeserver on a different URL or one that is hosted on a different server, you can add a config file as above, and include the homeserver URL that you'd like to use.
|
||||
By default, the app expects you to have a Matrix homeserver (such as [Synapse](https://element-hq.github.io/synapse/latest/setup/installation.html)) installed locally and running on port 8008. If you wish to use a homeserver on a different URL or one that is hosted on a different server, you can add a config file as above, and include the homeserver URL that you'd like to use.
|
||||
|
||||
Element Call requires a homeserver with registration enabled without any 3pid or token requirements, if you want it to be used by unregistered users. Furthermore, it is not recommended to use it with an existing homeserver where user accounts have joined normal rooms, as it may not be able to handle those yet and it may behave unreliably.
|
||||
|
||||
|
@ -7,12 +7,16 @@ services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
|
||||
hostname: auth-server
|
||||
ports:
|
||||
- 8881:8080
|
||||
# Use host network in case the configured homeserver runs on localhost
|
||||
network_mode: host
|
||||
environment:
|
||||
- LK_JWT_PORT=8881
|
||||
- LIVEKIT_URL=ws://localhost:7880
|
||||
- LIVEKIT_KEY=devkey
|
||||
- LIVEKIT_SECRET=secret
|
||||
# If the configured homeserver runs on localhost, it'll probably be using
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@ -23,11 +27,15 @@ services:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "7882:7882"
|
||||
- "50100-50200:50100-50200"
|
||||
# The SFU seems to work far more reliably when we let it share the host
|
||||
# network rather than opening specific ports (but why?? we're not missing
|
||||
# any…)
|
||||
network_mode: host
|
||||
# ports:
|
||||
# - "7880:7880/tcp"
|
||||
# - "7881:7881/tcp"
|
||||
# - "7882:7882/tcp"
|
||||
# - "50100-50200:50100-50200/udp"
|
||||
volumes:
|
||||
- ./backend/livekit.yaml:/etc/livekit.yaml
|
||||
networks:
|
||||
|
@ -1,6 +1,10 @@
|
||||
# Don't post comments on PRs; they're noisy and the same information can be
|
||||
# gotten through the checks section at the bottom of the PR anyways
|
||||
comment: false
|
||||
github_checks:
|
||||
# Don't mark up the diffs on PRs with warnings about untested lines; we're not
|
||||
# aiming for 100% test coverage and they just get in the way of reviewing
|
||||
annotations: false
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
15
package.json
15
package.json
@ -41,6 +41,7 @@
|
||||
"@sentry/vite-plugin": "^2.0.0",
|
||||
"@testing-library/dom": "^10.1.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
@ -70,9 +71,9 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-matrix-org": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"global-jsdom": "^24.0.0",
|
||||
"global-jsdom": "^25.0.0",
|
||||
"history": "^4.0.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
@ -80,7 +81,7 @@
|
||||
"i18next-parser": "^9.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.0.2",
|
||||
"livekit-client": "^2.5.7",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#b0174eccdb0e33f5df5d7b590938daf8ff5c7f7a",
|
||||
@ -90,7 +91,7 @@
|
||||
"pako": "^2.0.4",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"posthog-js": "^1.29.0",
|
||||
"posthog-js": "1.160.3",
|
||||
"prettier": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18",
|
||||
@ -108,6 +109,10 @@
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^2.0.0",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"strip-ansi": "6.0.1"
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Abmelden",
|
||||
"submit": "Absenden"
|
||||
},
|
||||
"analytics_notice": "Mit der Teilnahme an der Beta akzeptierst du die Sammlung von anonymen Daten, die wir zur Verbesserung des Produkts verwenden. Weitere Informationen zu den von uns erhobenen Daten findest du in unserer <2>Datenschutzerklärung</2> und unseren <5>Cookie-Richtlinien</5>.",
|
||||
"analytics_notice": "Mit der Teilnahme an der Beta akzeptierst du die Sammlung von anonymen Daten, die wir zur Verbesserung des Produkts verwenden. Weitere Informationen zu den von uns erhobenen Daten findest du in unserer <2>Datenschutzerklärung</2> und unseren <6>Cookie-Richtlinien</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Weiter im Browser",
|
||||
"open_in_app": "In der App öffnen",
|
||||
|
@ -13,7 +13,7 @@
|
||||
"sign_out": "Αποσύνδεση",
|
||||
"submit": "Υποβολή"
|
||||
},
|
||||
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <5>Πολιτική cookies</5>.",
|
||||
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <6>Πολιτική cookies</6>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Δημιουργία λογαριασμού",
|
||||
"create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;</0><1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.</1>",
|
||||
|
@ -16,7 +16,7 @@
|
||||
"submit": "Submit",
|
||||
"upload_file": "Upload file"
|
||||
},
|
||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.",
|
||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Continue in browser",
|
||||
"open_in_app": "Open in the app",
|
||||
@ -91,6 +91,7 @@
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"join_as_guest": "Join as guest",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
@ -128,8 +129,8 @@
|
||||
"register_confirm_password_label": "Confirm password",
|
||||
"register_heading": "Create your account",
|
||||
"return_home_button": "Return to home screen",
|
||||
"room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
||||
"room_auth_view_join_button": "Join call now",
|
||||
"room_auth_view_continue_button": "Continue",
|
||||
"room_auth_view_eula_caption": "By clicking \"Continue\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
||||
"screenshare_button_label": "Share screen",
|
||||
"settings": {
|
||||
"developer_settings_label": "Developer Settings",
|
||||
@ -161,8 +162,8 @@
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"exit_full_screen": "Exit full screen",
|
||||
"full_screen": "Full screen",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"volume": "Volume"
|
||||
}
|
||||
|
@ -12,7 +12,7 @@
|
||||
"sign_out": "Cerrar sesión",
|
||||
"submit": "Enviar"
|
||||
},
|
||||
"analytics_notice": "Al participar en esta beta, consientes a la recogida de datos anónimos, los cuales usaremos para mejorar el producto. Puedes encontrar más información sobre que datos recogemos en nuestra <2>Política de privacidad</2> y en nuestra <5>Política sobre Cookies</5>.",
|
||||
"analytics_notice": "Al participar en esta beta, consientes a la recogida de datos anónimos, los cuales usaremos para mejorar el producto. Puedes encontrar más información sobre que datos recogemos en nuestra <2>Política de privacidad</2> y en nuestra <6>Política sobre Cookies</6>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Crear cuenta",
|
||||
"create_account_prompt": "<0>¿Por qué no mantienes tu cuenta estableciendo una contraseña?</0><1>Podrás mantener tu nombre y establecer un avatar para usarlo en futuras llamadas</1>",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Logi välja",
|
||||
"submit": "Saada"
|
||||
},
|
||||
"analytics_notice": "Nõustudes selle beetaversiooni kasutamisega sa nõustud ka toote arendamiseks kasutatavate anonüümsete andmete kogumisega. Täpsemat teavet kogutavate andmete kohta leiad meie <2>Privaatsuspoliitikast</2> ja meie <5>Küpsiste kasutamise reeglitest</5>.",
|
||||
"analytics_notice": "Nõustudes selle beetaversiooni kasutamisega sa nõustud ka toote arendamiseks kasutatavate anonüümsete andmete kogumisega. Täpsemat teavet kogutavate andmete kohta leiad meie <2>Privaatsuspoliitikast</2> ja meie <6>Küpsiste kasutamise reeglitest</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Jätka veebibrauseris",
|
||||
"open_in_app": "Ava rakenduses",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Déconnexion",
|
||||
"submit": "Envoyer"
|
||||
},
|
||||
"analytics_notice": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus d’informations sur les données collectées dans notre <2>Politique de vie privée</2> et notre <5>Politique de cookies</5>.",
|
||||
"analytics_notice": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus d’informations sur les données collectées dans notre <2>Politique de vie privée</2> et notre <6>Politique de cookies</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Continuer dans le navigateur",
|
||||
"open_in_app": "Ouvrir dans l’application",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Keluar",
|
||||
"submit": "Kirim"
|
||||
},
|
||||
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
|
||||
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <6>Kebijakan Kuki</6> kami.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Lanjutkan dalam peramban",
|
||||
"open_in_app": "Buka dalam aplikasi",
|
||||
|
@ -14,7 +14,7 @@
|
||||
"sign_out": "Disconnetti",
|
||||
"submit": "Invia"
|
||||
},
|
||||
"analytics_notice": "Partecipando a questa beta, acconsenti alla raccolta di dati anonimi che usiamo per migliorare il prodotto. Puoi trovare più informazioni su quali dati monitoriamo nella nostra <2>informativa sulla privacy</2> e nell'<5>informativa sui cookie</5>.",
|
||||
"analytics_notice": "Partecipando a questa beta, acconsenti alla raccolta di dati anonimi che usiamo per migliorare il prodotto. Puoi trovare più informazioni su quali dati monitoriamo nella nostra <2>informativa sulla privacy</2> e nell'<6>informativa sui cookie</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Continua nel browser",
|
||||
"open_in_app": "Apri nell'app",
|
||||
|
@ -13,7 +13,7 @@
|
||||
"sign_out": "Atteikties",
|
||||
"submit": "Iesniegt"
|
||||
},
|
||||
"analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos</2> un <5>sīkdatņu nosacījumos</5>.",
|
||||
"analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos</2> un <6>sīkdatņu nosacījumos</6>.",
|
||||
"call_ended_view": {
|
||||
"body": "Tu tiki atvienots no zvana",
|
||||
"create_account_button": "Izveidot kontu",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Wyloguj się",
|
||||
"submit": "Wyślij"
|
||||
},
|
||||
"analytics_notice": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
|
||||
"analytics_notice": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <6>Polityce ciasteczek</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Kontynuuj w przeglądarce",
|
||||
"open_in_app": "Otwórz w aplikacji",
|
||||
|
@ -13,7 +13,7 @@
|
||||
"sign_out": "Выйти",
|
||||
"submit": "Отправить"
|
||||
},
|
||||
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <5> Политике использования файлов cookie</5>.",
|
||||
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <6> Политике использования файлов cookie</6>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Создать аккаунт",
|
||||
"create_account_prompt": "<0>Почему бы не задать пароль, тем самым сохранив аккаунт?</0><1>Так вы можете оставить своё имя и задать аватар для будущих звонков.</1>",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Odhlásiť sa",
|
||||
"submit": "Odoslať"
|
||||
},
|
||||
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
|
||||
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <6>Zásadách používania súborov cookie</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Pokračovať v prehliadači",
|
||||
"open_in_app": "Otvoriť v aplikácii",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "Вийти",
|
||||
"submit": "Надіслати"
|
||||
},
|
||||
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
|
||||
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <6>Політиці про куки</6>.",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "Продовжити у браузері",
|
||||
"open_in_app": "Відкрити у застосунку",
|
||||
|
@ -13,7 +13,7 @@
|
||||
"sign_out": "登出",
|
||||
"submit": "提交"
|
||||
},
|
||||
"analytics_notice": "参与测试即表示您同意我们收集匿名数据,用于改进产品。您可以在我们的<2>隐私政策</2>和<5>Cookie政策</5>中找到有关我们跟踪哪些数据以及更多信息。",
|
||||
"analytics_notice": "参与测试即表示您同意我们收集匿名数据,用于改进产品。您可以在我们的<2>隐私政策</2>和<6>Cookie政策</6>中找到有关我们跟踪哪些数据以及更多信息。",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "在浏览器中继续",
|
||||
"open_in_app": "在应用中打开",
|
||||
|
@ -15,7 +15,7 @@
|
||||
"sign_out": "登出",
|
||||
"submit": "遞交"
|
||||
},
|
||||
"analytics_notice": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策</2>與我們的 <5>Cookie 政策</5> 中找到關於我們追蹤哪些資料的更多資訊。",
|
||||
"analytics_notice": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策</2>與我們的 <6>Cookie 政策</6> 中找到關於我們追蹤哪些資料的更多資訊。",
|
||||
"app_selection_modal": {
|
||||
"continue_in_browser": "在瀏覽器中繼續",
|
||||
"open_in_app": "在應用程式中開啟",
|
||||
|
@ -44,5 +44,6 @@
|
||||
"prHeader": "Please review modals on mobile for visual regressions."
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled"
|
||||
"semanticCommits": "disabled",
|
||||
"ignoreDeps": ["posthog-js"]
|
||||
}
|
||||
|
30
src/Header.test.tsx
Normal file
30
src/Header.test.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { RoomHeaderInfo } from "./Header";
|
||||
|
||||
test("RoomHeaderInfo is accessible", async () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<RoomHeaderInfo
|
||||
id="!a:example.org"
|
||||
name="Mission Control"
|
||||
avatarUrl=""
|
||||
encrypted
|
||||
participantCount={11}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Check that the room name acts as a heading
|
||||
screen.getByRole("heading", { name: "Mission Control" });
|
||||
});
|
@ -89,6 +89,9 @@ export const Modal: FC<Props> = ({
|
||||
styles.drawer,
|
||||
{ [styles.tabbed]: tabbed },
|
||||
)}
|
||||
// Suppress the warning about there being no description; the modal
|
||||
// has an accessible title
|
||||
aria-describedby={undefined}
|
||||
{...rest}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
@ -111,7 +114,9 @@ export const Modal: FC<Props> = ({
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
<DialogContent asChild {...rest}>
|
||||
{/* Suppress the warning about there being no description; the modal
|
||||
has an accessible title */}
|
||||
<DialogContent asChild aria-describedby={undefined} {...rest}>
|
||||
<Glass
|
||||
className={classNames(
|
||||
className,
|
||||
|
@ -16,6 +16,13 @@ interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
onValueChange: (value: number) => void;
|
||||
/**
|
||||
* Event handler called when the value changes at the end of an interaction.
|
||||
* Useful when you only need to capture a final value to update a backend
|
||||
* service, or when you want to remember the last value that the user
|
||||
* "committed" to.
|
||||
*/
|
||||
onValueCommit?: (value: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
@ -30,6 +37,7 @@ export const Slider: FC<Props> = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange: onValueChangeProp,
|
||||
onValueCommit: onValueCommitProp,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
@ -39,12 +47,17 @@ export const Slider: FC<Props> = ({
|
||||
([v]: number[]) => onValueChangeProp(v),
|
||||
[onValueChangeProp],
|
||||
);
|
||||
const onValueCommit = useCallback(
|
||||
([v]: number[]) => onValueCommitProp?.(v),
|
||||
[onValueCommitProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<Root
|
||||
className={classNames(className, styles.slider)}
|
||||
value={[value]}
|
||||
onValueChange={onValueChange}
|
||||
onValueCommit={onValueCommit}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
@ -6,19 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, configure } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { Toast } from "../src/Toast";
|
||||
import { withFakeTimers } from "./utils/test";
|
||||
|
||||
configure({
|
||||
defaultHidden: true,
|
||||
});
|
||||
|
||||
// Test Explanation:
|
||||
// This test the toast. We need to use { document: window.document } because the toast listens
|
||||
// for user input on `window`.
|
||||
describe("Toast", () => {
|
||||
test("renders", () => {
|
||||
const { queryByRole } = render(
|
||||
@ -36,7 +29,7 @@ describe("Toast", () => {
|
||||
});
|
||||
|
||||
test("dismisses when Esc is pressed", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast open={true} onDismiss={onDismiss}>
|
||||
@ -50,7 +43,7 @@ describe("Toast", () => {
|
||||
test("dismisses when background is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
const { getByRole, unmount } = render(
|
||||
const { getByRole } = render(
|
||||
<Toast open={true} onDismiss={onDismiss}>
|
||||
Hello world!
|
||||
</Toast>,
|
||||
@ -58,7 +51,6 @@ describe("Toast", () => {
|
||||
const background = getByRole("dialog").previousSibling! as Element;
|
||||
await user.click(background);
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("dismisses itself after the specified timeout", () => {
|
||||
|
@ -8,14 +8,20 @@ Please see LICENSE in the repository root for full details.
|
||||
import { FC } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { Link } from "../typography/Typography";
|
||||
import { ExternalLink } from "../button/Link";
|
||||
|
||||
export const AnalyticsNotice: FC = () => (
|
||||
<Trans i18nKey="analytics_notice">
|
||||
By participating in this beta, you consent to the collection of anonymous
|
||||
data, which we use to improve the product. You can find more information
|
||||
about which data we track in our{" "}
|
||||
<Link href="https://element.io/privacy">Privacy Policy</Link> and our{" "}
|
||||
<Link href="https://element.io/cookie-policy">Cookie Policy</Link>.
|
||||
<ExternalLink href="https://element.io/privacy">
|
||||
Privacy Policy
|
||||
</ExternalLink>{" "}
|
||||
and our{" "}
|
||||
<ExternalLink href="https://element.io/cookie-policy">
|
||||
Cookie Policy
|
||||
</ExternalLink>
|
||||
.
|
||||
</Trans>
|
||||
);
|
||||
|
@ -66,6 +66,7 @@ export class CallEndedTracker {
|
||||
e2eeType: E2eeType,
|
||||
rtcSession: MatrixRTCSession,
|
||||
sendInstantly: boolean,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
{
|
||||
|
@ -64,15 +64,6 @@ Please see LICENSE in the repository root for full details.
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.authLinks {
|
||||
margin-bottom: 100px;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.authLinks a {
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, FormEvent, useCallback, useRef, useState } from "react";
|
||||
import { useHistory, useLocation, Link } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
@ -18,6 +18,7 @@ import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -19,7 +19,7 @@ import { captureException } from "@sentry/react";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
@ -28,10 +28,10 @@ import styles from "./LoginPage.module.css";
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { LoadingView } from "../FullScreenView";
|
||||
import { useRecaptcha } from "./useRecaptcha";
|
||||
import { Caption, Link } from "../typography/Typography";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { ExternalLink, Link } from "../button/Link";
|
||||
|
||||
export const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -201,24 +201,24 @@ export const RegisterPage: FC = () => {
|
||||
data-testid="register_confirm_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
<Text size="sm">
|
||||
<Trans i18nKey="recaptcha_caption">
|
||||
This site is protected by ReCAPTCHA and the Google{" "}
|
||||
<Link href="https://www.google.com/policies/privacy/">
|
||||
<ExternalLink href="https://www.google.com/policies/privacy/">
|
||||
Privacy Policy
|
||||
</Link>{" "}
|
||||
</ExternalLink>{" "}
|
||||
and{" "}
|
||||
<Link href="https://policies.google.com/terms">
|
||||
<ExternalLink href="https://policies.google.com/terms">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
</ExternalLink>{" "}
|
||||
apply.
|
||||
<br />
|
||||
By clicking "Register", you agree to our{" "}
|
||||
<Link href={Config.get().eula}>
|
||||
<ExternalLink href={Config.get().eula}>
|
||||
End User Licensing Agreement (EULA)
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
</Trans>
|
||||
</Caption>
|
||||
</Text>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage error={error} />
|
||||
|
13
src/button/Link.module.css
Normal file
13
src/button/Link.module.css
Normal file
@ -0,0 +1,13 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.external {
|
||||
/* By default links will be blue/purple (or whatever the user agent does), but
|
||||
in our designs we generally want external links to be the same color as the
|
||||
surrounding text */
|
||||
color: inherit;
|
||||
}
|
@ -15,10 +15,16 @@ import {
|
||||
import { Link as CpdLink } from "@vector-im/compound-web";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { createPath, LocationDescriptor, Path } from "history";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLatest } from "../useLatest";
|
||||
import styles from "./Link.module.css";
|
||||
|
||||
export function useLink(
|
||||
to: LocationDescriptor,
|
||||
state?: unknown,
|
||||
): [Path, (e: MouseEvent) => void] {
|
||||
const latestState = useLatest(state);
|
||||
const history = useHistory();
|
||||
const path = useMemo(
|
||||
() => (typeof to === "string" ? to : createPath(to)),
|
||||
@ -27,9 +33,9 @@ export function useLink(
|
||||
const onClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
history.push(to);
|
||||
history.push(to, latestState.current);
|
||||
},
|
||||
[history, to],
|
||||
[history, to, latestState],
|
||||
);
|
||||
|
||||
return [path, onClick];
|
||||
@ -38,15 +44,37 @@ export function useLink(
|
||||
type Props = Omit<
|
||||
ComponentPropsWithoutRef<typeof CpdLink>,
|
||||
"href" | "onClick"
|
||||
> & { to: LocationDescriptor };
|
||||
> & { to: LocationDescriptor; state?: unknown };
|
||||
|
||||
/**
|
||||
* A version of Compound's link component that integrates with our router setup.
|
||||
* This is only for app-internal links.
|
||||
*/
|
||||
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link(
|
||||
{ to, ...props },
|
||||
{ to, state, ...props },
|
||||
ref,
|
||||
) {
|
||||
const [path, onClick] = useLink(to);
|
||||
const [path, onClick] = useLink(to, state);
|
||||
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
|
||||
});
|
||||
|
||||
/**
|
||||
* A link to an external web page, made to fit into blocks of text more subtly
|
||||
* than the normal Compound link component.
|
||||
*/
|
||||
export const ExternalLink = forwardRef<
|
||||
HTMLAnchorElement,
|
||||
ComponentPropsWithoutRef<"a">
|
||||
>(function ExternalLink({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
className={classNames(className, styles.external)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
@ -15,7 +17,7 @@ import {
|
||||
export class Config {
|
||||
private static internalInstance: Config | undefined;
|
||||
|
||||
public static get(): ConfigOptions {
|
||||
public static get(): ResolvedConfigOptions {
|
||||
if (!this.internalInstance?.config)
|
||||
throw new Error("Config instance read before config got initialized");
|
||||
return this.internalInstance.config;
|
||||
@ -29,7 +31,7 @@ export class Config {
|
||||
Config.internalInstance.initPromise = downloadConfig(
|
||||
"../config.json",
|
||||
).then((config) => {
|
||||
internalInstance.config = { ...DEFAULT_CONFIG, ...config };
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
});
|
||||
}
|
||||
return Config.internalInstance.initPromise;
|
||||
|
@ -77,6 +77,17 @@ export interface ConfigOptions {
|
||||
* A link to the end-user license agreement (EULA)
|
||||
*/
|
||||
eula: string;
|
||||
|
||||
media_devices?: {
|
||||
/**
|
||||
* Defines whether participants should start with audio enabled by default.
|
||||
*/
|
||||
enable_audio?: boolean;
|
||||
/**
|
||||
* Defines whether participants should start with video enabled by default.
|
||||
*/
|
||||
enable_video?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
@ -88,6 +99,10 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
media_devices: {
|
||||
enable_audio: boolean;
|
||||
enable_video: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
@ -98,4 +113,8 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
},
|
||||
},
|
||||
eula: "https://static.element.io/legal/online-EULA.pdf",
|
||||
media_devices: {
|
||||
enable_audio: true,
|
||||
enable_video: true,
|
||||
},
|
||||
};
|
||||
|
72
src/e2ee/matrixKeyProvider.test.ts
Normal file
72
src/e2ee/matrixKeyProvider.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/src/matrixrtc";
|
||||
import { KeyProviderEvent } from "livekit-client";
|
||||
|
||||
import { MatrixKeyProvider } from "./matrixKeyProvider";
|
||||
|
||||
function mockRTCSession(): MatrixRTCSession {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
reemitEncryptionKeys: vi.fn(),
|
||||
} as unknown as MatrixRTCSession;
|
||||
}
|
||||
|
||||
describe("matrixKeyProvider", () => {
|
||||
test("initializes", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
expect(keyProvider).toBeTruthy();
|
||||
});
|
||||
|
||||
test("listens for key requests and emits existing keys", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
|
||||
const session = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session);
|
||||
|
||||
expect(session.on).toHaveBeenCalledWith(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(session.off).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stops listening when session changes", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
|
||||
const session1 = mockRTCSession();
|
||||
const session2 = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session1);
|
||||
expect(session1.off).not.toHaveBeenCalled();
|
||||
|
||||
keyProvider.setRTCSession(session2);
|
||||
expect(session1.off).toHaveBeenCalledWith(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("emits existing keys", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
const setKeyListener = vi.fn();
|
||||
keyProvider.on(KeyProviderEvent.SetKey, setKeyListener);
|
||||
|
||||
const session = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session);
|
||||
|
||||
expect(session.reemitEncryptionKeys).toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -16,7 +16,7 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
private rtcSession?: MatrixRTCSession;
|
||||
|
||||
public constructor() {
|
||||
super({ ratchetWindowSize: 0 });
|
||||
super({ ratchetWindowSize: 0, keyringSize: 256 });
|
||||
}
|
||||
|
||||
public setRTCSession(rtcSession: MatrixRTCSession): void {
|
||||
@ -35,15 +35,8 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
);
|
||||
|
||||
// The new session could be aware of keys of which the old session wasn't,
|
||||
// so emit a key changed event.
|
||||
for (const [
|
||||
participant,
|
||||
encryptionKeys,
|
||||
] of this.rtcSession.getEncryptionKeys()) {
|
||||
for (const [index, encryptionKey] of encryptionKeys.entries()) {
|
||||
this.onEncryptionKeyChanged(encryptionKey, index, participant);
|
||||
}
|
||||
}
|
||||
// so emit key changed events
|
||||
this.rtcSession.reemitEncryptionKeys();
|
||||
}
|
||||
|
||||
private onEncryptionKeyChanged = (
|
||||
|
@ -50,6 +50,12 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.callName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.facePile {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@ -64,3 +70,8 @@ Please see LICENSE in the repository root for full details.
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
@ -9,12 +9,15 @@ import { Link } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { FC } from "react";
|
||||
import { FC, useCallback, MouseEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton, Text } from "@vector-im/compound-web";
|
||||
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import styles from "./CallList.module.css";
|
||||
import { getRelativeRoomUrl } from "../utils/matrix";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
@ -55,22 +58,53 @@ interface CallTileProps {
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room, client }) => {
|
||||
const { t } = useTranslation();
|
||||
const roomEncryptionSystem = useRoomEncryptionSystem(room.roomId);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsLeaving(true);
|
||||
client.leave(room.roomId).catch(() => setIsLeaving(false));
|
||||
},
|
||||
[room, client],
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
<div className={styles.callInfo}>
|
||||
<Text weight="semibold" className={styles.callName}>
|
||||
{name}
|
||||
</Text>
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={onRemove}
|
||||
disabled={isLeaving}
|
||||
aria-label={t("action.remove")}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
{isLeaving ? (
|
||||
<span className={classNames(styles.callTileLink, styles.disabled)}>
|
||||
{body}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={getRelativeRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||
className={styles.callTileLink}
|
||||
>
|
||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
<div className={styles.callInfo}>
|
||||
<Body overflowEllipsis fontWeight="semiBold">
|
||||
{name}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.copyButtonSpacer} />
|
||||
{body}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import { useState, useCallback, FormEvent, FormEventHandler, FC } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dropdown, Heading } from "@vector-im/compound-web";
|
||||
import { Dropdown, Heading, Text } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
@ -27,7 +27,6 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { CallList } from "./CallList";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { Caption } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@ -163,9 +162,9 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
</Button>
|
||||
</FieldRow>
|
||||
{optInAnalytics === null && (
|
||||
<Caption className={styles.notice}>
|
||||
<Text size="sm" className={styles.notice}>
|
||||
<AnalyticsNotice />
|
||||
</Caption>
|
||||
</Text>
|
||||
)}
|
||||
{error && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
|
@ -9,7 +9,7 @@ import { FC, useCallback, useState, FormEventHandler } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown, Heading } from "@vector-im/compound-web";
|
||||
import { Button, Dropdown, Heading, Text } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
@ -25,7 +25,6 @@ import {
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { Body, Caption, Link } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import styles from "./UnauthenticatedView.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
@ -34,6 +33,7 @@ import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
import { ExternalLink, Link } from "../button/Link";
|
||||
|
||||
const encryptionOptions = {
|
||||
shared: {
|
||||
@ -191,18 +191,18 @@ export const UnauthenticatedView: FC = () => {
|
||||
/>
|
||||
</FieldRow>
|
||||
{optInAnalytics === null && (
|
||||
<Caption className={styles.notice}>
|
||||
<Text size="sm" className={styles.notice}>
|
||||
<AnalyticsNotice />
|
||||
</Caption>
|
||||
</Text>
|
||||
)}
|
||||
<Caption className={styles.notice}>
|
||||
<Text size="sm" className={styles.notice}>
|
||||
<Trans i18nKey="unauthenticated_view_eula_caption">
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={Config.get().eula}>
|
||||
<ExternalLink href={Config.get().eula}>
|
||||
End User Licensing Agreement (EULA)
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
</Trans>
|
||||
</Caption>
|
||||
</Text>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage error={error} />
|
||||
@ -234,19 +234,19 @@ export const UnauthenticatedView: FC = () => {
|
||||
</Form>
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<Body className={styles.mobileLoginLink}>
|
||||
<Link color="primary" to="/login" data-testid="home_login">
|
||||
<Text className={styles.mobileLoginLink}>
|
||||
<Link to="/login" data-testid="home_login">
|
||||
{t("unauthenticated_view_login_button")}
|
||||
</Link>
|
||||
</Body>
|
||||
<Body>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans i18nKey="unauthenticated_view_body">
|
||||
Not registered yet?{" "}
|
||||
<Link color="primary" to="/register" data-testid="home_register">
|
||||
<Link to="/register" data-testid="home_register">
|
||||
Create an account
|
||||
</Link>
|
||||
</Trans>
|
||||
</Body>
|
||||
</Text>
|
||||
</footer>
|
||||
</div>
|
||||
{onFinished && (
|
||||
|
@ -237,16 +237,6 @@ body[data-platform="desktop"] {
|
||||
line-height: var(--font-size-title);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: calc(100% - 24px);
|
||||
border: none;
|
||||
|
29
src/input/StarRating.test.tsx
Normal file
29
src/input/StarRating.test.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { StarRatingInput } from "./StarRatingInput";
|
||||
|
||||
test("StarRatingInput is accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<StarRatingInput starCount={5} onChange={onChange} />,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Change the rating to 4 stars
|
||||
await user.click(
|
||||
(
|
||||
await screen.findAllByRole("radio", { name: "star_rating_input_label" })
|
||||
)[3],
|
||||
);
|
||||
expect(onChange).toBeCalledWith(4);
|
||||
});
|
@ -97,7 +97,11 @@ function useMediaDevice(
|
||||
}
|
||||
|
||||
return {
|
||||
available: available ?? [],
|
||||
available: available
|
||||
? // Sometimes browsers (particularly Firefox) can return multiple
|
||||
// device entries for the exact same device ID; deduplicate them
|
||||
[...new Map(available.map((d) => [d.deviceId, d])).values()]
|
||||
: [],
|
||||
selectedId: alwaysDefault ? undefined : devId,
|
||||
select,
|
||||
};
|
||||
|
@ -9,12 +9,11 @@ import { FC, FormEventHandler, ReactNode, useCallback, useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./CallEndedView.module.css";
|
||||
import feedbackStyle from "../input/FeedbackInput.module.css";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { Body, Headline } from "../typography/Typography";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
@ -139,11 +138,11 @@ export const CallEndedView: FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<main className={styles.main}>
|
||||
<Headline className={styles.headline}>
|
||||
<Heading size="xl" weight="semibold" className={styles.headline}>
|
||||
<Trans i18nKey="call_ended_view.body">
|
||||
You were disconnected from the call
|
||||
</Trans>
|
||||
</Headline>
|
||||
</Heading>
|
||||
<div className={styles.disconnectedButtons}>
|
||||
<Button onClick={reconnect}>
|
||||
{t("call_ended_view.reconnect_button")}
|
||||
@ -154,9 +153,9 @@ export const CallEndedView: FC<Props> = ({
|
||||
</div>
|
||||
</main>
|
||||
{!confineToRoom && (
|
||||
<Body className={styles.footer}>
|
||||
<Text className={styles.footer}>
|
||||
<Link to="/"> {t("return_home_button")} </Link>
|
||||
</Body>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -164,7 +163,7 @@ export const CallEndedView: FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<main className={styles.main}>
|
||||
<Headline className={styles.headline}>
|
||||
<Heading size="xl" weight="semibold" className={styles.headline}>
|
||||
{surveySubmitted
|
||||
? t("call_ended_view.headline", {
|
||||
displayName,
|
||||
@ -174,16 +173,16 @@ export const CallEndedView: FC<Props> = ({
|
||||
}) +
|
||||
"\n" +
|
||||
t("call_ended_view.survey_prompt")}
|
||||
</Headline>
|
||||
</Heading>
|
||||
{(!surveySubmitted || confineToRoom) &&
|
||||
PosthogAnalytics.instance.isEnabled()
|
||||
? qualitySurveyDialog
|
||||
: createAccountDialog}
|
||||
</main>
|
||||
{!confineToRoom && (
|
||||
<Body className={styles.footer}>
|
||||
<Text className={styles.footer}>
|
||||
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
|
||||
</Body>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -31,7 +31,6 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
||||
height={16}
|
||||
className={styles.lock}
|
||||
data-encrypted={encrypted}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -5,13 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Link } from "../button/Link";
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
@ -35,15 +34,6 @@ export function GroupCallLoader({
|
||||
const { t } = useTranslation();
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
|
||||
const history = useHistory();
|
||||
const onHomeClick = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push("/");
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
@ -63,9 +53,7 @@ export function GroupCallLoader({
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("common.home")}
|
||||
</Link>
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
@ -79,9 +67,7 @@ export function GroupCallLoader({
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("common.home")}
|
||||
</Link>
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
@ -40,6 +40,7 @@ import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -220,6 +221,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
matrixInfo.e2eeSystem.kind,
|
||||
rtcSession,
|
||||
sendInstantly,
|
||||
rtcSession,
|
||||
);
|
||||
|
||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||
@ -283,14 +285,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
|
||||
|
||||
const onHomeClick = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push("/");
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
||||
@ -299,9 +293,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
<FullScreenView>
|
||||
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
|
||||
<Text>{t("browser_media_e2ee_unsupported")}</Text>
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("common.home")}
|
||||
</Link>
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
35
src/room/InviteModal.test.tsx
Normal file
35
src/room/InviteModal.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { axe } from "vitest-axe";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { InviteModal } from "./InviteModal";
|
||||
|
||||
// Used by copy-to-clipboard
|
||||
window.prompt = (): null => null;
|
||||
|
||||
test("InviteModal is accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = {
|
||||
roomId: "!a:example.org",
|
||||
name: "Mission Control",
|
||||
} as unknown as Room;
|
||||
const onDismiss = vi.fn();
|
||||
const { container } = render(
|
||||
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
|
||||
{ wrapper: BrowserRouter },
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
await user.click(screen.getByRole("button", { name: "action.copy_link" }));
|
||||
expect(onDismiss).toBeCalled();
|
||||
});
|
@ -18,6 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@ -71,8 +72,14 @@ function useMuteState(
|
||||
export function useMuteStates(): MuteStates {
|
||||
const devices = useMediaDevices();
|
||||
|
||||
const audio = useMuteState(devices.audioInput, () => true);
|
||||
const video = useMuteState(devices.videoInput, () => true);
|
||||
const audio = useMuteState(
|
||||
devices.audioInput,
|
||||
() => Config.get().media_devices.enable_audio,
|
||||
);
|
||||
const video = useMuteState(
|
||||
devices.videoInput,
|
||||
() => Config.get().media_devices.enable_video,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport
|
||||
|
@ -7,12 +7,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Modal, Props as ModalProps } from "../Modal";
|
||||
import { FieldRow, ErrorMessage } from "../input/Input";
|
||||
import { useSubmitRageshake } from "../settings/submit-rageshake";
|
||||
import { Body } from "../typography/Typography";
|
||||
|
||||
interface Props extends Omit<ModalProps, "title" | "children"> {
|
||||
rageshakeRequestId: string;
|
||||
@ -40,7 +39,7 @@ export const RageshakeRequestModal: FC<Props> = ({
|
||||
open={open}
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<Body>{t("rageshake_request_modal.body")}</Body>
|
||||
<Text>{t("rageshake_request_modal.body")}</Text>
|
||||
<FieldRow>
|
||||
<Button
|
||||
onClick={(): void =>
|
||||
|
@ -9,16 +9,16 @@ import { FC, useCallback, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./RoomAuthView.module.css";
|
||||
import { Body, Caption, Link, Headline } from "../typography/Typography";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Form } from "../form/Form";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { Config } from "../config/Config";
|
||||
import { ExternalLink, Link } from "../button/Link";
|
||||
|
||||
export const RoomAuthView: FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -63,9 +63,9 @@ export const RoomAuthView: FC = () => {
|
||||
</Header>
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<Headline className={styles.headline}>
|
||||
{t("lobby.join_button")}
|
||||
</Headline>
|
||||
<Heading size="xl" weight="semibold" className={styles.headline}>
|
||||
{t("lobby.join_as_guest")}
|
||||
</Heading>
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
@ -79,14 +79,14 @@ export const RoomAuthView: FC = () => {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
<Text size="sm">
|
||||
<Trans i18nKey="room_auth_view_eula_caption">
|
||||
By clicking "Join call now", you agree to our{" "}
|
||||
<Link href={Config.get().eula}>
|
||||
<ExternalLink href={Config.get().eula}>
|
||||
End User Licensing Agreement (EULA)
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
</Trans>
|
||||
</Caption>
|
||||
</Text>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage error={error} />
|
||||
@ -98,22 +98,21 @@ export const RoomAuthView: FC = () => {
|
||||
disabled={loading}
|
||||
data-testid="joincall_joincall"
|
||||
>
|
||||
{loading ? t("common.loading") : t("room_auth_view_join_button")}
|
||||
{loading
|
||||
? t("common.loading")
|
||||
: t("room_auth_view_continue_button")}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
</Form>
|
||||
</main>
|
||||
<Body className={styles.footer}>
|
||||
<Text className={styles.footer}>
|
||||
<Trans i18nKey="unauthenticated_view_body">
|
||||
Not registered yet?{" "}
|
||||
<Link
|
||||
color="primary"
|
||||
to={{ pathname: "/register", state: { from: location } }}
|
||||
>
|
||||
<Link to="/register" state={{ from: location }}>
|
||||
Create an account
|
||||
</Link>
|
||||
</Trans>
|
||||
</Body>
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { RoomEvent, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
import { JoinRule, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { widget } from "../widget";
|
||||
@ -54,6 +54,42 @@ export type GroupCallStatus =
|
||||
| GroupCallWaitForInvite
|
||||
| GroupCallCanKnock;
|
||||
|
||||
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
|
||||
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
|
||||
|
||||
/**
|
||||
* Join a room, and retry on M_FORBIDDEN error in order to work
|
||||
* around a potential race when joining rooms over federation.
|
||||
*
|
||||
* Will wait up to to `DELAY_MS_FOR_INVITE_JOIN_FAILURE` per attempt.
|
||||
* Will try up to `MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE` times.
|
||||
*
|
||||
* @see https://github.com/element-hq/element-call/issues/2634
|
||||
* @param client The matrix client
|
||||
* @param attempt Number of attempts made.
|
||||
* @param params Parameters to pass to client.joinRoom
|
||||
*/
|
||||
async function joinRoomAfterInvite(
|
||||
client: MatrixClient,
|
||||
attempt = 0,
|
||||
...params: Parameters<MatrixClient["joinRoom"]>
|
||||
): ReturnType<MatrixClient["joinRoom"]> {
|
||||
try {
|
||||
return await client.joinRoom(...params);
|
||||
} catch (ex) {
|
||||
if (
|
||||
ex instanceof MatrixError &&
|
||||
ex.errcode === "M_FORBIDDEN" &&
|
||||
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
|
||||
) {
|
||||
// If we were invited and got a M_FORBIDDEN, it's highly likely the server hasn't caught up yet.
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
|
||||
return joinRoomAfterInvite(client, attempt + 1, ...params);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
export class CallTerminatedMessage extends Error {
|
||||
/**
|
||||
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
|
||||
@ -162,10 +198,13 @@ export const useLoadGroupCall = (
|
||||
membership === KnownMembership.Invite &&
|
||||
prevMembership === KnownMembership.Knock
|
||||
) {
|
||||
client.joinRoom(room.roomId, { viaServers }).then((room) => {
|
||||
joinRoomAfterInvite(client, 0, room.roomId, { viaServers }).then(
|
||||
(room) => {
|
||||
logger.log("Auto-joined %s", room.roomId);
|
||||
resolve(room);
|
||||
}, reject);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
}
|
||||
if (membership === KnownMembership.Ban) reject(bannedError());
|
||||
if (membership === KnownMembership.Leave)
|
||||
|
@ -11,6 +11,7 @@ import { expect, test, vi } from "vitest";
|
||||
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { DEFAULT_CONFIG } from "./config/ConfigOptions";
|
||||
|
||||
test("It joins the correct Session", async () => {
|
||||
const focusFromOlderMembership = {
|
||||
@ -35,8 +36,8 @@ test("It joins the correct Session", async () => {
|
||||
};
|
||||
|
||||
vi.spyOn(Config, "get").mockReturnValue({
|
||||
...DEFAULT_CONFIG,
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
eula: "",
|
||||
});
|
||||
const mockedSession = vi.mocked({
|
||||
room: {
|
||||
|
@ -8,12 +8,11 @@ Please see LICENSE in the repository root for full details.
|
||||
import { FC, useCallback } from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
|
||||
import { Body } from "../typography/Typography";
|
||||
import feedbackStyles from "../input/FeedbackInput.module.css";
|
||||
|
||||
interface Props {
|
||||
@ -56,7 +55,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
return (
|
||||
<div>
|
||||
<h4>{t("settings.feedback_tab_h4")}</h4>
|
||||
<Body>{t("settings.feedback_tab_body")}</Body>
|
||||
<Text>{t("settings.feedback_tab_body")}</Text>
|
||||
<form onSubmit={onSubmitFeedback}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
@ -85,7 +84,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
)}
|
||||
<FieldRow>
|
||||
{error && <ErrorMessage error={error} />}
|
||||
{sent && <Body> {t("settings.feedback_tab_thank_you")}</Body>}
|
||||
{sent && <Text>{t("settings.feedback_tab_thank_you")}</Text>}
|
||||
</FieldRow>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -8,13 +8,12 @@ Please see LICENSE in the repository root for full details.
|
||||
import { ChangeEvent, FC, ReactNode, useCallback } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { Dropdown } from "@vector-im/compound-web";
|
||||
import { Dropdown, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
import { Tab, TabContainer } from "../tabs/Tabs";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Caption } from "../typography/Typography";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
@ -102,14 +101,14 @@ export const SettingsModal: FC<Props> = ({
|
||||
};
|
||||
|
||||
const optInDescription = (
|
||||
<Caption>
|
||||
<Text size="sm">
|
||||
<Trans i18nKey="settings.opt_in_description">
|
||||
<AnalyticsNotice />
|
||||
<br />
|
||||
You may withdraw consent by unchecking this box. If you are currently in
|
||||
a call, this setting will take effect at the end of the call.
|
||||
</Trans>
|
||||
</Caption>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
|
@ -478,21 +478,22 @@ export async function init(): Promise<void> {
|
||||
|
||||
// intercept console logging so that we can get matrix_sdk logs:
|
||||
// this is nasty, but no logging hooks are provided
|
||||
(
|
||||
["trace", "debug", "info", "warn", "error"] as (
|
||||
| "trace"
|
||||
| "debug"
|
||||
| "info"
|
||||
| "warn"
|
||||
| "error"
|
||||
)[]
|
||||
).forEach((level) => {
|
||||
if (!window.console[level]) return;
|
||||
const prefix = `${level.toUpperCase()} matrix_sdk`;
|
||||
[
|
||||
"trace" as const,
|
||||
"debug" as const,
|
||||
"info" as const,
|
||||
"warn" as const,
|
||||
"error" as const,
|
||||
].forEach((level) => {
|
||||
const originalMethod = window.console[level];
|
||||
if (!originalMethod) return;
|
||||
const prefix = `${level.toUpperCase()} matrix_sdk`;
|
||||
window.console[level] = (...args): void => {
|
||||
originalMethod(...args);
|
||||
// args for calls from the matrix-sdk-crypto-wasm look like:
|
||||
// ["DEBUG matrix_sdk_indexeddb::crypto_store: IndexedDbCryptoStore: opening main store matrix-js-sdk::matrix-sdk-crypto\n at /home/runner/.cargo/git/checkouts/matrix-rust-sdk-1f4927f82a3d27bb/07aa6d7/crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs:267"]
|
||||
if (typeof args[0] === "string" && args[0].startsWith(prefix)) {
|
||||
// we pass all the args on to the logger in case there are more sent in future
|
||||
global.mx_rage_logger.log(LogLevel[level], "matrix_sdk", ...args);
|
||||
}
|
||||
};
|
||||
|
276
src/state/CallViewModel.test.ts
Normal file
276
src/state/CallViewModel.test.ts
Normal file
@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi, onTestFinished } from "vitest";
|
||||
import { map, Observable } from "rxjs";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { CallViewModel, Layout } from "./CallViewModel";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMember,
|
||||
mockRemoteParticipant,
|
||||
OurRunHelpers,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
ECConnectionState,
|
||||
} from "../livekit/useECConnectionState";
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const aliceId = "@alice:example.org:AAAA";
|
||||
const bobId = "@bob:example.org:BBBB";
|
||||
|
||||
const alice = mockMember({ userId: "@alice:example.org" });
|
||||
const bob = mockMember({ userId: "@bob:example.org" });
|
||||
const carol = mockMember({ userId: "@carol:example.org" });
|
||||
|
||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||
const aliceSharingScreen = mockRemoteParticipant({
|
||||
identity: aliceId,
|
||||
isScreenShareEnabled: true,
|
||||
});
|
||||
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
||||
const bobSharingScreen = mockRemoteParticipant({
|
||||
identity: bobId,
|
||||
isScreenShareEnabled: true,
|
||||
});
|
||||
|
||||
const members = new Map([
|
||||
[alice.userId, alice],
|
||||
[bob.userId, bob],
|
||||
[carol.userId, carol],
|
||||
]);
|
||||
|
||||
export interface GridLayoutSummary {
|
||||
type: "grid";
|
||||
spotlight?: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayoutSummary {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayoutSummary {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayoutSummary {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: string[];
|
||||
pip?: string;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayoutSummary {
|
||||
type: "one-on-one";
|
||||
local: string;
|
||||
remote: string;
|
||||
}
|
||||
|
||||
export interface PipLayoutSummary {
|
||||
type: "pip";
|
||||
spotlight: string[];
|
||||
}
|
||||
|
||||
export type LayoutSummary =
|
||||
| GridLayoutSummary
|
||||
| SpotlightLandscapeLayoutSummary
|
||||
| SpotlightPortraitLayoutSummary
|
||||
| SpotlightExpandedLayoutSummary
|
||||
| OneOnOneLayoutSummary
|
||||
| PipLayoutSummary;
|
||||
|
||||
function summarizeLayout(l: Layout): LayoutSummary {
|
||||
switch (l.type) {
|
||||
case "grid":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight?.map((vm) => vm.id),
|
||||
grid: l.grid.map((vm) => vm.id),
|
||||
};
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight.map((vm) => vm.id),
|
||||
grid: l.grid.map((vm) => vm.id),
|
||||
};
|
||||
case "spotlight-expanded":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight.map((vm) => vm.id),
|
||||
pip: l.pip?.id,
|
||||
};
|
||||
case "one-on-one":
|
||||
return { type: l.type, local: l.local.id, remote: l.remote.id };
|
||||
case "pip":
|
||||
return { type: l.type, spotlight: l.spotlight.map((vm) => vm.id) };
|
||||
}
|
||||
}
|
||||
|
||||
function withCallViewModel(
|
||||
{ cold }: OurRunHelpers,
|
||||
remoteParticipants: Observable<RemoteParticipant[]>,
|
||||
connectionState: Observable<ECConnectionState>,
|
||||
continuation: (vm: CallViewModel) => void,
|
||||
): void {
|
||||
const participantsSpy = vi
|
||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||
.mockReturnValue(remoteParticipants);
|
||||
const mediaSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||
.mockImplementation((p) =>
|
||||
cold("a", {
|
||||
a: { participant: p } as Partial<
|
||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>,
|
||||
}),
|
||||
);
|
||||
const eventsSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||
.mockImplementation((p) => cold("a", { a: p }));
|
||||
|
||||
const vm = new CallViewModel(
|
||||
mockMatrixRoom({
|
||||
client: {
|
||||
getUserId: () => "@carol:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient,
|
||||
getMember: (userId) => members.get(userId) ?? null,
|
||||
}),
|
||||
mockLivekitRoom({ localParticipant }),
|
||||
true,
|
||||
connectionState,
|
||||
);
|
||||
|
||||
onTestFinished(() => {
|
||||
vm!.destroy();
|
||||
participantsSpy!.mockRestore();
|
||||
mediaSpy!.mockRestore();
|
||||
eventsSpy!.mockRestore();
|
||||
});
|
||||
|
||||
continuation(vm);
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler((helpers) => {
|
||||
const { hot, expectObservable } = helpers;
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const partMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
const connMarbles = "ab-a";
|
||||
// The visible participants should remain the same throughout the switch
|
||||
const laytMarbles = "aaaa 2997ms a 56998ms a";
|
||||
|
||||
withCallViewModel(
|
||||
helpers,
|
||||
hot(partMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
hot(connMarbles, {
|
||||
a: ConnectionState.Connected,
|
||||
b: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
(vm) => {
|
||||
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||
laytMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler((helpers) => {
|
||||
const { hot, schedule, expectObservable } = helpers;
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const partMarbles = "abc---d---a-b---a";
|
||||
// While there are no screen shares, switch to spotlight manually, and then
|
||||
// switch back to grid at the end
|
||||
const modeMarbles = "-----------a--------b";
|
||||
// We should automatically enter spotlight for the first round of screen
|
||||
// sharing, then return to grid, then manually go into spotlight, and
|
||||
// remain in spotlight until we manually go back to grid
|
||||
const laytMarbles = "ab(cc)(dd)ae(bb)(ee)a 59979ms a";
|
||||
|
||||
withCallViewModel(
|
||||
helpers,
|
||||
hot(partMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
hot("a", { a: ConnectionState.Connected }),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, {
|
||||
a: () => vm.setGridMode("spotlight"),
|
||||
b: () => vm.setGridMode("grid"),
|
||||
});
|
||||
|
||||
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||
laytMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [
|
||||
`${aliceId}:0:screen-share`,
|
||||
`${bobId}:0:screen-share`,
|
||||
],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
e: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -30,13 +30,13 @@ import {
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
forkJoin,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeAll,
|
||||
mergeMap,
|
||||
of,
|
||||
race,
|
||||
sample,
|
||||
scan,
|
||||
skip,
|
||||
startWith,
|
||||
@ -46,7 +46,7 @@ import {
|
||||
take,
|
||||
throttleTime,
|
||||
timer,
|
||||
zip,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@ -170,19 +170,18 @@ class UserMedia {
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
this.vm =
|
||||
participant instanceof LocalParticipant
|
||||
this.vm = participant.isLocal
|
||||
? new LocalUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
participant as LocalParticipant,
|
||||
callEncrypted,
|
||||
livekitRoom,
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
participant as RemoteParticipant,
|
||||
callEncrypted,
|
||||
livekitRoom,
|
||||
);
|
||||
@ -199,6 +198,7 @@ class UserMedia {
|
||||
),
|
||||
),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
// Make this Observable hot so that the timers don't reset when you
|
||||
// resubscribe
|
||||
this.scope.state(),
|
||||
@ -276,10 +276,9 @@ export class CallViewModel extends ViewModel {
|
||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||
// they've left
|
||||
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||
zip(
|
||||
this.connectionState,
|
||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||
(s, ps) => {
|
||||
this.connectionState.pipe(
|
||||
withLatestFrom(this.rawRemoteParticipants),
|
||||
mergeMap(([s, ps]) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||
// give their clients time to switch over and avoid jarring layout shifts
|
||||
@ -288,29 +287,19 @@ export class CallViewModel extends ViewModel {
|
||||
// Hold these participants
|
||||
of({ hold: ps }),
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
forkJoin([
|
||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
this.connectionState.pipe(
|
||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||
take(1),
|
||||
),
|
||||
new Promise<void>((resolve) => {
|
||||
const subscription = this.connectionState
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((s) => {
|
||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Then unhold them
|
||||
]).then(() => ({ unhold: ps })),
|
||||
]).pipe(map(() => ({ unhold: ps }))),
|
||||
);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
}),
|
||||
// Accumulate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||
@ -356,8 +345,8 @@ export class CallViewModel extends ViewModel {
|
||||
const newItems = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||
const userMediaId = p === localParticipant ? "local" : p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
const id = p === localParticipant ? "local" : p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, id);
|
||||
if (member === undefined)
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||
@ -366,7 +355,7 @@ export class CallViewModel extends ViewModel {
|
||||
// Create as many tiles for this participant as called for by
|
||||
// the duplicateTiles option
|
||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||
const userMediaId = `${p.identity}:${i}`;
|
||||
const userMediaId = `${id}:${i}`;
|
||||
yield [
|
||||
userMediaId,
|
||||
prevItems.get(userMediaId) ??
|
||||
|
@ -5,93 +5,62 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { LocalParticipant, RemoteParticipant } from "livekit-client";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
|
||||
function withLocal(continuation: (vm: LocalUserMediaViewModel) => void): void {
|
||||
const member = {} as unknown as RoomMember;
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"a",
|
||||
member,
|
||||
{} as unknown as LocalParticipant,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function withRemote(
|
||||
participant: Partial<RemoteParticipant>,
|
||||
continuation: (vm: RemoteUserMediaViewModel) => void,
|
||||
): void {
|
||||
const member = {} as unknown as RoomMember;
|
||||
const vm = new RemoteUserMediaViewModel(
|
||||
"a",
|
||||
member,
|
||||
{ setVolume() {}, ...participant } as RemoteParticipant,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("set a participant's volume", () => {
|
||||
test("control a participant's volume", async () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
||||
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", {
|
||||
a() {
|
||||
vm.setLocalVolume(0.8);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.localVolume).toBe("ab", { a: 1, b: 0.8 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("mute and unmute a participant", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-abc|", {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.setLocalVolume(0.6);
|
||||
vm.setLocalVolume(0.8);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.setLocalVolume(0.2);
|
||||
vm.setLocalVolume(0);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.locallyMuted).toBe("ab-c", {
|
||||
a: false,
|
||||
b: true,
|
||||
c: false,
|
||||
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
d: 0.8,
|
||||
e: 0.2,
|
||||
f: 0,
|
||||
g: 0.8,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
withRemote({}, (vm) =>
|
||||
test("toggle fit/contain for a participant's video", async () => {
|
||||
await withRemoteMedia({}, {}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
@ -106,15 +75,15 @@ test("toggle fit/contain for a participant's video", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
withLocal((vm) =>
|
||||
test("local media remembers whether it should always be shown", async () => {
|
||||
await withLocalMedia({}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
|
||||
}),
|
||||
);
|
||||
// Next local media should start out *not* always shown
|
||||
withLocal((vm) =>
|
||||
await withLocalMedia({}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
|
||||
|
@ -29,6 +29,7 @@ import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
@ -47,6 +48,7 @@ import { useEffect } from "react";
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { alwaysShowSelf } from "../settings/settings";
|
||||
import { accumulate } from "../utils/observable";
|
||||
|
||||
// TODO: Move this naming logic into the view model
|
||||
export function useDisplayName(vm: MediaViewModel): string {
|
||||
@ -287,18 +289,51 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
||||
/**
|
||||
* Whether we've disabled this participant's audio.
|
||||
*/
|
||||
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
|
||||
private readonly locallyMutedToggle = new Subject<void>();
|
||||
private readonly localVolumeAdjustment = new Subject<number>();
|
||||
private readonly localVolumeCommit = new Subject<void>();
|
||||
|
||||
private readonly _localVolume = new BehaviorSubject(1);
|
||||
/**
|
||||
* The volume to which we've set this participant's audio, as a scalar
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume: Observable<number> = this._localVolume;
|
||||
public readonly localVolume: Observable<number> = merge(
|
||||
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment,
|
||||
this.localVolumeCommit.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
|
||||
map((volume) => volume === 0),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
@ -309,22 +344,24 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
) {
|
||||
super(id, member, participant, callEncrypted, livekitRoom);
|
||||
|
||||
// Sync the local mute state and volume with LiveKit
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
muted ? 0 : volume,
|
||||
)
|
||||
// Sync the local volume with LiveKit
|
||||
this.localVolume
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((volume) => {
|
||||
(this.participant as RemoteParticipant).setVolume(volume);
|
||||
});
|
||||
.subscribe((volume) =>
|
||||
(this.participant as RemoteParticipant).setVolume(volume),
|
||||
);
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this._locallyMuted.next(!this._locallyMuted.value);
|
||||
this.locallyMutedToggle.next();
|
||||
}
|
||||
|
||||
public setLocalVolume(value: number): void {
|
||||
this._localVolume.next(value);
|
||||
this.localVolumeAdjustment.next(value);
|
||||
}
|
||||
|
||||
public commitLocalVolume(): void {
|
||||
this.localVolumeCommit.next();
|
||||
}
|
||||
}
|
||||
|
||||
|
43
src/tile/GridTile.test.tsx
Normal file
43
src/tile/GridTile.test.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RemoteTrackPublication } from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
import { withRemoteMedia } from "../utils/test";
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
},
|
||||
async (vm) => {
|
||||
const { container } = render(
|
||||
<GridTile
|
||||
vm={vm}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showVideo
|
||||
showSpeakingIndicators
|
||||
/>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Name should be visible
|
||||
screen.getByText("Alice");
|
||||
},
|
||||
);
|
||||
});
|
@ -239,6 +239,7 @@ const RemoteUserMediaTile = forwardRef<
|
||||
(v: number) => vm.setLocalVolume(v),
|
||||
[vm],
|
||||
);
|
||||
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
|
||||
|
||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
|
||||
@ -262,10 +263,10 @@ const RemoteUserMediaTile = forwardRef<
|
||||
label={t("video_tile.volume")}
|
||||
value={localVolume}
|
||||
onValueChange={onChangeLocalVolume}
|
||||
min={0.1}
|
||||
onValueCommit={onCommitLocalVolume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={locallyMuted}
|
||||
/>
|
||||
</MenuItem>
|
||||
</>
|
||||
|
@ -122,7 +122,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
73
src/tile/SpotlightTile.test.tsx
Normal file
73
src/tile/SpotlightTile.test.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import { withLocalMedia, withRemoteMedia } from "../utils/test";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{},
|
||||
async (vm1) => {
|
||||
await withLocalMedia(
|
||||
{
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
async (vm2) => {
|
||||
const user = userEvent.setup();
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vms={[vm1, vm2]}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
maximised={false}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Alice should be in the spotlight, with her name and avatar on the
|
||||
// first page
|
||||
screen.getByText("Alice");
|
||||
const aliceAvatar = screen.getByRole("img");
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).toBe(
|
||||
null,
|
||||
);
|
||||
// Bob should be out of the spotlight, and therefore invisible
|
||||
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||
// Now navigate to Bob
|
||||
await user.click(screen.getByRole("button", { name: "common.next" }));
|
||||
screen.getByText("Bob");
|
||||
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||
// Can toggle whether the tile is expanded
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "video_tile.expand" }),
|
||||
);
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
@ -56,6 +56,7 @@ interface SpotlightItemBaseProps {
|
||||
encryptionKeyInvalid: boolean;
|
||||
displayName: string;
|
||||
participantId: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
@ -113,10 +114,21 @@ interface SpotlightItemProps {
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
||||
(
|
||||
{
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useDisplayName(vm);
|
||||
@ -162,6 +174,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
encryptionKeyMissing,
|
||||
encryptionKeyInvalid,
|
||||
participantId,
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
@ -289,7 +302,12 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver={intersectionObserver}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -297,9 +315,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
|
@ -1,47 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.caption {
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: var(--font-size-body);
|
||||
}
|
||||
|
||||
.micro {
|
||||
font-size: var(--font-size-micro);
|
||||
line-height: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.semiBold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--cpd-color-text-link-external);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
opacity: initial;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
}
|
||||
|
||||
.overflowEllipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
@ -1,262 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createElement, forwardRef, ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import * as H from "history";
|
||||
|
||||
import styles from "./Typography.module.css";
|
||||
|
||||
interface TypographyProps {
|
||||
children: ReactNode;
|
||||
fontWeight?: string;
|
||||
className?: string;
|
||||
overflowEllipsis?: boolean;
|
||||
as?: string;
|
||||
}
|
||||
|
||||
export const Headline = forwardRef<HTMLHeadingElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "h1",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Headline.displayName = "Headline";
|
||||
|
||||
export const Title = forwardRef<HTMLHeadingElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "h2",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Title.displayName = "Title";
|
||||
|
||||
export const Subtitle = forwardRef<HTMLParagraphElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "h3",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Subtitle.displayName = "Subtitle";
|
||||
|
||||
export const Body = forwardRef<HTMLParagraphElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Body.displayName = "Body";
|
||||
|
||||
export const Caption = forwardRef<HTMLParagraphElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles.caption,
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Caption.displayName = "Caption";
|
||||
|
||||
export const Micro = forwardRef<HTMLParagraphElement, TypographyProps>(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
...rest,
|
||||
className: classNames(
|
||||
styles.micro,
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Micro.displayName = "Micro";
|
||||
|
||||
interface LinkProps extends TypographyProps {
|
||||
to?: H.LocationDescriptor<unknown>;
|
||||
color?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
(
|
||||
{
|
||||
as,
|
||||
children,
|
||||
className,
|
||||
color = "link",
|
||||
href,
|
||||
to,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const Component: string | RouterLink = as || (to ? RouterLink : "a");
|
||||
let externalLinkProps: { href: string; target: string; rel: string };
|
||||
|
||||
if (href) {
|
||||
externalLinkProps = {
|
||||
href,
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener",
|
||||
};
|
||||
}
|
||||
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
...externalLinkProps,
|
||||
...rest,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
to: to,
|
||||
className: classNames(
|
||||
styles[color],
|
||||
styles[fontWeight ?? ""],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className,
|
||||
),
|
||||
ref: ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Link.displayName = "Link";
|
@ -16,8 +16,6 @@ import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcut
|
||||
// Test Explanation:
|
||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||
// The TestComponent just wraps a button around that hook.
|
||||
// - We need to set `userEvent` to the `{document = window.document}` since we are testing the
|
||||
// `useCallViewKeyboardShortcuts` hook here. Which is listening to window.
|
||||
|
||||
interface TestComponentProps {
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
@ -43,7 +41,7 @@ const TestComponent: FC<TestComponentProps> = ({
|
||||
};
|
||||
|
||||
test("spacebar unmutes", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
let muted = true;
|
||||
render(
|
||||
<TestComponent
|
||||
@ -62,7 +60,7 @@ test("spacebar unmutes", async () => {
|
||||
});
|
||||
|
||||
test("spacebar prioritizes pressing a button", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const setMuted = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
@ -77,7 +75,7 @@ test("spacebar prioritizes pressing a button", async () => {
|
||||
});
|
||||
|
||||
test("unmuting happens in place of the default action", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
const defaultPrevented = vi.fn();
|
||||
// In the real application, we mostly just want the spacebar shortcut to avoid
|
||||
// scrolling the page. But to test that here in JSDOM, we need some kind of
|
||||
|
78
src/useTheme.test.ts
Normal file
78
src/useTheme.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook } from "@testing-library/react-hooks";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
Mock,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
|
||||
// Mock the useUrlParams hook
|
||||
vi.mock("./UrlParams", () => ({
|
||||
useUrlParams: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useTheme", () => {
|
||||
let originalClassList: DOMTokenList;
|
||||
beforeEach(() => {
|
||||
// Save the original classList to setup spies
|
||||
originalClassList = document.body.classList;
|
||||
|
||||
vi.spyOn(originalClassList, "add");
|
||||
vi.spyOn(originalClassList, "remove");
|
||||
vi.spyOn(originalClassList, "item").mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ setTheme: null, add: ["cpd-theme-dark"] },
|
||||
{ setTheme: "light", add: ["cpd-theme-light"] },
|
||||
{ setTheme: "dark-high-contrast", add: ["cpd-theme-dark-hc"] },
|
||||
{ setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] },
|
||||
])("apply procedure", ({ setTheme, add }) => {
|
||||
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
|
||||
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||
"cpd-theme-light",
|
||||
"cpd-theme-dark",
|
||||
"cpd-theme-light-hc",
|
||||
"cpd-theme-dark-hc",
|
||||
);
|
||||
expect(originalClassList.add).toHaveBeenCalledWith(...add);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not reapply the same theme if it hasn't changed", () => {
|
||||
(useUrlParams as Mock).mockReturnValue({ theme: "dark" });
|
||||
// Simulate a previous theme
|
||||
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(document.body.classList.add).not.toHaveBeenCalledWith(
|
||||
"cpd-theme-dark",
|
||||
);
|
||||
|
||||
// Ensure the 'no-theme' class is removed
|
||||
expect(document.body.classList.remove).toHaveBeenCalledWith("no-theme");
|
||||
expect(originalClassList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -7,6 +7,19 @@ Please see LICENSE in the repository root for full details.
|
||||
import { map } from "rxjs";
|
||||
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||
import { expect, vi } from "vitest";
|
||||
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
|
||||
export function withFakeTimers(continuation: () => void): void {
|
||||
vi.useFakeTimers();
|
||||
@ -49,3 +62,102 @@ export function withTestScheduler(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
on: () => T;
|
||||
off: () => T;
|
||||
addListener: () => T;
|
||||
removeListener: () => T;
|
||||
}
|
||||
|
||||
function mockEmitter<T>(): EmitterMock<T> {
|
||||
return {
|
||||
on(): T {
|
||||
return this as T;
|
||||
},
|
||||
off(): T {
|
||||
return this as T;
|
||||
},
|
||||
addListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
removeListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
|
||||
// rather simple, but if one util to mock a member is good enough for us, maybe
|
||||
// it's useful for matrix-js-sdk consumers in general.
|
||||
export function mockMember(member: Partial<RoomMember>): RoomMember {
|
||||
return { ...mockEmitter(), ...member } as RoomMember;
|
||||
}
|
||||
|
||||
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
||||
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
||||
}
|
||||
|
||||
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom {
|
||||
return { ...mockEmitter(), ...room } as Partial<LivekitRoom> as LivekitRoom;
|
||||
}
|
||||
|
||||
export function mockLocalParticipant(
|
||||
participant: Partial<LocalParticipant>,
|
||||
): LocalParticipant {
|
||||
return {
|
||||
isLocal: true,
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
...mockEmitter(),
|
||||
...participant,
|
||||
} as Partial<LocalParticipant> as LocalParticipant;
|
||||
}
|
||||
|
||||
export async function withLocalMedia(
|
||||
member: Partial<RoomMember>,
|
||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"local",
|
||||
mockMember(member),
|
||||
mockLocalParticipant({}),
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function mockRemoteParticipant(
|
||||
participant: Partial<RemoteParticipant>,
|
||||
): RemoteParticipant {
|
||||
return {
|
||||
isLocal: false,
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
...mockEmitter(),
|
||||
...participant,
|
||||
} as RemoteParticipant;
|
||||
}
|
||||
|
||||
export async function withRemoteMedia(
|
||||
member: Partial<RoomMember>,
|
||||
participant: Partial<RemoteParticipant>,
|
||||
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const vm = new RemoteUserMediaViewModel(
|
||||
"remote",
|
||||
mockMember(member),
|
||||
mockRemoteParticipant(participant),
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,14 @@ Copyright 2024 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "global-jsdom/register";
|
||||
import globalJsdom from "global-jsdom";
|
||||
import i18n from "i18next";
|
||||
import posthog from "posthog-js";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { afterEach, beforeEach } from "vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import "vitest-axe/extend-expect";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
@ -30,12 +31,12 @@ i18n
|
||||
Config.initDefault();
|
||||
posthog.opt_out_capturing();
|
||||
|
||||
// We need to cleanup the global jsDom
|
||||
// Otherwise we will run into issues with async input test overlapping and throwing.
|
||||
afterEach(cleanup);
|
||||
|
||||
let cleanupJsDom: { (): void };
|
||||
beforeEach(() => (cleanupJsDom = globalJsdom()));
|
||||
afterEach(() => {
|
||||
cleanupJsDom();
|
||||
cleanup();
|
||||
});
|
||||
// Used by a lot of components
|
||||
window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}) as Partial<MediaQueryList> as MediaQueryList;
|
||||
|
@ -12,11 +12,11 @@ export default defineConfig((configEnv) =>
|
||||
classNameStrategy: "non-scoped",
|
||||
},
|
||||
},
|
||||
isolate: false,
|
||||
setupFiles: ["src/vitest.setup.ts"],
|
||||
coverage: {
|
||||
reporter: ["html", "json"],
|
||||
include: ["src/"],
|
||||
exclude: ["src/**/*.{d,test}.{ts,tsx}", "src/utils/test.ts"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
Loading…
Reference in New Issue
Block a user