diff --git a/.eslintrc.cjs b/.eslintrc.cjs index cada6b46..98e6e4c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,8 +28,6 @@ module.exports = { rules: { "matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER], "jsx-a11y/media-has-caption": "off", - // We should use the js-sdk logger, never console directly. - "no-console": ["error"], "react/display-name": "error", // Encourage proper usage of Promises: "@typescript-eslint/no-floating-promises": "error", @@ -44,8 +42,17 @@ module.exports = { ], // To encourage good usage of RxJS: "rxjs/no-exposed-subjects": "error", - "rxjs/finnish": "error", + "rxjs/finnish": ["error", { names: { "^this$": false } }], }, + overrides: [ + { + files: ["src/*/**"], + rules: { + // In application code we should use the js-sdk logger, never console directly. + "no-console": ["error"], + }, + }, + ], settings: { react: { version: "detect", diff --git a/.github/workflows/blocked.yaml b/.github/workflows/blocked.yaml index e016e707..f3c99b3e 100644 --- a/.github/workflows/blocked.yaml +++ b/.github/workflows/blocked.yaml @@ -1,7 +1,7 @@ name: Prevent blocked on: pull_request_target: - types: [opened, labeled, unlabeled] + types: [opened, labeled, unlabeled, synchronize] jobs: prevent-blocked: name: Prevent blocked diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index fc5eee02..49542e5d 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -9,6 +9,11 @@ on: type: string # This would ideally be a `choice` type, but that isn't supported yet description: The package type to be built. Must be one of 'full' or 'embedded' required: true + build_mode: + type: string # This would ideally be a `choice` type, but that isn't supported yet + description: The build mode for vite. Must be either 'development' or 'production' + required: false + default: production secrets: SENTRY_ORG: required: true @@ -37,20 +42,8 @@ jobs: node-version-file: ".node-version" - name: Install dependencies run: "yarn install --immutable" - - name: Build full version - if: ${{ inputs.package == 'full' }} - run: "yarn run build:full" - env: - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - SENTRY_URL: ${{ secrets.SENTRY_URL }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - VITE_APP_VERSION: ${{ inputs.vite_app_version }} - NODE_OPTIONS: "--max-old-space-size=4096" - - name: Build embedded - if: ${{ inputs.package == 'embedded' }} - run: "yarn run build:embedded" + - name: Build Element Call + run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }} env: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 47f956c7..6aa5fae6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -14,6 +14,7 @@ jobs: with: package: full vite_app_version: ${{ github.event.release.tag_name || github.sha }} + build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }} secrets: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} @@ -61,6 +62,7 @@ jobs: with: package: embedded vite_app_version: ${{ github.event.release.tag_name || github.sha }} + build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }} secrets: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/changelog-label.yml b/.github/workflows/changelog-label.yml index d8a82832..8d9acbc2 100644 --- a/.github/workflows/changelog-label.yml +++ b/.github/workflows/changelog-label.yml @@ -9,6 +9,6 @@ jobs: steps: - uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2 with: - REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies" + REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies,PR-Breaking-Change" REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label" BANNED_LABELS: "banned" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2ef4dc04..f532cda6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,7 +30,7 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests - timeout-minutes: 10 + timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -46,11 +46,12 @@ jobs: run: yarn playwright install --with-deps - name: Run backend components run: | - docker compose -f playwright-backend-docker-compose.yml up -d + docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull + docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d docker ps - - name: Copy config file - run: cp config/config.devenv.json public/config.json - name: Run Playwright tests + env: + USE_DOCKER: 1 run: yarn playwright test - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ !cancelled() }} diff --git a/.gitignore b/.gitignore index d15cee0b..3e9016a6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist-ssr public/config.json backend/synapse_tmp/* /coverage +config.json # Yarn yarn-error.log diff --git a/Dockerfile b/Dockerfile index c919d0c9..bf34c6c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY ./dist /dist WORKDIR /dist/assets RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json -FROM nginxinc/nginx-unprivileged:alpine +FROM nginxinc/nginx-unprivileged:alpine-slim COPY --from=builder ./dist /app diff --git a/README.md b/README.md index 510b7c76..8ca7fa96 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ via the `org.matrix.msc4143.rtc_foci` key, e.g.: where the format for MatrixRTC using LiveKit backend is defined in [MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md). In the example above Matrix clients do discover a focus of type `livekit` which -points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`. +points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) +via `livekit_service_url`. ### Backend Selection @@ -154,9 +155,9 @@ points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`. the **first participant who joined the call** defines via the `foci_preferred` key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend will be used for this call. -- During the actual call join flow, the **LiveKit JWT Auth Service** provides - the client with the **LiveKit SFU WebSocket URL** and an **access JWT token** - in order to exchange media via WebRTC. +- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)** + provides the client with the **LiveKit SFU WebSocket URL** and an + **access JWT token** in order to exchange media via WebRTC. The example below illustrates how backend selection works across **Matrix federation**, using the setup from sites A, B, and C. It demonstrates backend @@ -208,7 +209,7 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the whole stack of components which is required for a local development environment: - Minimum Synapse Setup (servername: `synapse.m.localhost`) -- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy) +- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy) - Minimum LiveKit SFU Setup using dev defaults for config - Redis db for completeness - Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS) diff --git a/backend/dev_livekit.yaml b/backend/dev_livekit.yaml index 0e0c5c7b..f0c5b3a4 100644 --- a/backend/dev_livekit.yaml +++ b/backend/dev_livekit.yaml @@ -21,3 +21,5 @@ turn: external_tls: true keys: devkey: secret +room: + auto_create: false diff --git a/backend/dev_nginx.conf b/backend/dev_nginx.conf index 44fef8a5..a29b06d7 100644 --- a/backend/dev_nginx.conf +++ b/backend/dev_nginx.conf @@ -43,6 +43,11 @@ server { # MatrixRTC reverse proxy # - MatrixRTC Authorization Service # - LiveKit SFU websocket signaling connection +upstream jwt-auth-services { + server auth-server:6080; + server host.docker.internal:6080; +} + server { listen 80; listen [::]:80; @@ -62,8 +67,9 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # JWT Service running at port 8080 - proxy_pass http://auth-server:8080/; + # JWT Service running at port 6080 + proxy_pass http://jwt-auth-services/; + } location ^~ /livekit/sfu/ { diff --git a/config/config.devenv.json b/config/config.devenv.json index 59608d13..df0ff4c1 100644 --- a/config/config.devenv.json +++ b/config/config.devenv.json @@ -8,5 +8,12 @@ "features": { "feature_use_device_session_member_events": true }, - "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf" + "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_session": { + "wait_for_key_rotation_ms": 3000, + "membership_event_expiry_ms": 180000000, + "delayed_leave_event_delay_ms": 18000, + "delayed_leave_event_restart_ms": 4000, + "network_error_retry_ms": 100 + } } diff --git a/config/config.sample.json b/config/config.sample.json index 18c5d07a..126d7626 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -11,5 +11,12 @@ "features": { "feature_use_device_session_member_events": true }, - "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf" + "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_session": { + "wait_for_key_rotation_ms": 3000, + "membership_event_expiry_ms": 180000000, + "delayed_leave_event_delay_ms": 18000, + "delayed_leave_event_restart_ms": 4000, + "network_error_retry_ms": 100 + } } diff --git a/config/config_netlify_preview.json b/config/config_netlify_preview.json index dde58267..cf0148e9 100644 --- a/config/config_netlify_preview.json +++ b/config/config_netlify_preview.json @@ -5,6 +5,14 @@ "server_name": "call-unstable.ems.host" } }, + "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_session": { + "wait_for_key_rotation_ms": 3000, + "membership_event_expiry_ms": 180000000, + "delayed_leave_event_delay_ms": 18000, + "delayed_leave_event_restart_ms": 4000, + "network_error_retry_ms": 100 + }, "posthog": { "api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU", "api_host": "https://posthog-element-call.element.io" diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index da3c3530..eb11b630 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -4,26 +4,29 @@ networks: services: auth-service: image: ghcr.io/element-hq/lk-jwt-service:latest-ci + pull_policy: always hostname: auth-server environment: - - LIVEKIT_JWT_PORT=8080 + - LIVEKIT_JWT_PORT=6080 - LIVEKIT_URL=wss://matrix-rtc.m.localhost/livekit/sfu - 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 + - LIVEKIT_FULL_ACCESS_HOMESERVERS=* deploy: restart_policy: condition: on-failure ports: # HOST_PORT:CONTAINER_PORT - - 8080:8080 + - 6080:6080 networks: - ecbackend livekit: image: livekit/livekit-server:latest + pull_policy: always hostname: livekit-sfu command: --dev --config /etc/livekit.yaml restart: unless-stopped @@ -43,6 +46,7 @@ services: redis: image: redis:6-alpine + pull_policy: always command: redis-server /etc/redis.conf ports: # HOST_PORT:CONTAINER_PORT @@ -55,6 +59,7 @@ services: synapse: hostname: homeserver image: docker.io/matrixdotorg/synapse:latest + pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml # Needed for rootless podman-compose such that the uid/gid mapping does @@ -85,6 +90,7 @@ services: # see backend/dev_tls_setup for how to generate the tls certs hostname: synapse.m.localhost image: nginx:latest + pull_policy: always volumes: - ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z - ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z @@ -100,4 +106,6 @@ services: depends_on: - synapse networks: - - ecbackend + ecbackend: + aliases: + - matrix-rtc.m.localhost diff --git a/docs/Federated_Setup.drawio.png b/docs/Federated_Setup.drawio.png index 6f0a4967..9813df37 100644 Binary files a/docs/Federated_Setup.drawio.png and b/docs/Federated_Setup.drawio.png differ diff --git a/docs/MSC4195_setup.drawio.png b/docs/MSC4195_setup.drawio.png index 18566448..80e47fe2 100644 Binary files a/docs/MSC4195_setup.drawio.png and b/docs/MSC4195_setup.drawio.png differ diff --git a/docs/controls.md b/docs/controls.md index bb457237..e5e0746d 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -17,5 +17,15 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting - `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output. - `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example. - `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default. +- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start + playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready. + In particular android is using it to setup the output channel so that the call volume can + be controlled by the hardware volume rocker. + +## Element Call button delegation + +Callbacks for buttons in EC that are handled by the native application + - `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu. - This button is only shown on iOS. (`userAgent.includes("iPhone")`) + This button is only shown on iOS. (`/iPad|iPhone|iPod|Mac/.test(navigator.userAgent)`) +- `onBackButtonPressed: (() => void) | undefined`. Callback when the webview detects a tab on the header's back button. diff --git a/docs/element_call_standalone.drawio.png b/docs/element_call_standalone.drawio.png index 1e105ef4..ab541fbd 100644 Binary files a/docs/element_call_standalone.drawio.png and b/docs/element_call_standalone.drawio.png differ diff --git a/docs/element_call_widget.drawio.png b/docs/element_call_widget.drawio.png index 72a4e1de..61a36e42 100644 Binary files a/docs/element_call_widget.drawio.png and b/docs/element_call_widget.drawio.png differ diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 85ace615..051bb31c 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -64,7 +64,7 @@ rc_delayed_event_mgmt: ``` As a prerequisite for the -[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service) +[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) make sure that your Synapse server has either a `federation` or `openid` [listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners). @@ -77,7 +77,7 @@ required for each site deployment. As depicted above in the `example.com` site deployment, Element Call requires a [Livekit SFU](https://github.com/livekit/livekit) alongside a -[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) +[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) to implement [MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md). @@ -89,7 +89,7 @@ the example above, this results in: | Service | Endpoint | Example | | -------- | ------- | ------- | | [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` | -| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` | +| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` | Using Nginx, you can achieve this by: @@ -102,7 +102,7 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # JWT Service running at port 8080 + # MatrixRTC Authorization Service running at port 8080 proxy_pass http://localhost:8080/; } @@ -126,6 +126,32 @@ server { } ``` +Or Using Caddy, you can achieve this by: + +```caddy configuration file +# Route for lk-jwt-service with livekit/jwt prefix +@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz +handle @jwt_service { + uri strip_prefix /livekit/jwt + reverse_proxy http://[::1]:8080 { + header_up Host {host} + header_up X-Forwarded-Server {host} + header_up X-Real-IP {remote_addr} + header_up X-Forwarded-For {remote_addr} + } +} + +# Default route for livekit +handle { + reverse_proxy http://localhost:7880 { + header_up Host {host} + header_up X-Forwarded-Server {host} + header_up X-Real-IP {remote_addr} + header_up X-Forwarded-For {remote_addr} + } +} +``` + #### MatrixRTC backend announcement > [!IMPORTANT] @@ -145,10 +171,6 @@ server { { "type": "livekit", "livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt" - }, - { - "type": "nextgen_new_foci_type", - "props_for_nextgen_foci": "val" } ] ``` @@ -218,7 +240,7 @@ server { There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found -in [ConfigOptions.ts](src/config/ConfigOptions.ts). +in [ConfigOptions.ts](../src/config/ConfigOptions.ts). > [!CAUTION] > Please note configuring MatrixRTC backend via `config.json` of @@ -255,13 +277,17 @@ self-hosters and developers working with Element Call. - [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/) -## 🛠️ How-Tos & Tutorials +## 📝 How-Tos & Tutorials - [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/) - [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/) - [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/) - [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/) +## 🛠️ Tools + +- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix) + ## 🤝 Want to Contribute? Have a guide or blog post you'd like to share? Open a diff --git a/docs/url-params.md b/docs/url-params.md index 27f8f579..b2af8416 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -48,6 +48,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | Name | Values | Required for widget | Required for SPA | Description | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | | `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | | `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | | `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | @@ -56,10 +57,9 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | | `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | | `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | -| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. | +| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | | `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | | `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | -| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. | | `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | | `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | | `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | @@ -69,6 +69,9 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | +| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | +| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | +| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | ### Widget-only parameters diff --git a/embedded/android/gradle/libs.versions.toml b/embedded/android/gradle/libs.versions.toml index 2d9bfa40..9982f14d 100644 --- a/embedded/android/gradle/libs.versions.toml +++ b/embedded/android/gradle/libs.versions.toml @@ -2,11 +2,11 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] -android_gradle_plugin = "8.10.0" +android_gradle_plugin = "8.11.1" [libraries] android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } [plugins] android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } -maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" } \ No newline at end of file +maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } \ No newline at end of file diff --git a/embedded/android/gradle/wrapper/gradle-wrapper.properties b/embedded/android/gradle/wrapper/gradle-wrapper.properties index 6514f919..7705927e 100644 --- a/embedded/android/gradle/wrapper/gradle-wrapper.properties +++ b/embedded/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/embedded/android/lib/build.gradle.kts b/embedded/android/lib/build.gradle.kts index 59039206..0a1863fd 100644 --- a/embedded/android/lib/build.gradle.kts +++ b/embedded/android/lib/build.gradle.kts @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -import com.vanniktech.maven.publish.SonatypeHost - plugins { alias(libs.plugins.android.library) alias(libs.plugins.maven.publish) @@ -27,7 +25,7 @@ android { } mavenPublishing { - publishToMavenCentral(SonatypeHost.S01, automaticRelease = true) + publishToMavenCentral(automaticRelease = true) signAllPublications() diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index 3acf2b5e..10d748a8 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -1,4 +1,6 @@ -export default { +import type { UserConfig } from "i18next-parser"; + +const config: UserConfig = { keySeparator: ".", namespaceSeparator: false, contextSeparator: "|", @@ -26,3 +28,5 @@ export default { input: ["src/**/*.{ts,tsx}"], sort: true, }; + +export default config; diff --git a/knip.ts b/knip.ts index 2381356c..6b378e29 100644 --- a/knip.ts +++ b/knip.ts @@ -1,8 +1,15 @@ -import { KnipConfig } from "knip"; +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type KnipConfig } from "knip"; export default { vite: { - config: ["vite.config.js", "vite-embedded.config.js"], + config: ["vite.config.ts", "vite-embedded.config.ts"], }, entry: ["src/main.tsx", "i18next-parser.config.ts"], ignoreBinaries: [ diff --git a/locales/cs/app.json b/locales/cs/app.json index 81f1ef9a..f307bf6b 100644 --- a/locales/cs/app.json +++ b/locales/cs/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách", "crypto_version": "Kryptografická verze: {{version}}", "debug_tile_layout_label": "Ladění rozložení dlaždic", "device_id": "ID zařízení: {{id}}", @@ -81,7 +82,7 @@ "error": { "call_is_not_supported": "Volání není podporováno", "call_not_found": "Volání nebylo nalezeno", - "call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový.", + "call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu hovoru. Zkontrolujte, zda máte správný odkaz, nebo<2> vytvořte nový.", "connection_lost": "Spojení ztraceno", "connection_lost_description": "Hovor byl přerušen.", "e2ee_unsupported": "Nekompatibilní prohlížeč", @@ -104,6 +105,11 @@ "knock_reject_heading": "Přístup odepřen", "reason": "Důvod" }, + "handset": { + "overlay_back_button": "Zpět do režimu reproduktoru", + "overlay_description": "Funguje pouze při používání aplikace", + "overlay_title": "Režim sluchátka" + }, "hangup_button_label": "Ukončit hovor", "header_label": "Domov Element Call", "header_participants_label": "Účastníci", @@ -173,8 +179,11 @@ "devices": { "camera": "Fotoaparát", "camera_numbered": "Fotoaparát {{n}}", + "change_device_button": "Změnit zvukové zařízení", "default": "Výchozí", "default_named": "Výchozí <2> ({{name}}) ", + "handset": "Sluchátko", + "loudspeaker": "Reproduktor", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon {{n}}", "speaker": "Reproduktor", diff --git a/locales/da/app.json b/locales/da/app.json index 4461e467..7708551f 100644 --- a/locales/da/app.json +++ b/locales/da/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme", "crypto_version": "Krypto-version: {{version}}", "debug_tile_layout_label": "Fejlfinding af fliselayout", "device_id": "Enheds-id: {{id}}", @@ -70,6 +71,7 @@ "livekit_server_info": "LiveKit Serverinfo", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix ID: {{id}}", + "mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)", "show_connection_stats": "Vis forbindelsesstatistik", "show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer", "url_params": "URL-parametre", @@ -80,7 +82,7 @@ "error": { "call_is_not_supported": "Opkald er ikke understøttet", "call_not_found": "Opkald ikke fundet", - "call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt.", + "call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller <2> opret et nyt.", "connection_lost": "Forbindelsen gik tabt", "connection_lost_description": "Du blev afbrudt fra opkaldet.", "e2ee_unsupported": "Inkompatibel browser", @@ -92,6 +94,8 @@ "matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).", "open_elsewhere": "Åbnet i en anden fane", "open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.", + "room_creation_restricted": "Kunne ikke oprette opkald", + "room_creation_restricted_description": "Oprettelse af opkald er muligvis begrænset til autoriserede brugere. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.", "unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode: <1> {{ errorCode }}). Kontakt venligst din serveradministrator." }, "group_call_loader": { @@ -103,6 +107,11 @@ "knock_reject_heading": "Adgang nægtet", "reason": "Årsag: {{reason}}" }, + "handset": { + "overlay_back_button": "Tilbage til højttalertilstand", + "overlay_description": "Virker kun, når du bruger appen", + "overlay_title": "Telefon-højtaler" + }, "hangup_button_label": "Afslut opkald", "header_label": "Element Ring hjem", "header_participants_label": "Deltagere", @@ -164,12 +173,18 @@ "effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.", "effect_volume_label": "Lydstyrke for lydeffekter" }, + "background_blur_header": "Baggrund", + "background_blur_label": "Gør videoens baggrund sløret", + "blur_not_supported_by_browser": "(Baggrundssløring understøttes ikke af denne enhed.)", "developer_tab_title": "Udvikler", "devices": { "camera": "Kamera", "camera_numbered": "Kamera {{n}}", + "change_device_button": "Skift lydenhed", "default": "Standard", "default_named": "Standard <2>({{name}})", + "handset": "Telefon", + "loudspeaker": "Højttaler", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon {{n}}", "speaker": "Højttaler", diff --git a/locales/de/app.json b/locales/de/app.json index 67aab44c..bb6328e7 100644 --- a/locales/de/app.json +++ b/locales/de/app.json @@ -55,6 +55,7 @@ "profile": "Profil", "reaction": "Reaktion", "reactions": "Reaktionen", + "reconnecting": "Verbindung wird wiederhergestellt...", "settings": "Einstellungen", "unencrypted": "Nicht verschlüsselt", "username": "Benutzername", @@ -82,7 +83,7 @@ "error": { "call_is_not_supported": "Anrufe werden nicht unterstützt", "call_not_found": "Anruf nicht gefunden", - "call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen. ", + "call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Es sollte geprüft werden, ob der Link korrekt ist, oder <2>ein neuer erstellt werden.", "connection_lost": "Verbindung verloren", "connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.", "e2ee_unsupported": "Inkompatibler Browser", @@ -94,6 +95,8 @@ "matrix_rtc_focus_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Serveradministrator kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).", "open_elsewhere": "In einem anderen Tab geöffnet", "open_elsewhere_description": "{{brand}} wurde in einem anderen Tab geöffnet. Wenn das nicht richtig klingt, versuchen Sie, die Seite neu zu laden.", + "room_creation_restricted": "Anruf konnte nicht erstellt werden", + "room_creation_restricted_description": "Das Erstellen von Anrufen ist nur für autorisierte Nutzer möglich. Versuche es später erneut oder kontaktiere deinen Serveradministrator, falls das Problem weiterhin besteht.", "unexpected_ec_error": "Ein unerwarteter Fehler ist aufgetreten (<0>Fehlercode: <1>{{ errorCode }}). Bitte den Serveradministrator kontaktieren." }, "group_call_loader": { @@ -105,6 +108,11 @@ "knock_reject_heading": "Zugriff verweigert", "reason": "Grund: {{reason}}" }, + "handset": { + "overlay_back_button": "Zurück zum Lautsprechermodus", + "overlay_description": "Nur wenn App im Vordergrund nutzbar", + "overlay_title": "Ohrhörer Modus" + }, "hangup_button_label": "Anruf beenden", "header_label": "Element Call-Startseite", "header_participants_label": "Teilnehmende", @@ -173,9 +181,11 @@ "devices": { "camera": "Kamera", "camera_numbered": "Kamera {{n}}", + "change_device_button": "Audiogerät wechseln", "default": "Standard", "default_named": "Standard<2> ({{name}} )", - "earpiece": "Ohrhörer", + "handset": "Ohrhörer", + "loudspeaker": "Lautsprecher", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon{{n}}", "speaker": "Lautsprecher", diff --git a/locales/en/app.json b/locales/en/app.json index e8a86fcc..007e372a 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -55,6 +55,7 @@ "profile": "Profile", "reaction": "Reaction", "reactions": "Reactions", + "reconnecting": "Reconnecting…", "settings": "Settings", "unencrypted": "Not encrypted", "username": "Username", @@ -82,7 +83,7 @@ "error": { "call_is_not_supported": "Call is not supported", "call_not_found": "Call not found", - "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one.", + "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one.", "connection_lost": "Connection lost", "connection_lost_description": "You were disconnected from the call.", "e2ee_unsupported": "Incompatible browser", @@ -94,6 +95,8 @@ "matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "open_elsewhere": "Opened in another tab", "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", + "room_creation_restricted": "Failed to create call", + "room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.", "unexpected_ec_error": "An unexpected error occurred (<0>Error Code: <1>{{ errorCode }}). Please contact your server admin." }, "group_call_loader": { @@ -105,6 +108,11 @@ "knock_reject_heading": "Access denied", "reason": "Reason: {{reason}}" }, + "handset": { + "overlay_back_button": "Back to Speaker Mode", + "overlay_description": "Only works while using app", + "overlay_title": "Handset Mode" + }, "hangup_button_label": "End call", "header_label": "Element Call Home", "header_participants_label": "Participants", @@ -176,7 +184,8 @@ "change_device_button": "Change audio device", "default": "Default", "default_named": "Default <2>({{name}})", - "earpiece": "Earpiece", + "handset": "Handset", + "loudspeaker": "Loudspeaker", "microphone": "Microphone", "microphone_numbered": "Microphone {{n}}", "speaker": "Speaker", diff --git a/locales/et/app.json b/locales/et/app.json index ed32a6fc..e269e53f 100644 --- a/locales/et/app.json +++ b/locales/et/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel", "crypto_version": "Krüptoteekide versioon: {{version}}", "debug_tile_layout_label": "Meediapaanide paigutus", "device_id": "Seadme tunnus: {{id}}", @@ -81,7 +82,7 @@ "error": { "call_is_not_supported": "Kõne pole toetatud", "call_not_found": "Kõnet ei leidu", - "call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus.", + "call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus.", "connection_lost": "Ühendus on katkenud", "connection_lost_description": "Sinu ühendus selle kõnega on katkenud.", "e2ee_unsupported": "Mitteühilduv brauser", @@ -93,6 +94,8 @@ "matrix_rtc_focus_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).", "open_elsewhere": "Avatud teisel vahekaardil", "open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.", + "room_creation_restricted": "Kõne loomine ei õnnestunud", + "room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.", "unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood: <1>{{ errorCode }}). Palun võta ühendust serveri haldajaga." }, "group_call_loader": { @@ -104,6 +107,10 @@ "knock_reject_heading": "Liitumine pole lubatud", "reason": "Põhjus" }, + "handset": { + "overlay_back_button": "Tagasi esineja vaatesse", + "overlay_description": "See toimib vaid rakenduse kasutamise ajal" + }, "hangup_button_label": "Lõpeta kõne", "header_label": "Avaleht: Element Call", "header_participants_label": "Osalejad", @@ -172,8 +179,10 @@ "devices": { "camera": "Kaamera", "camera_numbered": "Kaamera {{n}}", + "change_device_button": "Muuda heliseadet", "default": "Vaikimisi", "default_named": "Vaikimisi <2>({{name}})", + "loudspeaker": "Valjuhääldi", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon {{n}}", "speaker": "Kõlar", diff --git a/locales/ru/app.json b/locales/ru/app.json index b6ec8a6a..99b8775a 100644 --- a/locales/ru/app.json +++ b/locales/ru/app.json @@ -61,6 +61,7 @@ "video": "Видео" }, "developer_mode": { + "always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах", "crypto_version": "Версия криптографии: {{version}}", "debug_tile_layout_label": "Отладка расположения плиток", "device_id": "Идентификатор устройства: {{id}}", @@ -70,6 +71,7 @@ "livekit_server_info": "Информация о сервере LiveKit", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix ID: {{id}}", + "mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)", "show_connection_stats": "Показать статистику подключений", "show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками", "url_params": "Параметры URL-адреса", @@ -172,6 +174,7 @@ "devices": { "camera": "Камера", "camera_numbered": "Камера {{n}}", + "change_device_button": "Изменить аудиоустройство", "default": "По умолчанию", "default_named": "По умолчанию <2>({{name}})", "microphone": "Микрофон", diff --git a/locales/sk/app.json b/locales/sk/app.json index 20e41408..d017220b 100644 --- a/locales/sk/app.json +++ b/locales/sk/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách", "crypto_version": "Krypto verzia: {{version}}", "debug_tile_layout_label": "Ladenie rozloženia dlaždíc", "device_id": "ID zariadenia: {{id}}", @@ -81,7 +82,7 @@ "error": { "call_is_not_supported": "Hovor nie je podporovaný", "call_not_found": "Hovor nebol nájdený", - "call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový. ", + "call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový.", "connection_lost": "Strata spojenia", "connection_lost_description": "Boli ste odpojení od hovoru.", "e2ee_unsupported": "Nekompatibilný prehliadač", @@ -173,8 +174,10 @@ "devices": { "camera": "Kamera", "camera_numbered": "Kamera {{n}}", + "change_device_button": "Zmeniť zvukové zariadenie", "default": "Predvolené", "default_named": "Predvolené <2>({{name}})", + "loudspeaker": "Reproduktor", "microphone": "Mikrofón", "microphone_numbered": "Mikrofón {{n}}", "speaker": "Reproduktor", diff --git a/locales/sv/app.json b/locales/sv/app.json index 500c4ee7..bf0b742f 100644 --- a/locales/sv/app.json +++ b/locales/sv/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar", "crypto_version": "Kryptoversion: {{version}}", "debug_tile_layout_label": "Felsök panelarrangemang", "device_id": "Enhets-ID: {{id}}", @@ -70,6 +71,7 @@ "livekit_server_info": "LiveKit-serverinfo", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix-ID: {{id}}", + "mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)", "show_connection_stats": "Visa anslutningsstatistik", "show_non_member_tiles": "Visa paneler för media som inte är medlemmar", "url_params": "URL-parametrar", @@ -80,7 +82,7 @@ "error": { "call_is_not_supported": "Call stöds inte", "call_not_found": "Samtal hittades inte", - "call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <1>skapa en ny.", + "call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <2>skapa en ny.", "connection_lost": "Anslutning förlorad", "connection_lost_description": "Du kopplades bort från samtalet.", "e2ee_unsupported": "Inkompatibel webbläsare", @@ -92,6 +94,8 @@ "matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).", "open_elsewhere": "Öppnades i en annan flik", "open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.", + "room_creation_restricted": "Misslyckades att skapa samtal", + "room_creation_restricted_description": "Samtalsskapande kan vara begränsat till endast behöriga användare. Försök igen senare eller kontakta serveradministratören om problemet kvarstår.", "unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod: <1>{{ errorCode }}). Kontakta din serveradministratör." }, "group_call_loader": { @@ -103,6 +107,11 @@ "knock_reject_heading": "Inte tillåten att gå med", "reason": "Anledning" }, + "handset": { + "overlay_back_button": "Tillbaka till högtalarläge", + "overlay_description": "Fungerar bara när appen används", + "overlay_title": "Telefonläge" + }, "hangup_button_label": "Avsluta samtal", "header_label": "Element Call Hem", "header_participants_label": "Deltagare", @@ -164,12 +173,18 @@ "effect_volume_description": "Justera volymen vid vilken reaktioner och handuppräckningseffekter spelas", "effect_volume_label": "Ljudeffektsvolym" }, + "background_blur_header": "Bakgrund", + "background_blur_label": "Gör bakgrunden i videon suddig", + "blur_not_supported_by_browser": "(Bakgrundssuddighet stöds inte av den här enheten.)", "developer_tab_title": "Utvecklare", "devices": { "camera": "Kamera", "camera_numbered": "Kamera {{n}}", + "change_device_button": "Byt ljudenhet", "default": "Förval", "default_named": "Förval <2>({{name}})", + "handset": "Telefonlur", + "loudspeaker": "Högtalare", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon {{n}}", "speaker": "Högtalare", diff --git a/package.json b/package.json index 76d63cb0..18877823 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "dev:embedded": "vite --config vite-embedded.config.js", "build": "yarn build:full", "build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build", + "build:full:production": "yarn build:full", + "build:full:development": "yarn build:full --mode development", "build:embedded": "yarn build:full --config vite-embedded.config.js", + "build:embedded:production": "yarn build:embedded", + "build:embedded:development": "yarn build:embedded --mode development", "serve": "vite preview", "prettier:check": "prettier -c .", "prettier:format": "prettier -w .", @@ -22,6 +26,7 @@ "test": "vitest", "test:coverage": "vitest --coverage", "backend": "docker-compose -f dev-backend-docker-compose.yml up", + "backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up", "test:playwright": "playwright test", "test:playwright:open": "yarn test:playwright --ui", "links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml", @@ -44,7 +49,7 @@ "@mediapipe/tasks-vision": "^0.10.18", "@opentelemetry/api": "^1.4.0", "@opentelemetry/core": "^2.0.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.201.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", @@ -53,7 +58,7 @@ "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-visually-hidden": "^1.0.3", - "@react-spring/web": "^9.4.4", + "@react-spring/web": "^10.0.0", "@sentry/react": "^8.0.0", "@sentry/vite-plugin": "^3.0.0", "@stylistic/eslint-plugin": "^3.0.0", @@ -69,19 +74,20 @@ "@types/node": "^22.0.0", "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@types/sdp-transform": "^2.4.5", "@types/uuid": "10", "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", "@use-gesture/react": "^10.2.11", - "@vector-im/compound-design-tokens": "^3.0.0", - "@vector-im/compound-web": "^7.2.0", + "@vector-im/compound-design-tokens": "^6.0.0", + "@vector-im/compound-web": "^8.0.0", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^3.0.0", "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", + "copy-to-clipboard": "^3.3.3", "eslint": "^8.14.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", @@ -112,11 +118,10 @@ "posthog-js": "1.160.3", "prettier": "^3.0.0", "qrcode": "^1.5.4", - "react": "18", - "react-dom": "18", + "react": "19", + "react-dom": "19", "react-i18next": "^15.0.0", "react-router-dom": "^7.0.0", - "react-use-clipboard": "^1.0.7", "react-use-measure": "^2.1.1", "rxjs": "^7.8.1", "sass": "^1.42.1", @@ -124,7 +129,7 @@ "typescript-eslint-language-service": "^5.0.5", "unique-names-generator": "^4.6.0", "vaul": "^1.0.0", - "vite": "^6.0.0", + "vite": "^7.0.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-svgr": "^4.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index cdb8ec23..7a8ee530 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details. import { defineConfig, devices } from "@playwright/test"; +const baseURL = process.env.USE_DOCKER + ? "http://localhost:8080" + : "https://localhost:3000"; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -25,7 +29,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "https://localhost:3000", + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -73,9 +77,13 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "yarn dev", - url: "https://localhost:3000", + command: "./scripts/playwright-webserver-command.sh", + url: baseURL, reuseExistingServer: !process.env.CI, ignoreHTTPSErrors: true, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 500, + }, }, }); diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts index 759cd2db..6f03272e 100644 --- a/playwright/create-call.spec.ts +++ b/playwright/create-call.spec.ts @@ -40,6 +40,11 @@ test("Start a new call then leave and show the feedback screen", async ({ // The tooltip with the name should be visible await expect(page.getByTestId("name_tag")).toContainText("John Doe"); + // Resize the window to resemble a small mobile phone + await page.setViewportSize({ width: 350, height: 660 }); + // We should still be able to send reactions at this screen size + await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible(); + // leave the call await page.getByTestId("incall_leave").click(); await expect(page.getByRole("heading")).toContainText( diff --git a/playwright/errors.spec.ts b/playwright/errors.spec.ts index 7671c103..851e448d 100644 --- a/playwright/errors.spec.ts +++ b/playwright/errors.spec.ts @@ -72,3 +72,56 @@ test("Should automatically retry non fatal JWT errors", async ({ await hasRetriedPromise; await expect(page.getByTestId("video").first()).toBeVisible(); }); + +test("Should show error screen if call creation is restricted", async ({ + page, +}) => { + await page.goto("/"); + + // We need the socket connection to fail, but this cannot be done by using the websocket route. + // Instead, we will trick the app by returning a bad URL for the SFU that will not be reachable an error out. + await page.route( + "**/matrix-rtc.m.localhost/livekit/jwt/sfu/get", + async (route) => + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + url: "wss://badurltotricktest/livekit/sfu", + jwt: "FAKE", + }), + }), + ); + + // Then if the socket connection fails, livekit will try to validate the token! + // Livekit will not auto_create anymore and will return a 404 error. + await page.route( + "**/badurltotricktest/livekit/sfu/rtc/validate?**", + async (route) => + await route.fulfill({ + status: 404, + contentType: "text/plain", + body: "requested room does not exist", + }), + ); + + await page.pause(); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + await page.pause(); + // Should fail + await expect(page.getByText("Failed to create call")).toBeVisible(); + await expect( + page.getByText( + /Call creation might be restricted to authorized users only/, + ), + ).toBeVisible(); +}); diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index d1412bd8..3ccb2ab2 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type Page, test, expect, type JSHandle } from "@playwright/test"; +import { + type Browser, + type Page, + test, + expect, + type JSHandle, +} from "@playwright/test"; import type { MatrixClient } from "matrix-js-sdk"; @@ -30,8 +36,8 @@ const PASSWORD = "foobarbaz1!"; const CONFIG_JSON = { default_server_config: { "m.homeserver": { - base_url: "http://synapse.localhost:8008", - server_name: "synapse.localhost", + base_url: "https://synapse.m.localhost", + server_name: "synapse.m.localhost", }, }, @@ -63,15 +69,72 @@ const CONFIG_JSON = { * Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`. * @param page */ -async function setDevToolElementCallDevUrl(page: Page): Promise { - await page.evaluate(() => { - window.mxSettingsStore.setValue( - "Developer.elementCallUrl", - null, - "device", - "https://localhost:3000/room", - ); +const setDevToolElementCallDevUrl = process.env.USE_DOCKER + ? async (page: Page): Promise => { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "http://localhost:8080/room", + ); + }); + } + : async (page: Page): Promise => { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "https://localhost:3000/room", + ); + }); + }; + +/** + * Registers a new user and returns page, clientHandle and mxId. + */ +async function registerUser( + browser: Browser, + username: string, +): Promise<{ page: Page; clientHandle: JSHandle; mxId: string }> { + const userContext = await browser.newContext({ + reducedMotion: "reduce", }); + const page = await userContext.newPage(); + await page.goto("http://localhost:8081/#/welcome"); + await page.getByRole("link", { name: "Create Account" }).click(); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await page.getByRole("textbox", { name: "Confirm password" }).click(); + await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD); + await page.getByRole("button", { name: "Register" }).click(); + const continueButton = page.getByRole("button", { name: "Continue" }); + try { + await expect(continueButton).toBeVisible({ timeout: 5000 }); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await continueButton.click(); + } catch { + // continueButton not visible, continue as normal + } + await expect( + page.getByRole("heading", { name: `Welcome ${username}` }), + ).toBeVisible(); + await setDevToolElementCallDevUrl(page); + + const clientHandle = await page.evaluateHandle(() => + window.mxMatrixClientPeg.get(), + ); + const mxId = (await clientHandle.evaluate( + (cli: MatrixClient) => cli.getUserId(), + clientHandle, + ))!; + + return { page, clientHandle, mxId }; } export const widgetTest = test.extend({ @@ -83,61 +146,17 @@ export const widgetTest = test.extend({ const userA = `brooks_${Date.now()}`; const userB = `whistler_${Date.now()}`; - const user1Context = await browser.newContext({ - reducedMotion: "reduce", - }); - const ewPage1 = await user1Context.newPage(); - // Register the first user - await ewPage1.goto("http://localhost:8081/#/welcome"); - await ewPage1.getByRole("link", { name: "Create Account" }).click(); - await ewPage1.getByRole("textbox", { name: "Username" }).fill(userA); - await ewPage1 - .getByRole("textbox", { name: "Password", exact: true }) - .fill(PASSWORD); - await ewPage1.getByRole("textbox", { name: "Confirm password" }).click(); - await ewPage1 - .getByRole("textbox", { name: "Confirm password" }) - .fill(PASSWORD); - await ewPage1.getByRole("button", { name: "Register" }).click(); - await expect( - ewPage1.getByRole("heading", { name: `Welcome ${userA}` }), - ).toBeVisible(); - await setDevToolElementCallDevUrl(ewPage1); - - const brooksClientHandle = await ewPage1.evaluateHandle(() => - window.mxMatrixClientPeg.get(), - ); - const brooksMxId = (await brooksClientHandle.evaluate((cli) => { - return cli.getUserId(); - }, brooksClientHandle))!; - - const user2Context = await browser.newContext({ - reducedMotion: "reduce", - }); - const ewPage2 = await user2Context.newPage(); - // Register the second user - await ewPage2.goto("http://localhost:8081/#/welcome"); - await ewPage2.getByRole("link", { name: "Create Account" }).click(); - await ewPage2.getByRole("textbox", { name: "Username" }).fill(userB); - await ewPage2 - .getByRole("textbox", { name: "Password", exact: true }) - .fill(PASSWORD); - await ewPage2.getByRole("textbox", { name: "Confirm password" }).click(); - await ewPage2 - .getByRole("textbox", { name: "Confirm password" }) - .fill(PASSWORD); - await ewPage2.getByRole("button", { name: "Register" }).click(); - await expect( - ewPage2.getByRole("heading", { name: `Welcome ${userB}` }), - ).toBeVisible(); - await setDevToolElementCallDevUrl(ewPage2); - - const whistlerClientHandle = await ewPage2.evaluateHandle(() => - window.mxMatrixClientPeg.get(), - ); - const whistlerMxId = (await whistlerClientHandle.evaluate((cli) => { - return cli.getUserId(); - }, whistlerClientHandle))!; + // Register users + const { + page: ewPage1, + clientHandle: brooksClientHandle, + mxId: brooksMxId, + } = await registerUser(browser, userA); + const { + page: ewPage2, + clientHandle: whistlerClientHandle, + mxId: whistlerMxId, + } = await registerUser(browser, userB); // Invite the second user await ewPage1.getByRole("button", { name: "Add room" }).click(); diff --git a/playwright/restricted-sfu.spec.ts b/playwright/restricted-sfu.spec.ts new file mode 100644 index 00000000..a9e07d38 --- /dev/null +++ b/playwright/restricted-sfu.spec.ts @@ -0,0 +1,75 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; +import { sleep } from "matrix-js-sdk/lib/utils.js"; + +test("Should request JWT token before starting the call", async ({ page }) => { + await page.goto("/"); + + let sfGetTimestamp = 0; + let sendStateEventTimestamp = 0; + await page.route( + "**/matrix-rtc.m.localhost/livekit/jwt/sfu/get", + async (route) => { + await sleep(2000); // Simulate very slow request + await route.continue(); + sfGetTimestamp = Date.now(); + }, + ); + + await page.route( + "**/state/org.matrix.msc3401.call.member/**", + async (route) => { + await route.continue(); + sendStateEventTimestamp = Date.now(); + }, + ); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + await page.waitForTimeout(4000); + // Ensure that the call is connected + await page + .locator("div") + .filter({ hasText: /^HelloCall$/ }) + .click(); + + expect(sfGetTimestamp).toBeGreaterThan(0); + expect(sendStateEventTimestamp).toBeGreaterThan(0); + expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp); +}); + +test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({ + page, +}) => { + await page.goto("/"); + + await page.route("**/openid/request_token", async (route) => { + await route.fulfill({ + status: 418, // Simulate an error not retryable + }); + }); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // Should fail + await expect(page.getByText("Something went wrong")).toBeVisible(); +}); diff --git a/playwright/sfu-reconnect-bug.spec.ts b/playwright/sfu-reconnect-bug.spec.ts new file mode 100644 index 00000000..6138eb78 --- /dev/null +++ b/playwright/sfu-reconnect-bug.spec.ts @@ -0,0 +1,104 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +test("When creator left, avoid reconnect to the same SFU", async ({ + browser, +}) => { + // Use reduce motion to disable animations that are making the tests a bit flaky + const creatorContext = await browser.newContext({ reducedMotion: "reduce" }); + const creatorPage = await creatorContext.newPage(); + + await creatorPage.goto("/"); + + // ======== + // ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link + // ======== + await creatorPage.getByTestId("home_callName").click(); + await creatorPage.getByTestId("home_callName").fill("Welcome"); + await creatorPage.getByTestId("home_displayName").click(); + await creatorPage.getByTestId("home_displayName").fill("Inviter"); + await creatorPage.getByTestId("home_go").click(); + await expect(creatorPage.locator("video")).toBeVisible(); + + // join + await creatorPage.getByTestId("lobby_joinCall").click(); + // Spotlight mode to make checking the test visually clearer + await creatorPage.getByRole("radio", { name: "Spotlight" }).check(); + + // Get the invite link + await creatorPage.getByRole("button", { name: "Invite" }).click(); + await expect( + creatorPage.getByRole("heading", { name: "Invite to this call" }), + ).toBeVisible(); + await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await creatorPage.getByTestId("modal_inviteLink").click(); + + const inviteLink = (await creatorPage.evaluate( + "navigator.clipboard.readText()", + )) as string; + expect(inviteLink).toContain("room/#/"); + + // ======== + // ACT: The other user use the invite link to join the call as a guest + // ======== + const guestB = await browser.newContext({ + reducedMotion: "reduce", + }); + const guestBPage = await guestB.newPage(); + + await guestBPage.goto(inviteLink); + await guestBPage.getByTestId("joincall_displayName").fill("Invitee"); + await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible(); + await guestBPage.getByTestId("joincall_joincall").click(); + await guestBPage.getByTestId("lobby_joinCall").click(); + await guestBPage.getByRole("radio", { name: "Spotlight" }).check(); + + // ======== + // ACT: add a third user to the call to reproduce the bug + // ======== + const guestC = await browser.newContext({ + reducedMotion: "reduce", + }); + const guestCPage = await guestC.newPage(); + let sfuGetCallCount = 0; + await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => { + sfuGetCallCount++; + await route.continue(); + }); + // Track WebSocket connections + let wsConnectionCount = 0; + await guestCPage.routeWebSocket("**", (ws) => { + // For some reason the interception is not working with the ** + if (ws.url().includes("livekit/sfu/rtc")) { + wsConnectionCount++; + } + ws.connectToServer(); + }); + + await guestCPage.goto(inviteLink); + await guestCPage.getByTestId("joincall_displayName").fill("Invitee"); + await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible(); + await guestCPage.getByTestId("joincall_joincall").click(); + await guestCPage.getByTestId("lobby_joinCall").click(); + await guestCPage.getByRole("radio", { name: "Spotlight" }).check(); + + await guestCPage.waitForTimeout(1000); + + // ======== + // the creator leaves the call + await creatorPage.getByTestId("incall_leave").click(); + + await guestCPage.waitForTimeout(2000); + // https://github.com/element-hq/element-call/issues/3344 + // The app used to request a new jwt token then to reconnect to the SFU + expect(wsConnectionCount).toBe(1); + expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */); +}); diff --git a/playwright/widget/simple-create.spec.ts b/playwright/widget/simple-create.spec.ts index 3712f5c4..00d5c658 100644 --- a/playwright/widget/simple-create.spec.ts +++ b/playwright/widget/simple-create.spec.ts @@ -9,11 +9,14 @@ import { expect, test } from "@playwright/test"; import { widgetTest } from "../fixtures/widget-user.ts"; +// Skip test, including Fixtures +widgetTest.skip( + ({ browserName }) => browserName === "firefox", + "This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget", +); + widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { - test.skip( - browserName === "firefox", - "This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget", - ); + test.slow(); // Triples the timeout const { brooks, whistler } = asWidget; diff --git a/renovate.json b/renovate.json index b737c0ed..612e6674 100644 --- a/renovate.json +++ b/renovate.json @@ -59,7 +59,7 @@ } ], "semanticCommits": "disabled", - "ignoreDeps": ["posthog-js"], + "ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"], "vulnerabilityAlerts": { "schedule": ["at any time"], "prHourlyLimit": 0, diff --git a/scripts/playwright-webserver-command.sh b/scripts/playwright-webserver-command.sh new file mode 100755 index 00000000..8c00909b --- /dev/null +++ b/scripts/playwright-webserver-command.sh @@ -0,0 +1,10 @@ +#!/bin/sh +if [ -n "$USE_DOCKER" ]; then + set -ex + yarn build + docker build -t element-call:testing . + exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing +else + cp config/config.devenv.json public/config.json + exec yarn dev +fi diff --git a/src/App.tsx b/src/App.tsx index 5dc8d29c..b87f587c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, Suspense, useEffect, useState } from "react"; +import { + type FC, + type JSX, + Suspense, + useEffect, + useMemo, + useState, +} from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -17,12 +24,14 @@ import { RegisterPage } from "./auth/RegisterPage"; import { RoomPage } from "./room/RoomPage"; import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; -import { DisconnectedBanner } from "./DisconnectedBanner"; import { Initializer } from "./initializer"; -import { MediaDevicesProvider } from "./livekit/MediaDevicesContext"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; +import { type AppViewModel } from "./state/AppViewModel"; +import { MediaDevicesContext } from "./MediaDevicesContext"; +import { getUrlParams, HeaderStyle } from "./UrlParams"; +import { AppBar } from "./AppBar"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -50,7 +59,11 @@ const ThemeProvider: FC = ({ children }) => { return children; }; -export const App: FC = () => { +interface Props { + vm: AppViewModel; +} + +export const App: FC = ({ vm }) => { const [loaded, setLoaded] = useState(false); useEffect(() => { Initializer.init() @@ -62,41 +75,42 @@ export const App: FC = () => { .catch(logger.error); }); + // Since we are outside the router component, we cannot use useUrlParams here + const { header } = useMemo(getUrlParams, []); + + const content = loaded ? ( + + + + } + > + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); + return ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - {loaded ? ( - - - - - ( - - )} - > - - - } /> - } /> - } - /> - } /> - - - - - - - ) : ( - - )} + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + diff --git a/src/AppBar.module.css b/src/AppBar.module.css new file mode 100644 index 00000000..d8954759 --- /dev/null +++ b/src/AppBar.module.css @@ -0,0 +1,23 @@ +.bar { + block-size: 64px; + flex-shrink: 0; +} + +.bar > header { + position: absolute; + inset-inline: 0; + inset-block-start: 0; + block-size: 64px; + z-index: var(--call-view-header-footer-layer); +} + +.bar svg path { + fill: var(--cpd-color-icon-primary); +} + +.bar > header > h1 { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/AppBar.test.tsx b/src/AppBar.test.tsx new file mode 100644 index 00000000..a2cce683 --- /dev/null +++ b/src/AppBar.test.tsx @@ -0,0 +1,25 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { AppBar } from "./AppBar"; + +describe("AppBar", () => { + it("renders", () => { + const { container } = render( + + +

This is the content.

+
+
, + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/AppBar.tsx b/src/AppBar.tsx new file mode 100644 index 00000000..aaa7565e --- /dev/null +++ b/src/AppBar.tsx @@ -0,0 +1,134 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + createContext, + type FC, + type MouseEvent, + type ReactNode, + use, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Heading, IconButton, Tooltip } from "@vector-im/compound-web"; +import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useTranslation } from "react-i18next"; + +import { Header, LeftNav, RightNav } from "./Header"; +import { platform } from "./Platform"; +import styles from "./AppBar.module.css"; + +interface AppBarContext { + setTitle: (value: string) => void; + setSecondaryButton: (value: ReactNode) => void; + setHidden: (value: boolean) => void; +} + +const AppBarContext = createContext(null); + +interface Props { + children: ReactNode; +} + +/** + * A "top app bar" featuring a back button, title and possibly a secondary + * button, similar to what you might see in mobile apps. + */ +export const AppBar: FC = ({ children }) => { + const { t } = useTranslation(); + const onBackClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + window.controls.onBackButtonPressed?.(); + }, []); + + const [title, setTitle] = useState(""); + const [hidden, setHidden] = useState(false); + const [secondaryButton, setSecondaryButton] = useState(null); + const context = useMemo( + () => ({ setTitle, setSecondaryButton, setHidden }), + [setTitle, setHidden, setSecondaryButton], + ); + + return ( + <> + + {children} + + ); +}; + +/** + * React hook which sets the title to be shown in the app bar, if present. It is + * an error to call this hook from multiple sites in the same component tree. + */ +export function useAppBarTitle(title: string): void { + const setTitle = use(AppBarContext)?.setTitle; + useEffect(() => { + if (setTitle !== undefined) { + setTitle(title); + return (): void => setTitle(""); + } + }, [title, setTitle]); +} + +/** + * React hook which sets the title to be shown in the app bar, if present. It is + * an error to call this hook from multiple sites in the same component tree. + */ +export function useAppBarHidden(hidden: boolean): void { + const setHidden = use(AppBarContext)?.setHidden; + useEffect(() => { + if (setHidden !== undefined) { + setHidden(hidden); + return (): void => setHidden(false); + } + }, [setHidden, hidden]); +} + +/** + * React hook which sets the secondary button to be shown in the app bar, if + * present. It is an error to call this hook from multiple sites in the same + * component tree. + */ +export function useAppBarSecondaryButton(button: ReactNode): void { + const setSecondaryButton = use(AppBarContext)?.setSecondaryButton; + useEffect(() => { + if (setSecondaryButton !== undefined) { + setSecondaryButton(button); + return (): void => setSecondaryButton(""); + } + }, [button, setSecondaryButton]); +} diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index bde20dc8..1488965a 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -11,7 +11,7 @@ import { useEffect, useState, createContext, - useContext, + use, useRef, useMemo, type JSX, @@ -69,8 +69,7 @@ const ClientContext = createContext(undefined); export const ClientContextProvider = ClientContext.Provider; -export const useClientState = (): ClientState | undefined => - useContext(ClientContext); +export const useClientState = (): ClientState | undefined => use(ClientContext); export function useClient(): { client?: MatrixClient; @@ -350,9 +349,7 @@ export const ClientProvider: FC = ({ children }) => { return ; } - return ( - {children} - ); + return {children}; }; export type InitResult = { diff --git a/src/ErrorView.module.css b/src/ErrorView.module.css index 14c5f141..bd68f5e3 100644 --- a/src/ErrorView.module.css +++ b/src/ErrorView.module.css @@ -12,6 +12,7 @@ .error > h1 { margin: 0; + text-align: center; } .error > p { diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index ed4103c5..1309ae04 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -99,7 +99,7 @@ export const ErrorView: FC = ({ return (
- + {title} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index e3c33480..41e6cb16 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -10,7 +10,7 @@ import classNames from "classnames"; import { useTranslation } from "react-i18next"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/lib/logger"; -import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import styles from "./FullScreenView.module.css"; @@ -28,10 +28,10 @@ export const FullScreenView: FC = ({ className, children, }) => { - const { hideHeader } = useUrlParams(); + const { header } = useUrlParams(); return (
- {!hideHeader && ( + {header === "standard" && (
@@ -67,7 +67,7 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { ) : ( { + ref?: Ref; children: ReactNode; className?: string; + /** + * Whether the header should display an informational banner whenever the + * client is disconnected from the homeserver. + * @default true + */ + disconnectedBanner?: boolean; } -export const Header = forwardRef( - ({ children, className, ...rest }, ref) => { - return ( +export const Header: FC = ({ + ref, + children, + className, + disconnectedBanner = true, + ...rest +}) => { + return ( + <>
( > {children}
- ); - }, -); + {disconnectedBanner && } + + ); +}; Header.displayName = "Header"; diff --git a/src/MediaDevicesContext.ts b/src/MediaDevicesContext.ts new file mode 100644 index 00000000..3cf54c2a --- /dev/null +++ b/src/MediaDevicesContext.ts @@ -0,0 +1,56 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use, useMemo } from "react"; +import { useObservableEagerState } from "observable-hooks"; + +import { type MediaDevices } from "./state/MediaDevices"; + +export const MediaDevicesContext = createContext( + undefined, +); + +export function useMediaDevices(): MediaDevices { + const mediaDevices = use(MediaDevicesContext); + if (mediaDevices === undefined) + throw new Error( + "useMediaDevices must be used within a MediaDevices context provider", + ); + return mediaDevices; +} + +export const useIsEarpiece = (): boolean => { + const devices = useMediaDevices(); + const audioOutput = useObservableEagerState(devices.audioOutput.selected$); + const available = useObservableEagerState(devices.audioOutput.available$); + if (!audioOutput?.id) return false; + return available.get(audioOutput.id)?.type === "earpiece"; +}; + +/** + * A convenience hook to get the audio node configuration for the earpiece. + * It will check the `useAsEarpiece` of the `audioOutput` device and return + * the appropriate pan and volume values. + * + * @returns pan and volume values for the earpiece audio node configuration. + */ +export const useEarpieceAudioConfig = (): { + pan: number; + volume: number; +} => { + const devices = useMediaDevices(); + const audioOutput = useObservableEagerState(devices.audioOutput.selected$); + const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false; + return { + // We use only the right speaker (pan = 1) for the earpiece. + // This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone) + pan: useMemo(() => (isVirtualEarpiece ? 1 : 0), [isVirtualEarpiece]), + // We also do lower the volume by a factor of 10 to optimize for the usecase where + // a user is holding the phone to their ear. + volume: useMemo(() => (isVirtualEarpiece ? 0.1 : 1), [isVirtualEarpiece]), + }; +}; diff --git a/src/Toast.tsx b/src/Toast.tsx index ada5b29c..105572c8 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -45,6 +45,12 @@ interface Props { * A supporting icon to display within the toast. */ Icon?: ComponentType>; + /** + * Whether the toast should be portaled into the root of the document (rather + * than rendered in-place within the component tree). + * @default true + */ + portal?: boolean; } /** @@ -56,6 +62,7 @@ export const Toast: FC = ({ autoDismiss, children, Icon, + portal = true, }) => { const onOpenChange = useCallback( (open: boolean) => { @@ -71,29 +78,33 @@ export const Toast: FC = ({ } }, [open, autoDismiss, onDismiss]); + const content = ( + <> + + + + + + {children} + + + {Icon && } + + + + ); + return ( - - - - - - - {children} - - - {Icon && } - - - + {portal ? {content} : content} ); }; diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index dce46754..56a3797d 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { getRoomIdentifierFromUrl, getUrlParams, - UserIntent, + HeaderStyle, } from "../src/UrlParams"; const ROOM_NAME = "roomNameHere"; @@ -82,6 +82,16 @@ describe("UrlParams", () => { getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId, ).toBe(ROOM_ID); }); + it("(roomId with unprintable characters)", () => { + const invisibleChar = "\u2066"; + expect( + getRoomIdentifierFromUrl( + "", + `?roomId=${invisibleChar}${ROOM_ID}${invisibleChar}`, + "", + ).roomId, + ).toBe(ROOM_ID); + }); }); it("ignores room alias", () => { @@ -201,24 +211,68 @@ describe("UrlParams", () => { }); describe("intent", () => { - it("defaults to unknown", () => { - expect(getUrlParams().intent).toBe(UserIntent.Unknown); + const noIntentDefaults = { + confineToRoom: false, + appPrompt: true, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + }; + const startNewCallDefaults = (platform: string): object => ({ + confineToRoom: true, + appPrompt: false, + preload: false, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification", + }); + const joinExistingCallDefaults = (platform: string): object => ({ + confineToRoom: true, + appPrompt: false, + preload: false, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: false, + returnToLobby: false, + sendNotificationType: "notification", + }); + it("use no-intent-defaults with unknown intent", () => { + expect(getUrlParams()).toMatchObject(noIntentDefaults); }); it("ignores intent if it is not a valid value", () => { - expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown); + expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults); }); it("accepts start_call", () => { - expect(getUrlParams("?intent=start_call").intent).toBe( - UserIntent.StartNewCall, - ); + expect( + getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"), + ).toMatchObject(startNewCallDefaults("desktop")); }); it("accepts join_existing", () => { - expect(getUrlParams("?intent=join_existing").intent).toBe( - UserIntent.JoinExistingCall, - ); + expect( + getUrlParams( + "?intent=join_existing&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject(joinExistingCallDefaults("desktop")); }); }); @@ -243,4 +297,12 @@ describe("UrlParams", () => { expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false); }); }); + describe("header", () => { + it("uses header if provided", () => { + expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe( + "app_bar", + ); + expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none"); + }); + }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 17e169d9..f5369765 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -8,10 +8,13 @@ Please see LICENSE in the repository root for full details. import { useMemo } from "react"; import { useLocation } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; +import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc"; +import { pickBy } from "lodash-es"; import { Config } from "./config/Config"; import { type EncryptionSystem } from "./e2ee/sharedKeyManagement"; import { E2eeType } from "./e2ee/e2eeType"; +import { platform } from "./Platform"; interface RoomIdentifier { roomAlias: string | null; @@ -22,15 +25,23 @@ interface RoomIdentifier { export enum UserIntent { StartNewCall = "start_call", JoinExistingCall = "join_existing", + StartNewCallDM = "start_call_dm", + JoinExistingCallDM = "join_existing_dm", Unknown = "unknown", } -// If you need to add a new flag to this interface, prefer a name that describes -// a specific behavior (such as 'confineToRoom'), rather than one that describes -// the situations that call for this behavior ('isEmbedded'). This makes it -// clearer what each flag means, and helps us avoid coupling Element Call's -// behavior to the needs of specific consumers. -export interface UrlParams { +export enum HeaderStyle { + None = "none", + Standard = "standard", + AppBar = "app_bar", +} + +/** + * The UrlProperties are used to pass required data to the widget. + * Those are different in different rooms, users, devices. They do not configure the behavior of the + * widget but provide the required data to the widget. + */ +export interface UrlProperties { // Widget api related params widgetId: string | null; parentUrl: string | null; @@ -42,42 +53,11 @@ export interface UrlParams { * is also not validated, where it is in useRoomIdentifier(). */ roomId: string | null; - /** - * Whether the app should keep the user confined to the current call/room. - */ - confineToRoom: boolean; - /** - * Whether upon entering a room, the user should be prompted to launch the - * native mobile app. (Affects only Android and iOS.) - * - * The app prompt must also be enabled in the config for this to take effect. - */ - appPrompt: boolean; - /** - * Whether the app should pause before joining the call until it sees an - * io.element.join widget action, allowing it to be preloaded. - */ - preload: boolean; - /** - * Whether to hide the room header when in a call. - */ - hideHeader: boolean; - /** - * Whether the controls should be shown. For screen recording no controls can be desired. - */ - showControls: boolean; - /** - * Whether to hide the screen-sharing button. - */ - hideScreensharing: boolean; - /** - * Whether to use end-to-end encryption. - */ - e2eEnabled: boolean; /** * The user's ID (only used in matryoshka mode). */ userId: string | null; + /** * The display name to use for auto-registration. */ @@ -115,14 +95,96 @@ export interface UrlParams { */ posthogApiKey: string | null; /** - * Whether the app is allowed to use fallback STUN servers for ICE in case the - * user's homeserver doesn't provide any. + * Whether to use end-to-end encryption. */ - allowIceFallback: boolean; + e2eEnabled: boolean; /** * E2EE password */ password: string | null; + /** This defines the homeserver that is going to be used when joining a room. + * It has to be set to a non default value for links to rooms + * that are not on the default homeserver, + * that is in use for the current user. + */ + viaServers: string | null; + + /** + * This defines the homeserver that is going to be used when registering + * a new (guest) user. + * This can be user to configure a non default guest user server when + * creating a spa link. + */ + homeserver: string | null; + + /** + * The rageshake submit URL. This is only used in the embedded package of Element Call. + */ + rageshakeSubmitUrl: string | null; + + /** + * The Sentry DSN. This is only used in the embedded package of Element Call. + */ + sentryDsn: string | null; + + /** + * The Sentry environment. This is only used in the embedded package of Element Call. + */ + sentryEnvironment: string | null; + /** + * The theme to use for element call. + * can be "light", "dark", "light-high-contrast" or "dark-high-contrast". + */ + theme: string | null; +} + +/** + * The configuration for the app, which can be set via URL parameters. + * Those property are different to the UrlProperties, since they are all optional + * and configure the behavior of the app. Their value is the same if EC is used in + * the same context but with different accounts/users. + * + * Their defaults can be controlled by the `intent` property. + */ +export interface UrlConfiguration { + /** + * Whether the app should keep the user confined to the current call/room. + */ + confineToRoom: boolean; + /** + * Whether upon entering a room, the user should be prompted to launch the + * native mobile app. (Affects only Android and iOS.) + * + * The app prompt must also be enabled in the config for this to take effect. + */ + appPrompt: boolean; + /** + * Whether the app should pause before joining the call until it sees an + * io.element.join widget action, allowing it to be preloaded. + */ + preload: boolean; + /** + * The style of headers to show. "standard" is the default arrangement, "none" + * hides the header entirely, and "app_bar" produces a header with a back + * button like you might see in mobile apps. The callback for the back button + * is window.controls.onBackButtonPressed. + */ + header: HeaderStyle; + /** + * Whether the controls should be shown. For screen recording no controls can be desired. + */ + showControls: boolean; + /** + * Whether to hide the screen-sharing button. + */ + hideScreensharing: boolean; + + /** + * Whether the app is allowed to use fallback STUN servers for ICE in case the + * user's homeserver doesn't provide any. + */ + allowIceFallback: boolean; + /** * Whether the app should use per participant keys for E2EE. */ @@ -145,47 +207,35 @@ export interface UrlParams { */ returnToLobby: boolean; /** - * The theme to use for element call. - * can be "light", "dark", "light-high-contrast" or "dark-high-contrast". + * Whether and what type of notification EC should send, when the user joins the call. */ - theme: string | null; - /** This defines the homeserver that is going to be used when joining a room. - * It has to be set to a non default value for links to rooms - * that are not on the default homeserver, - * that is in use for the current user. - */ - viaServers: string | null; + sendNotificationType?: RTCNotificationType; /** - * This defines the homeserver that is going to be used when registering - * a new (guest) user. - * This can be user to configure a non default guest user server when - * creating a spa link. + * Whether the app should automatically leave the call when there + * is no one left in the call. + * This is one part to make the call matrixRTC session behave like a telephone call. */ - homeserver: string | null; + autoLeaveWhenOthersLeft: boolean; /** - * The user's intent with respect to the call. - * e.g. if they clicked a Start Call button, this would be `start_call`. - * If it was a Join Call button, it would be `join_existing`. + * If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up). + * This is a no-op if not combined with sendNotificationType. + * + * This entails: + * - show ui that it is awaiting an answer + * - play a sound that indicates that it is awaiting an answer + * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. */ - intent: string | null; - - /** - * The rageshake submit URL. This is only used in the embedded package of Element Call. - */ - rageshakeSubmitUrl: string | null; - - /** - * The Sentry DSN. This is only used in the embedded package of Element Call. - */ - sentryDsn: string | null; - - /** - * The Sentry environment. This is only used in the embedded package of Element Call. - */ - sentryEnvironment: string | null; + waitForCallPickup: boolean; } +// If you need to add a new flag to this interface, prefer a name that describes +// a specific behavior (such as 'confineToRoom'), rather than one that describes +// the situations that call for this behavior ('isEmbedded'). This makes it +// clearer what each flag means, and helps us avoid coupling Element Call's +// behavior to the needs of specific consumers. +export interface UrlParams extends UrlProperties, UrlConfiguration {} + // This is here as a stopgap, but what would be far nicer is a function that // takes a UrlParams and returns a query string. That would enable us to // consolidate all the data about URL parameters and their meanings to this one @@ -226,6 +276,17 @@ class ParamParser { return this.fragmentParams.get(name) ?? this.queryParams.get(name); } + public getEnumParam( + name: string, + type: { [s: string]: T } | ArrayLike, + ): T | undefined { + const value = this.getParam(name); + if (value !== null && Object.values(type).includes(value as T)) { + return value as T; + } + return undefined; + } + public getAllParams(name: string): string[] { return [ ...this.fragmentParams.getAll(name), @@ -233,10 +294,20 @@ class ParamParser { ]; } + /** + * Returns true if the flag exists and is not "false". + */ public getFlagParam(name: string, defaultValue = false): boolean { const param = this.getParam(name); return param === null ? defaultValue : param !== "false"; } + /** + * Returns the value of the flag if it exists, or undefined if it does not. + */ + public getFlag(name: string): boolean | undefined { + const param = this.getParam(name); + return param !== null ? param !== "false" : undefined; + } } /** @@ -253,32 +324,98 @@ export const getUrlParams = ( const fontScale = parseFloat(parser.getParam("fontScale") ?? ""); - let intent = parser.getParam("intent"); - if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) { - intent = UserIntent.Unknown; - } const widgetId = parser.getParam("widgetId"); const parentUrl = parser.getParam("parentUrl"); const isWidget = !!widgetId && !!parentUrl; - return { + /** + * The user's intent with respect to the call. + * e.g. if they clicked a Start Call button, this would be `start_call`. + * If it was a Join Call button, it would be `join_existing`. + * This is a platform specific default set of parameters, that allows to minize the configuration + * needed to start a call. And empowers the EC codebase to control the platform/intent behavior in + * a central place. + * + * In short: either provide url query parameters of UrlConfiguration or set the intent + * (or the global defaults will be used). + */ + const intent = !isWidget + ? UserIntent.Unknown + : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); + // Here we only use constants and `platform` to determine the intent preset. + let intentPreset: UrlConfiguration; + const inAppDefault = { + confineToRoom: true, + appPrompt: false, + preload: false, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification" as RTCNotificationType, + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + switch (intent) { + case UserIntent.StartNewCall: + intentPreset = { + ...inAppDefault, + skipLobby: true, + }; + break; + case UserIntent.JoinExistingCall: + intentPreset = { + ...inAppDefault, + skipLobby: false, + }; + break; + case UserIntent.StartNewCallDM: + intentPreset = { + ...inAppDefault, + skipLobby: true, + autoLeaveWhenOthersLeft: true, + waitForCallPickup: true, + }; + break; + case UserIntent.JoinExistingCallDM: + intentPreset = { + ...inAppDefault, + skipLobby: true, + autoLeaveWhenOthersLeft: true, + }; + break; + // Non widget usecase defaults + default: + intentPreset = { + confineToRoom: false, + appPrompt: true, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + } + + const properties: UrlProperties = { widgetId, parentUrl, - // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // what would we do if it were invalid? If the widget API says that's what // the room ID is, then that's what it is. roomId: parser.getParam("roomId"), password: parser.getParam("password"), - // This flag has 'embed' as an alias for historical reasons - confineToRoom: - parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"), - appPrompt: parser.getFlagParam("appPrompt", true), - preload: isWidget ? parser.getFlagParam("preload") : false, - hideHeader: parser.getFlagParam("hideHeader"), - showControls: parser.getFlagParam("showControls", true), - hideScreensharing: parser.getFlagParam("hideScreensharing"), - e2eEnabled: parser.getFlagParam("enableE2EE", true), userId: isWidget ? parser.getParam("userId") : null, displayName: parser.getParam("displayName"), deviceId: isWidget ? parser.getParam("deviceId") : null, @@ -286,24 +423,9 @@ export const getUrlParams = ( lang: parser.getParam("lang"), fonts: parser.getAllParams("font"), fontScale: Number.isNaN(fontScale) ? null : fontScale, - allowIceFallback: parser.getFlagParam("allowIceFallback"), - perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"), - controlledAudioDevices: parser.getFlagParam( - "controlledAudioDevices", - // the deprecated property name - parser.getFlagParam("controlledMediaDevices"), - ), - skipLobby: parser.getFlagParam( - "skipLobby", - isWidget && intent === UserIntent.StartNewCall, - ), - // In SPA mode the user should always exit to the home screen when hanging - // up, rather than being sent back to the lobby - returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false, theme: parser.getParam("theme"), viaServers: !isWidget ? parser.getParam("viaServers") : null, homeserver: !isWidget ? parser.getParam("homeserver") : null, - intent, posthogApiHost: parser.getParam("posthogApiHost"), posthogApiKey: parser.getParam("posthogApiKey"), posthogUserId: @@ -311,6 +433,37 @@ export const getUrlParams = ( rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), + e2eEnabled: parser.getFlagParam("enableE2EE", true), + }; + + const configuration: Partial = { + confineToRoom: parser.getFlag("confineToRoom"), + appPrompt: parser.getFlag("appPrompt"), + preload: isWidget ? parser.getFlag("preload") : undefined, + // Check hideHeader for backwards compatibility. If header is set, hideHeader + // is ignored. + header: parser.getEnumParam("header", HeaderStyle), + showControls: parser.getFlag("showControls"), + hideScreensharing: parser.getFlag("hideScreensharing"), + allowIceFallback: parser.getFlag("allowIceFallback"), + perParticipantE2EE: parser.getFlag("perParticipantE2EE"), + controlledAudioDevices: parser.getFlag("controlledAudioDevices"), + skipLobby: isWidget ? parser.getFlag("skipLobby") : false, + // In SPA mode the user should always exit to the home screen when hanging + // up, rather than being sent back to the lobby + returnToLobby: isWidget ? parser.getFlag("returnToLobby") : false, + sendNotificationType: parser.getEnumParam("sendNotificationType", [ + "ring", + "notification", + ]), + waitForCallPickup: parser.getFlag("waitForCallPickup"), + autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), + }; + + return { + ...properties, + ...intentPreset, + ...pickBy(configuration, (v?: unknown) => v !== undefined), }; }; @@ -369,10 +522,16 @@ export function getRoomIdentifierFromUrl( // Make sure roomId is valid let roomId: string | null = parser.getParam("roomId"); - if (!roomId?.startsWith("!")) { - roomId = null; - } else if (!roomId.includes("")) { - roomId = null; + if (roomId !== null) { + // Replace any non-printable characters that another client may have inserted. + // For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL. + // This isn't valid for a roomId, so we can freely strip the content. + roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, ""); + if (!roomId.startsWith("!")) { + roomId = null; + } else if (!roomId.includes("")) { + roomId = null; + } } return { diff --git a/src/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap new file mode 100644 index 00000000..fe61d09b --- /dev/null +++ b/src/__snapshots__/AppBar.test.tsx.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AppBar > renders 1`] = ` +
+
+
+ +
+
+

+ This is the content. +

+
+`; diff --git a/src/__snapshots__/Modal.test.tsx.snap b/src/__snapshots__/Modal.test.tsx.snap index 8772c543..92d837d1 100644 --- a/src/__snapshots__/Modal.test.tsx.snap +++ b/src/__snapshots__/Modal.test.tsx.snap @@ -2,10 +2,10 @@ exports[`the content is rendered when the modal is open 1`] = ` ); - }), + }, }); diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 5f9256fd..675e4d0a 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { forwardRef, useCallback, useMemo } from "react"; +import { type ReactNode, useCallback, useMemo } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; @@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod import { type CallLayout, arrangeTiles } from "./CallLayout"; import styles from "./OneOnOneLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "one-on-one" layout, in which the remote participant @@ -24,15 +25,15 @@ export const makeOneOnOneLayout: CallLayout = ({ }) => ({ scrollingOnTop: false, - fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) { + fixed: function OneOnOneLayoutFixed({ ref }): ReactNode { useUpdateLayout(); return
; - }), + }, - scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { + scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -66,5 +67,5 @@ export const makeOneOnOneLayout: CallLayout = ({
); - }), + }, }); diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index aa6b30ae..9dd2a109 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { forwardRef, useCallback } from "react"; -import { useObservableEagerState } from "observable-hooks"; +import { type ReactNode, useCallback } from "react"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type CallLayout } from "./CallLayout"; import { type DragCallback, useUpdateLayout } from "./Grid"; import styles from "./SpotlightExpandedLayout.module.css"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout< > = ({ pipAlignment$ }) => ({ scrollingOnTop: true, - fixed: forwardRef(function SpotlightExpandedLayoutFixed( - { model, Slot }, + fixed: function SpotlightExpandedLayoutFixed({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); return ( @@ -37,14 +38,15 @@ export const makeSpotlightExpandedLayout: CallLayout< />
); - }), + }, - scrolling: forwardRef(function SpotlightExpandedLayoutScrolling( - { model, Slot }, + scrolling: function SpotlightExpandedLayoutScrolling({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => @@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout< )}
); - }), + }, }); diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 99b9a82a..96343296 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { forwardRef } from "react"; +import { type ReactNode } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; @@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout< > = ({ minBounds$ }) => ({ scrollingOnTop: false, - fixed: forwardRef(function SpotlightLandscapeLayoutFixed( - { model, Slot }, + fixed: function SpotlightLandscapeLayoutFixed({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); useObservableEagerState(minBounds$); @@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
); - }), + }, - scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling( - { model, Slot }, + scrolling: function SpotlightLandscapeLayoutScrolling({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); useObservableEagerState(minBounds$); @@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout< ); - }), + }, }); diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 3e27c461..ad11ed11 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type CSSProperties, forwardRef } from "react"; +import { type ReactNode, type CSSProperties } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; @@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightPortraitLayout.module.css"; import { useUpdateLayout, useVisibleTiles } from "./Grid"; +import { useBehavior } from "../useBehavior"; interface GridCSSProperties extends CSSProperties { "--grid-gap": string; @@ -30,10 +31,11 @@ export const makeSpotlightPortraitLayout: CallLayout< > = ({ minBounds$ }) => ({ scrollingOnTop: false, - fixed: forwardRef(function SpotlightPortraitLayoutFixed( - { model, Slot }, + fixed: function SpotlightPortraitLayoutFixed({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); return ( @@ -47,12 +49,13 @@ export const makeSpotlightPortraitLayout: CallLayout< ); - }), + }, - scrolling: forwardRef(function SpotlightPortraitLayoutScrolling( - { model, Slot }, + scrolling: function SpotlightPortraitLayoutScrolling({ ref, - ) { + model, + Slot, + }): ReactNode { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); const { width } = useObservableEagerState(minBounds$); @@ -63,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout< width, model.grid.length, ); - const withIndicators = - useObservableEagerState(model.spotlight.media$).length > 1; + const withIndicators = useBehavior(model.spotlight.media$).length > 1; return (
); - }), + }, }); diff --git a/src/grid/TileWrapper.tsx b/src/grid/TileWrapper.tsx index 9e58fd7c..1bed08da 100644 --- a/src/grid/TileWrapper.tsx +++ b/src/grid/TileWrapper.tsx @@ -61,7 +61,12 @@ const TileWrapper_ = memo( useDrag((state) => onDrag?.current!(id, state), { target: ref, filterTaps: true, - preventScroll: true, + // Previous designs, which allowed tiles to be dragged and dropped around + // the scrolling grid, required us to set preventScroll to true here. But + // our designs no longer call for this, and meanwhile there's a bug in + // use-gesture that causes filterTaps + preventScroll to break buttons + // within tiles (like the 'switch camera' button) on mobile. + // https://github.com/pmndrs/use-gesture/issues/593 }); return ( diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index af2d5f26..361160c5 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -37,12 +37,14 @@ import { Form } from "../form/Form"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; +import { useUrlParams } from "../UrlParams"; interface Props { client: MatrixClient; } export const RegisteredView: FC = ({ client }) => { + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -114,14 +116,16 @@ export const RegisteredView: FC = ({ client }) => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index e23c637b..6e05bc34 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -34,9 +34,11 @@ import { Config } from "../config/Config"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; import { ExternalLink, Link } from "../button/Link"; +import { useUrlParams } from "../UrlParams"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/home/useGroupCallRooms.ts b/src/home/useGroupCallRooms.ts index 45b893e3..e89c3f14 100644 --- a/src/home/useGroupCallRooms.ts +++ b/src/home/useGroupCallRooms.ts @@ -81,8 +81,9 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] { } const roomIsJoinable = (room: Room): boolean => { - if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) { - // if we have an non encrypted room (no encryption state event) we need a locally stored shared key. + const password = getKeyForRoom(room.roomId); + if (!room.hasEncryptionStateEvent() && !password) { + // if we have a non encrypted room (no encryption state event) we need a locally stored shared key. // in case this key also does not exists we cannot join the room. return false; } diff --git a/src/icons/FullScreenMaximise.svg b/src/icons/FullScreenMaximise.svg new file mode 100644 index 00000000..1814f16e --- /dev/null +++ b/src/icons/FullScreenMaximise.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/icons/FullScreenMinimise.svg b/src/icons/FullScreenMinimise.svg new file mode 100644 index 00000000..204259e2 --- /dev/null +++ b/src/icons/FullScreenMinimise.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/index.css b/src/index.css index 883481b1..dc914452 100644 --- a/src/index.css +++ b/src/index.css @@ -45,6 +45,9 @@ layer(compound); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); --background-gradient: url("graphics/backgroundGradient.svg"); + + --call-view-overlay-layer: 1; + --call-view-header-footer-layer: 2; } :root, @@ -71,6 +74,13 @@ body { -webkit-tap-highlight-color: transparent; } +/* This prohibits the view to scroll for pages smaller than 122px in width +we use this for mobile pip webviews */ +.no-scroll-body { + position: fixed; + width: 100%; +} + /* We use this to not render the page at all until we know the theme.*/ .no-theme { opacity: 0; diff --git a/src/input/Input.tsx b/src/input/Input.tsx index 82b96109..1f6d35e8 100644 --- a/src/input/Input.tsx +++ b/src/input/Input.tsx @@ -9,10 +9,10 @@ import { type ChangeEvent, type FC, type ForwardedRef, - forwardRef, type ReactNode, useId, type JSX, + type Ref, } from "react"; import classNames from "classnames"; @@ -54,6 +54,7 @@ function Field({ children, className }: FieldProps): JSX.Element { } interface InputFieldProps { + ref?: Ref; label?: string; type: string; prefix?: string; @@ -78,88 +79,81 @@ interface InputFieldProps { onChange?: (event: ChangeEvent) => void; } -export const InputField = forwardRef< - HTMLInputElement | HTMLTextAreaElement, - InputFieldProps ->( - ( - { - id, - label, - className, - type, - checked, - prefix, - suffix, - description, - disabled, - min, - ...rest - }, - ref, - ) => { - const descriptionId = useId(); +export const InputField: FC = ({ + ref, + id, + label, + className, + type, + checked, + prefix, + suffix, + description, + disabled, + min, + ...rest +}) => { + const descriptionId = useId(); - return ( - - {prefix && {prefix}} - {type === "textarea" ? ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore -