Merge branch 'livekit' into hughns/to-device-key-distribution

This commit is contained in:
Hugh Nimmo-Smith 2024-10-30 10:11:00 +00:00
commit 01c2efc7ed
80 changed files with 3039 additions and 2539 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/*"

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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:

View File

@ -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"
}
}

View File

@ -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",

View File

@ -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>",

View File

@ -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"
}

View File

@ -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>",

View File

@ -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",

View File

@ -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 dinformations 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 dinformations 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 lapplication",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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>",

View File

@ -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",

View File

@ -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": "Відкрити у застосунку",

View File

@ -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": "在应用中打开",

View File

@ -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": "在應用程式中開啟",

View File

@ -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
View 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" });
});

View File

@ -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,

View File

@ -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}

View File

@ -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", () => {

View File

@ -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>
);

View File

@ -66,6 +66,7 @@ export class CallEndedTracker {
e2eeType: E2eeType,
rtcSession: MatrixRTCSession,
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
): void {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{

View File

@ -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;
}

View File

@ -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();

View File

@ -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} />

View 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;
}

View File

@ -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>
);
});

View File

@ -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;

View File

@ -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,
},
};

View 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();
});
});

View File

@ -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 = (

View File

@ -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;
}

View File

@ -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}>
<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} />
</Link>
{isLeaving ? (
<span className={classNames(styles.callTileLink, styles.disabled)}>
{body}
</span>
) : (
<Link
to={getRelativeRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
className={styles.callTileLink}
>
{body}
</Link>
)}
</div>
);
};

View File

@ -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}>

View File

@ -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 && (

View File

@ -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;

View 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);
});

View File

@ -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,
};

View File

@ -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>
)}
</>
);

View File

@ -31,7 +31,6 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
height={16}
className={styles.lock}
data-encrypted={encrypted}
aria-hidden
/>
</Tooltip>
);

View File

@ -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 {

View File

@ -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>
);
}

View 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();
});

View File

@ -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

View File

@ -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 =>

View File

@ -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>
</>
);

View File

@ -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) => {
logger.log("Auto-joined %s", room.roomId);
resolve(room);
}, reject);
joinRoomAfterInvite(client, 0, room.roomId, { viaServers }).then(
(room) => {
logger.log("Auto-joined %s", room.roomId);
resolve(room);
},
reject,
);
}
if (membership === KnownMembership.Ban) reject(bannedError());
if (membership === KnownMembership.Leave)

View File

@ -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: {

View File

@ -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>

View File

@ -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();

View File

@ -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);
}
};

View 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`],
},
},
);
},
);
});
});

View File

@ -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,22 +170,21 @@ class UserMedia {
callEncrypted: boolean,
livekitRoom: LivekitRoom,
) {
this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(
id,
member,
participant,
callEncrypted,
livekitRoom,
)
: new RemoteUserMediaViewModel(
id,
member,
participant,
callEncrypted,
livekitRoom,
);
this.vm = participant.isLocal
? new LocalUserMediaViewModel(
id,
member,
participant as LocalParticipant,
callEncrypted,
livekitRoom,
)
: new RemoteUserMediaViewModel(
id,
member,
participant as RemoteParticipant,
callEncrypted,
livekitRoom,
);
this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
@ -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) ??

View File

@ -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 });

View File

@ -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();
}
}

View 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");
},
);
});

View File

@ -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>
</>

View File

@ -122,7 +122,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
width={20}
height={20}
className={styles.errorIcon}
aria-hidden
/>
</Tooltip>
)}

View 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();
},
);
},
);
});

View File

@ -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}
>

View File

@ -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;
}

View File

@ -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";

View File

@ -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
View 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();
});
});

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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"],
},
},
}),

3604
yarn.lock

File diff suppressed because it is too large Load Diff