diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index b3a913bf..3f707a36 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dfb8fc2b..c1fbca28 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.github/workflows/element-call.yaml b/.github/workflows/element-call.yaml index c6485e7f..5a342cb8 100644 --- a/.github/workflows/element-call.yaml +++ b/.github/workflows/element-call.yaml @@ -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 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 5c676a7c..585f0f43 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -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/*" diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 63ad7fbf..19d4ffdf 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -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: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 870dabe1..fde77b63 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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: diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 42a1cc69..1caab0b0 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -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 diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 0f759ddb..f52421b8 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -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 diff --git a/README.md b/README.md index 4a9adc73..9ff4e156 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend-docker-compose.yml b/backend-docker-compose.yml index 75b7f720..b0dbe822 100644 --- a/backend-docker-compose.yml +++ b/backend-docker-compose.yml @@ -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: diff --git a/codecov.yaml b/codecov.yaml index 0f42ad4e..e1289344 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -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: diff --git a/package.json b/package.json index 4355effc..8f3e49d5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@sentry/vite-plugin": "^2.0.0", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.1", "@types/content-type": "^1.1.5", "@types/grecaptcha": "^3.0.9", @@ -70,9 +71,9 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^1.2.1", "eslint-plugin-react": "^7.29.4", - "eslint-plugin-react-hooks": "^4.5.0", + "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^55.0.0", - "global-jsdom": "^24.0.0", + "global-jsdom": "^25.0.0", "history": "^4.0.0", "i18next": "^23.0.0", "i18next-browser-languagedetector": "^8.0.0", @@ -80,7 +81,7 @@ "i18next-parser": "^9.0.0", "jsdom": "^25.0.0", "knip": "^5.27.2", - "livekit-client": "^2.0.2", + "livekit-client": "^2.5.7", "lodash": "^4.17.21", "loglevel": "^1.9.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#b0174eccdb0e33f5df5d7b590938daf8ff5c7f7a", @@ -90,7 +91,7 @@ "pako": "^2.0.4", "postcss": "^8.4.41", "postcss-preset-env": "^10.0.0", - "posthog-js": "^1.29.0", + "posthog-js": "1.160.3", "prettier": "^3.0.0", "qrcode": "^1.5.4", "react": "18", @@ -108,6 +109,10 @@ "vite": "^5.0.0", "vite-plugin-html-template": "^1.1.0", "vite-plugin-svgr": "^4.0.0", - "vitest": "^2.0.0" + "vitest": "^2.0.0", + "vitest-axe": "^1.0.0-pre.3" + }, + "resolutions": { + "strip-ansi": "6.0.1" } } diff --git a/public/locales/de/app.json b/public/locales/de/app.json index 359b352c..80691ad2 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -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 und unseren <5>Cookie-Richtlinien.", + "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 und unseren <6>Cookie-Richtlinien.", "app_selection_modal": { "continue_in_browser": "Weiter im Browser", "open_in_app": "In der App öffnen", diff --git a/public/locales/el/app.json b/public/locales/el/app.json index 249ae236..cdc814aa 100644 --- a/public/locales/el/app.json +++ b/public/locales/el/app.json @@ -13,7 +13,7 @@ "sign_out": "Αποσύνδεση", "submit": "Υποβολή" }, - "analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου και στην <5>Πολιτική cookies.", + "analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου και στην <6>Πολιτική cookies.", "call_ended_view": { "create_account_button": "Δημιουργία λογαριασμού", "create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;<1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.", diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 6581274a..0cfa3085 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -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 and our <5>Cookie Policy.", + "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 and our <6>Cookie Policy.", "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)", - "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)", "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" } diff --git a/public/locales/es/app.json b/public/locales/es/app.json index b9a11989..f58e0998 100644 --- a/public/locales/es/app.json +++ b/public/locales/es/app.json @@ -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 y en nuestra <5>Política sobre Cookies.", + "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 y en nuestra <6>Política sobre Cookies.", "call_ended_view": { "create_account_button": "Crear cuenta", "create_account_prompt": "<0>¿Por qué no mantienes tu cuenta estableciendo una contraseña?<1>Podrás mantener tu nombre y establecer un avatar para usarlo en futuras llamadas", diff --git a/public/locales/et/app.json b/public/locales/et/app.json index 2ef7a76b..042d06a7 100644 --- a/public/locales/et/app.json +++ b/public/locales/et/app.json @@ -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 ja meie <5>Küpsiste kasutamise reeglitest.", + "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 ja meie <6>Küpsiste kasutamise reeglitest.", "app_selection_modal": { "continue_in_browser": "Jätka veebibrauseris", "open_in_app": "Ava rakenduses", diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index b5ddd1e1..406ca5a2 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -15,7 +15,7 @@ "sign_out": "Déconnexion", "submit": "Envoyer" }, - "analytics_notice": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus d’informations sur les données collectées dans notre <2>Politique de vie privée et notre <5>Politique de cookies.", + "analytics_notice": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus d’informations sur les données collectées dans notre <2>Politique de vie privée et notre <6>Politique de cookies.", "app_selection_modal": { "continue_in_browser": "Continuer dans le navigateur", "open_in_app": "Ouvrir dans l’application", diff --git a/public/locales/id/app.json b/public/locales/id/app.json index ce5cee36..ae4c2ba5 100644 --- a/public/locales/id/app.json +++ b/public/locales/id/app.json @@ -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 dan <5>Kebijakan Kuki 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 dan <6>Kebijakan Kuki kami.", "app_selection_modal": { "continue_in_browser": "Lanjutkan dalam peramban", "open_in_app": "Buka dalam aplikasi", diff --git a/public/locales/it/app.json b/public/locales/it/app.json index 7819fd30..6c0f9322 100644 --- a/public/locales/it/app.json +++ b/public/locales/it/app.json @@ -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 e nell'<5>informativa sui cookie.", + "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 e nell'<6>informativa sui cookie.", "app_selection_modal": { "continue_in_browser": "Continua nel browser", "open_in_app": "Apri nell'app", diff --git a/public/locales/lv/app.json b/public/locales/lv/app.json index 5c8ab81a..bde88489 100644 --- a/public/locales/lv/app.json +++ b/public/locales/lv/app.json @@ -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 un <5>sīkdatņu nosacījumos.", + "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 un <6>sīkdatņu nosacījumos.", "call_ended_view": { "body": "Tu tiki atvienots no zvana", "create_account_button": "Izveidot kontu", diff --git a/public/locales/pl/app.json b/public/locales/pl/app.json index 47375d60..2882bc24 100644 --- a/public/locales/pl/app.json +++ b/public/locales/pl/app.json @@ -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 i <5>Polityce ciasteczek.", + "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 i <6>Polityce ciasteczek.", "app_selection_modal": { "continue_in_browser": "Kontynuuj w przeglądarce", "open_in_app": "Otwórz w aplikacji", diff --git a/public/locales/ru/app.json b/public/locales/ru/app.json index 3409acf4..36517c7e 100644 --- a/public/locales/ru/app.json +++ b/public/locales/ru/app.json @@ -13,7 +13,7 @@ "sign_out": "Выйти", "submit": "Отправить" }, - "analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности и нашей <5> Политике использования файлов cookie.", + "analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности и нашей <6> Политике использования файлов cookie.", "call_ended_view": { "create_account_button": "Создать аккаунт", "create_account_prompt": "<0>Почему бы не задать пароль, тем самым сохранив аккаунт?<1>Так вы можете оставить своё имя и задать аватар для будущих звонков.", diff --git a/public/locales/sk/app.json b/public/locales/sk/app.json index 7888c256..eabef9e8 100644 --- a/public/locales/sk/app.json +++ b/public/locales/sk/app.json @@ -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 a <5>Zásadách používania súborov cookie.", + "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 a <6>Zásadách používania súborov cookie.", "app_selection_modal": { "continue_in_browser": "Pokračovať v prehliadači", "open_in_app": "Otvoriť v aplikácii", diff --git a/public/locales/uk/app.json b/public/locales/uk/app.json index dc2587ba..922658ef 100644 --- a/public/locales/uk/app.json +++ b/public/locales/uk/app.json @@ -15,7 +15,7 @@ "sign_out": "Вийти", "submit": "Надіслати" }, - "analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності і нашій <5>Політиці про куки.", + "analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності і нашій <6>Політиці про куки.", "app_selection_modal": { "continue_in_browser": "Продовжити у браузері", "open_in_app": "Відкрити у застосунку", diff --git a/public/locales/zh-Hans/app.json b/public/locales/zh-Hans/app.json index 86a926c8..a7d3d876 100644 --- a/public/locales/zh-Hans/app.json +++ b/public/locales/zh-Hans/app.json @@ -13,7 +13,7 @@ "sign_out": "登出", "submit": "提交" }, - "analytics_notice": "参与测试即表示您同意我们收集匿名数据,用于改进产品。您可以在我们的<2>隐私政策和<5>Cookie政策中找到有关我们跟踪哪些数据以及更多信息。", + "analytics_notice": "参与测试即表示您同意我们收集匿名数据,用于改进产品。您可以在我们的<2>隐私政策和<6>Cookie政策中找到有关我们跟踪哪些数据以及更多信息。", "app_selection_modal": { "continue_in_browser": "在浏览器中继续", "open_in_app": "在应用中打开", diff --git a/public/locales/zh-Hant/app.json b/public/locales/zh-Hant/app.json index c6484d4a..f667ef1d 100644 --- a/public/locales/zh-Hant/app.json +++ b/public/locales/zh-Hant/app.json @@ -15,7 +15,7 @@ "sign_out": "登出", "submit": "遞交" }, - "analytics_notice": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策與我們的 <5>Cookie 政策 中找到關於我們追蹤哪些資料的更多資訊。", + "analytics_notice": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策與我們的 <6>Cookie 政策 中找到關於我們追蹤哪些資料的更多資訊。", "app_selection_modal": { "continue_in_browser": "在瀏覽器中繼續", "open_in_app": "在應用程式中開啟", diff --git a/renovate.json b/renovate.json index 91fc985a..556a876a 100644 --- a/renovate.json +++ b/renovate.json @@ -44,5 +44,6 @@ "prHeader": "Please review modals on mobile for visual regressions." } ], - "semanticCommits": "disabled" + "semanticCommits": "disabled", + "ignoreDeps": ["posthog-js"] } diff --git a/src/Header.test.tsx b/src/Header.test.tsx new file mode 100644 index 00000000..681ef991 --- /dev/null +++ b/src/Header.test.tsx @@ -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( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + // Check that the room name acts as a heading + screen.getByRole("heading", { name: "Mission Control" }); +}); diff --git a/src/Modal.tsx b/src/Modal.tsx index 6ba9e239..deef7635 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -89,6 +89,9 @@ export const Modal: FC = ({ styles.drawer, { [styles.tabbed]: tabbed }, )} + // Suppress the warning about there being no description; the modal + // has an accessible title + aria-describedby={undefined} {...rest} >
@@ -111,7 +114,9 @@ export const Modal: FC = ({ - + {/* Suppress the warning about there being no description; the modal + has an accessible title */} + 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 = ({ label, value, onValueChange: onValueChangeProp, + onValueCommit: onValueCommitProp, min, max, step, @@ -39,12 +47,17 @@ export const Slider: FC = ({ ([v]: number[]) => onValueChangeProp(v), [onValueChangeProp], ); + const onValueCommit = useCallback( + ([v]: number[]) => onValueCommitProp?.(v), + [onValueCommitProp], + ); return ( { 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( @@ -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( Hello world! , @@ -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", () => { diff --git a/src/analytics/AnalyticsNotice.tsx b/src/analytics/AnalyticsNotice.tsx index 8df2d742..9ba78f0d 100644 --- a/src/analytics/AnalyticsNotice.tsx +++ b/src/analytics/AnalyticsNotice.tsx @@ -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 = () => ( 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{" "} - Privacy Policy and our{" "} - Cookie Policy. + + Privacy Policy + {" "} + and our{" "} + + Cookie Policy + + . ); diff --git a/src/analytics/PosthogEvents.ts b/src/analytics/PosthogEvents.ts index e2e428b4..492d5781 100644 --- a/src/analytics/PosthogEvents.ts +++ b/src/analytics/PosthogEvents.ts @@ -66,6 +66,7 @@ export class CallEndedTracker { e2eeType: E2eeType, rtcSession: MatrixRTCSession, sendInstantly: boolean, + rtcSession: MatrixRTCSession, ): void { PosthogAnalytics.instance.trackEvent( { diff --git a/src/auth/LoginPage.module.css b/src/auth/LoginPage.module.css index d42e1b5a..9bd9f3e5 100644 --- a/src/auth/LoginPage.module.css +++ b/src/auth/LoginPage.module.css @@ -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; -} diff --git a/src/auth/LoginPage.tsx b/src/auth/LoginPage.tsx index 515b6c99..e4aede09 100644 --- a/src/auth/LoginPage.tsx +++ b/src/auth/LoginPage.tsx @@ -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(); diff --git a/src/auth/RegisterPage.tsx b/src/auth/RegisterPage.tsx index 5ee1c9eb..392f8a7a 100644 --- a/src/auth/RegisterPage.tsx +++ b/src/auth/RegisterPage.tsx @@ -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" /> - + This site is protected by ReCAPTCHA and the Google{" "} - + Privacy Policy - {" "} + {" "} and{" "} - + Terms of Service - {" "} + {" "} apply.
By clicking "Register", you agree to our{" "} - + End User Licensing Agreement (EULA) - +
- +
{error && ( diff --git a/src/button/Link.module.css b/src/button/Link.module.css new file mode 100644 index 00000000..6248bc40 --- /dev/null +++ b/src/button/Link.module.css @@ -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; +} diff --git a/src/button/Link.tsx b/src/button/Link.tsx index 35c9af98..68c4dd13 100644 --- a/src/button/Link.tsx +++ b/src/button/Link.tsx @@ -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, "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(function Link( - { to, ...props }, + { to, state, ...props }, ref, ) { - const [path, onClick] = useLink(to); + const [path, onClick] = useLink(to, state); return ; }); + +/** + * 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 ( + + {children} + + ); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts index 941ffc82..972c9e0c 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -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; diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 2fd264ab..4f1ed02a 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -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, + }, }; diff --git a/src/e2ee/matrixKeyProvider.test.ts b/src/e2ee/matrixKeyProvider.test.ts new file mode 100644 index 00000000..e5b4015f --- /dev/null +++ b/src/e2ee/matrixKeyProvider.test.ts @@ -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(); + }); +}); diff --git a/src/e2ee/matrixKeyProvider.ts b/src/e2ee/matrixKeyProvider.ts index d84c3684..6cbecd19 100644 --- a/src/e2ee/matrixKeyProvider.ts +++ b/src/e2ee/matrixKeyProvider.ts @@ -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 = ( diff --git a/src/home/CallList.module.css b/src/home/CallList.module.css index 8e988d03..faa5bf2d 100644 --- a/src/home/CallList.module.css +++ b/src/home/CallList.module.css @@ -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; +} diff --git a/src/home/CallList.tsx b/src/home/CallList.tsx index aa6db6f3..72b7356a 100644 --- a/src/home/CallList.tsx +++ b/src/home/CallList.tsx @@ -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 = ({ name, avatarUrl, room }) => { +const CallTile: FC = ({ 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 = ( + <> + +
+ + {name} + +
+ + + + + ); + return (
- - -
- - {name} - -
-
- + {isLeaving ? ( + + {body} + + ) : ( + + {body} + + )}
); }; diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index 90843376..f7c16d4e 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -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 = ({ client }) => { {optInAnalytics === null && ( - + - + )} {error && ( diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 11645692..25bdd70d 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -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 = () => { /> {optInAnalytics === null && ( - + - + )} - + By clicking "Go", you agree to our{" "} - + End User Licensing Agreement (EULA) - + - + {error && ( @@ -234,19 +234,19 @@ export const UnauthenticatedView: FC = () => {
- - + + {t("unauthenticated_view_login_button")} - - + + Not registered yet?{" "} - + Create an account - +
{onFinished && ( diff --git a/src/index.css b/src/index.css index b416dcca..02f335f9 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/input/StarRating.test.tsx b/src/input/StarRating.test.tsx new file mode 100644 index 00000000..f15bb107 --- /dev/null +++ b/src/input/StarRating.test.tsx @@ -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( + , + ); + 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); +}); diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index ebd34ca7..3d85b165 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -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, }; diff --git a/src/room/CallEndedView.tsx b/src/room/CallEndedView.tsx index 296bf2fb..556dc6e5 100644 --- a/src/room/CallEndedView.tsx +++ b/src/room/CallEndedView.tsx @@ -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 = ({ return ( <>
- + You were disconnected from the call - +
{!confineToRoom && ( - + {t("return_home_button")} - + )} ); @@ -164,7 +163,7 @@ export const CallEndedView: FC = ({ return ( <>
- + {surveySubmitted ? t("call_ended_view.headline", { displayName, @@ -174,16 +173,16 @@ export const CallEndedView: FC = ({ }) + "\n" + t("call_ended_view.survey_prompt")} - + {(!surveySubmitted || confineToRoom) && PosthogAnalytics.instance.isEnabled() ? qualitySurveyDialog : createAccountDialog}
{!confineToRoom && ( - + {t("call_ended_view.not_now_button")} - + )} ); diff --git a/src/room/EncryptionLock.tsx b/src/room/EncryptionLock.tsx index 55f116f9..74706be1 100644 --- a/src/room/EncryptionLock.tsx +++ b/src/room/EncryptionLock.tsx @@ -31,7 +31,6 @@ export const EncryptionLock: FC = ({ encrypted }) => { height={16} className={styles.lock} data-encrypted={encrypted} - aria-hidden /> ); diff --git a/src/room/GroupCallLoader.tsx b/src/room/GroupCallLoader.tsx index 36bdcc4c..f843f3f4 100644 --- a/src/room/GroupCallLoader.tsx +++ b/src/room/GroupCallLoader.tsx @@ -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({ {t("group_call_loader.failed_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. */} - - {t("common.home")} - + {t("common.home")} ); } else if (groupCallState.error instanceof CallTerminatedMessage) { @@ -79,9 +67,7 @@ export function GroupCallLoader({ "{groupCallState.error.reason}" )} - - {t("common.home")} - + {t("common.home")} ); } else { diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 0c6cde81..ffbc0ab2 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -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 = ({ 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 = ({ ); 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 = ({ {t("browser_media_e2ee_unsupported_heading")} {t("browser_media_e2ee_unsupported")} - - {t("common.home")} - + {t("common.home")} ); } diff --git a/src/room/InviteModal.test.tsx b/src/room/InviteModal.test.tsx new file mode 100644 index 00000000..45d903b0 --- /dev/null +++ b/src/room/InviteModal.test.tsx @@ -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( + , + { wrapper: BrowserRouter }, + ); + + expect(await axe(container)).toHaveNoViolations(); + await user.click(screen.getByRole("button", { name: "action.copy_link" })); + expect(onDismiss).toBeCalled(); +}); diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index 80723f01..261be59e 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -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 diff --git a/src/room/RageshakeRequestModal.tsx b/src/room/RageshakeRequestModal.tsx index 03b0c1c0..d22b0bea 100644 --- a/src/room/RageshakeRequestModal.tsx +++ b/src/room/RageshakeRequestModal.tsx @@ -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 { rageshakeRequestId: string; @@ -40,7 +39,7 @@ export const RageshakeRequestModal: FC = ({ open={open} onDismiss={onDismiss} > - {t("rageshake_request_modal.body")} + {t("rageshake_request_modal.body")}
- + Not registered yet?{" "} - + Create an account - +
); diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 8b758726..6e07aa52 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -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 +): ReturnType { + 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) diff --git a/src/rtcSessionHelper.test.ts b/src/rtcSessionHelper.test.ts index 0598f250..355d0e7a 100644 --- a/src/rtcSessionHelper.test.ts +++ b/src/rtcSessionHelper.test.ts @@ -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: { diff --git a/src/settings/FeedbackSettingsTab.tsx b/src/settings/FeedbackSettingsTab.tsx index afefe07e..455995a1 100644 --- a/src/settings/FeedbackSettingsTab.tsx +++ b/src/settings/FeedbackSettingsTab.tsx @@ -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 = ({ roomId }) => { return (

{t("settings.feedback_tab_h4")}

- {t("settings.feedback_tab_body")} + {t("settings.feedback_tab_body")}
= ({ roomId }) => { )} {error && } - {sent && {t("settings.feedback_tab_thank_you")}} + {sent && {t("settings.feedback_tab_thank_you")}}
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 3dfbae65..c4ba24d1 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -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 = ({ }; const optInDescription = ( - +
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.
- +
); const devices = useMediaDevices(); diff --git a/src/settings/rageshake.ts b/src/settings/rageshake.ts index ffd9f333..1ac9fba1 100644 --- a/src/settings/rageshake.ts +++ b/src/settings/rageshake.ts @@ -478,21 +478,22 @@ export async function init(): Promise { // 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); } }; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts new file mode 100644 index 00000000..89f95b92 --- /dev/null +++ b/src/state/CallViewModel.test.ts @@ -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, + connectionState: Observable, + 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 + > as ComponentsCore.ParticipantMedia, + }), + ); + const eventsSpy = vi + .spyOn(ComponentsCore, "observeParticipantEvents") + .mockImplementation((p) => cold("a", { a: p })); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial 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`], + }, + }, + ); + }, + ); + }); +}); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 37c42d1c..c40d5009 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -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 = - 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((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((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) ?? diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts index 6239e10b..5b5e59a7 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/MediaViewModel.test.ts @@ -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, - 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 }); diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index be361759..f58c165c 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -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 = this._locallyMuted; + private readonly locallyMutedToggle = new Subject(); + private readonly localVolumeAdjustment = new Subject(); + private readonly localVolumeCommit = new Subject(); - 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 = this._localVolume; + public readonly localVolume: Observable = 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 = 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(); } } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx new file mode 100644 index 00000000..4d518df4 --- /dev/null +++ b/src/tile/GridTile.test.tsx @@ -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 as RemoteTrackPublication, + }, + async (vm) => { + const { container } = render( + {}} + targetWidth={300} + targetHeight={200} + showVideo + showSpeakingIndicators + />, + ); + expect(await axe(container)).toHaveNoViolations(); + // Name should be visible + screen.getByText("Alice"); + }, + ); +}); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index a583f658..c68e4762 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -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} /> diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 18f504f0..5bfa205b 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -122,7 +122,6 @@ export const MediaView = forwardRef( width={20} height={20} className={styles.errorIcon} - aria-hidden /> )} diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx new file mode 100644 index 00000000..a0fbed45 --- /dev/null +++ b/src/tile/SpotlightTile.test.tsx @@ -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( + , + ); + + 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(); + }, + ); + }, + ); +}); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 9e25f1a1..342ea544 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -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( - ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { + ( + { + vm, + targetWidth, + targetHeight, + intersectionObserver, + snap, + "aria-hidden": ariaHidden, + }, + theirRef, + ) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const displayName = useDisplayName(vm); @@ -162,6 +174,7 @@ const SpotlightItem = forwardRef( encryptionKeyMissing, encryptionKeyInvalid, participantId, + "aria-hidden": ariaHidden, }; return vm instanceof ScreenShareViewModel ? ( @@ -289,7 +302,12 @@ export const SpotlightTile = forwardRef( 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} /> ))}
@@ -297,9 +315,7 @@ export const SpotlightTile = forwardRef(