diff --git a/.github/workflows/blocked.yaml b/.github/workflows/blocked.yaml deleted file mode 100644 index 12a4b0204..000000000 --- a/.github/workflows/blocked.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Prevent blocked -on: - pull_request_target: - types: [opened, labeled, unlabeled, synchronize] -jobs: - prevent-blocked: - name: Prevent blocked - runs-on: ubuntu-latest - permissions: - pull-requests: read - steps: - - name: Add notice - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - if: contains(github.event.pull_request.labels.*.name, 'X-Blocked') - with: - script: | - core.setFailed("PR has been labeled with X-Blocked; it cannot be merged."); diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index bc2857249..7adc9903b 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache @@ -43,7 +45,7 @@ jobs: - name: Install dependencies run: "yarn install --immutable" - name: Build Element Call - run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }} + run: yarn run build:"$PACKAGE":"$BUILD_MODE" env: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} @@ -52,6 +54,8 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} VITE_APP_VERSION: ${{ inputs.vite_app_version }} NODE_OPTIONS: "--max-old-space-size=4096" + PACKAGE: ${{ inputs.package }} + BUILD_MODE: ${{ inputs.build_mode }} - name: Upload Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 32dde8690..763d2eacb 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -8,6 +8,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index bf6e16cea..bfbf9feb1 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -22,8 +22,18 @@ jobs: TAG: ${{ steps.tag.outputs.TAG }} steps: - name: Calculate VERSION - # We should only use the hard coded test value for a dry run - run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV" + # Safely store dynamic values in environment variables + # to prevent shell injection (template-injection) + run: | + # The logic is executed within the shell using the env variables + if [ "$EVENT_NAME" = "release" ]; then + echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV" + else + echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV" + fi + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT_NAME: ${{ github.event_name }} - id: dry_run name: Set DRY_RUN # We perform a dry run for all events except releases. @@ -71,7 +81,9 @@ jobs: contents: write # required to upload release asset steps: - name: Determine filename - run: echo "FILENAME_PREFIX=sable-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + run: echo "FILENAME_PREFIX=sable-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + env: + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: Download built artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -80,9 +92,9 @@ jobs: name: build-output-embedded path: ${{ env.FILENAME_PREFIX}} - name: Create Tarball - run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }} + run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX} - name: Create Checksum - run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 + run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -104,6 +116,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Download built artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -124,14 +138,16 @@ jobs: working-directory: embedded/web env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }} + NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }} run: | - npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version + npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV" - npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" release_notes: needs: [versioning, publish_npm] @@ -143,7 +159,9 @@ jobs: steps: - name: Log versions run: | - echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}" + echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}" + env: + NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }} - name: Add release notes if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 012de7cb8..cd1c94c56 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,6 +10,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache @@ -34,6 +36,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/config/config_netlify_preview_sdk.json b/config/config_netlify_preview_sdk.json new file mode 100644 index 000000000..784f0c7ef --- /dev/null +++ b/config/config_netlify_preview_sdk.json @@ -0,0 +1,16 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://call-unstable.ems.host", + "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 + } +} diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index 28682a33e..8d885399d 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -47,7 +47,7 @@ services: - ecbackend livekit: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu command: --dev --config /etc/livekit.yaml @@ -67,7 +67,7 @@ services: - ecbackend livekit-1: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu-1 command: --dev --config /etc/livekit.yaml @@ -88,7 +88,7 @@ services: synapse: hostname: homeserver - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml @@ -106,7 +106,7 @@ services: synapse-1: hostname: homeserver-1 - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml diff --git a/docs/url-params.md b/docs/url-params.md index a474daed4..e24e9823f 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -46,32 +46,32 @@ possible to support encryption. These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes: -| Name | Values | Required for widget | Required for SPA | Description | -| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | -| `displayName` | | No | No | Display name used for auto-registration. | -| `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...`. | -| `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. | -| `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. | -| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | -| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | -| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | -| `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. | +| 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. | +| `posthogUserId` | 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. | +| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | +| `displayName` | | No | No | Display name used for auto-registration. | +| `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...`. | +| `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. | +| `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. | +| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | +| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | +| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | +| `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 5a91e19e6..a93dc56e8 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.13.1" +android_gradle_plugin = "8.13.2" [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.35.0" } \ No newline at end of file +maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.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 7705927e9..de413606b 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.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/knip.ts b/knip.ts index d23d42fec..3be3e6539 100644 --- a/knip.ts +++ b/knip.ts @@ -34,6 +34,12 @@ export default { // then Knip will flag it as a false positive // https://github.com/webpro-nl/knip/issues/766 "@vector-im/compound-web", + // Yarn plugins are allowed to depend on packages provided by the Yarn + // runtime. These shouldn't be listed in package.json, because plugins + // should work before Yarn even installs dependencies for the first time. + // https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like + "@yarnpkg/core", + "@yarnpkg/parsers", "matrix-widget-api", ], ignoreExportsUsedInFile: true, diff --git a/locales/en/app.json b/locales/en/app.json index 0b0ac7b4d..9649b156f 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -194,13 +194,24 @@ "room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)", "screenshare_button_label": "Share screen", "settings": { + "advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.", + "advanced_camera_label": "Advanced camera settings", + "advanced_screen_share_description": "Configure resolution, framerate, bitrate, and codec for screen sharing", + "advanced_screen_share_label": "Advanced screen share settings", + "allow_pip_label": "Allow Browser Picture In Picture", + "audio_processing_description": "Changes apply on next call join.", + "audio_processing_header": "Audio processing", "audio_tab": { "effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.", "effect_volume_label": "Sound effect volume" }, + "auto_gain_control_label": "Automatic gain control", "background_blur_header": "Background", "background_blur_label": "Blur the background of the video", + "bitrate_label": "Bitrate", "blur_not_supported_by_browser": "(Background blur is not supported by this device.)", + "camera_header": "Camera quality", + "codec_label": "Codec", "developer_tab_title": "Developer", "devices": { "camera": "Camera", @@ -215,12 +226,15 @@ "speaker": "Speaker", "speaker_numbered": "Speaker {{n}}" }, + "echo_cancellation_label": "Echo cancellation", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_description_label": "Your feedback", "feedback_tab_h4": "Submit feedback", "feedback_tab_send_logs_label": "Include debug logs", "feedback_tab_thank_you": "Thanks, we received your feedback!", "feedback_tab_title": "Feedback", + "framerate_label": "Framerate", + "noise_suppression_label": "Noise suppression", "opt_in_description": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", "preferences_tab": { "developer_mode_label": "Developer mode", @@ -232,7 +246,9 @@ "reactions_show_label": "Show reactions", "show_hand_raised_timer_description": "Show a timer when a participant raises their hand", "show_hand_raised_timer_label": "Show hand raise duration" - } + }, + "resolution_label": "Resolution", + "screen_share_header": "Screen sharing" }, "star_rating_input_label_one": "{{count}} star", "star_rating_input_label_other": "{{count}} stars", @@ -250,11 +266,11 @@ "video_tile": { "always_show": "Always show", "camera_starting": "Video loading...", - "change_fit_contain": "Fit to frame", "collapse": "Collapse", "expand": "Expand", "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", + "screen_share_volume": "Screen share volume", "volume": "Volume", "waiting_for_media": "Waiting for media..." } diff --git a/package.json b/package.json index 496121204..cc8a36eb1 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,12 @@ "@codecov/vite-plugin": "^1.3.0", "@fontsource/inconsolata": "^5.1.0", "@fontsource/inter": "^5.1.0", - "@formatjs/intl-durationformat": "^0.9.0", + "@formatjs/intl-durationformat": "^0.10.0", "@formatjs/intl-segmenter": "^11.7.3", "@livekit/components-core": "^0.12.0", "@livekit/components-react": "^2.0.0", "@livekit/protocol": "^1.42.2", - "@livekit/track-processors": "^0.6.0 || ^0.7.1", + "@livekit/track-processors": "^0.7.1", "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.57.0", "@radix-ui/react-dialog": "^1.0.4", @@ -79,7 +79,7 @@ "@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", + "@vitest/coverage-v8": "^4.0.18", "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", @@ -101,7 +101,7 @@ "i18next-browser-languagedetector": "^8.0.0", "i18next-parser": "^9.1.0", "jsdom": "^26.0.0", - "knip": "^5.27.2", + "knip": "^5.86.0", "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", @@ -118,7 +118,7 @@ "qrcode": "^1.5.4", "react": "19", "react-dom": "19", - "react-i18next": "^16.0.0 <16.1.0", + "react-i18next": "^16.0.0 <16.6.0", "react-router-dom": "^7.0.0", "react-use-measure": "^2.1.1", "rxjs": "^7.8.1", @@ -128,17 +128,22 @@ "unique-names-generator": "^4.6.0", "uuid": "^13.0.0", "vaul": "^1.0.0", - "vite": "^7.0.0", + "vite": "^7.3.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-node-stdlib-browser": "^0.2.1", "vite-plugin-svgr": "^4.0.0", - "vitest": "^3.0.0", + "vitest": "^4.0.18", "vitest-axe": "^1.0.0-pre.3" }, "resolutions": { "@livekit/components-core/rxjs": "^7.8.1", - "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18" + "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", + "minimatch": "^10.2.3", + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index b42c0ab25..d4ba00068 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -60,7 +60,7 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { // The only way to know if it is muted or not is to look at the data-kind attribute.. const videoButton = frame.getByTestId("incall_videomute"); await expect(videoButton).toBeVisible(); - // video should be off by default in a voice call + // video should be on await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); } diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts new file mode 100644 index 000000000..1dda652df --- /dev/null +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -0,0 +1,68 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); + + const valere = await addUser("Valere", HOST1); + + const callRoom = "CallRoom"; + await TestHelpers.createRoom("CallRoom", valere.page); + + await TestHelpers.createRoom("OtherRoom", valere.page); + + await TestHelpers.switchToRoomNamed(valere.page, callRoom); + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await TestHelpers.joinCallFromLobby(valere.page); + // wait a bit so that the PIP has rendered + await valere.page.waitForTimeout(600); + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom"); + + // We should see the PIP overlay + const iFrame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + { + // Check for a bug where the video had the wrong fit in PIP + const hangupBtn = iFrame.getByRole("button", { name: "End call" }); + const audioBtn = iFrame.getByTestId("incall_mute"); + const videoBtn = iFrame.getByTestId("incall_videomute"); + await expect(hangupBtn).toBeVisible(); + await expect(audioBtn).toBeVisible(); + await expect(videoBtn).toBeVisible(); + await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/); + await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/); + + await videoBtn.click(); + await audioBtn.click(); + + // stop hovering on any of the buttons + await iFrame.getByTestId("videoTile").hover(); + + await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/); + await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/); + } +}); diff --git a/playwright/widget/pip-call.test.ts b/playwright/widget/pip-call.test.ts new file mode 100644 index 000000000..d57befc1a --- /dev/null +++ b/playwright/widget/pip-call.test.ts @@ -0,0 +1,74 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +widgetTest("Put call in PIP", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); + + const valere = await addUser("Valere", HOST1); + const timo = await addUser("Timo", HOST1); + + const callRoom = "TeamRoom"; + await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]); + + await TestHelpers.createRoom("DoubleTask", valere.page); + + await TestHelpers.acceptRoomInvite(callRoom, timo.page); + + await TestHelpers.switchToRoomNamed(valere.page, callRoom); + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await TestHelpers.joinCallFromLobby(valere.page); + + await TestHelpers.joinCallInCurrentRoom(timo.page); + + { + const frame = timo.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + const videoButton = frame.getByTestId("incall_videomute"); + await expect(videoButton).toBeVisible(); + // check that the video is on + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + } + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); + + // We should see the PIP overlay + await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible(); + + { + // wait a bit so that the PIP has rendered the video + await valere.page.waitForTimeout(600); + + // Check for a bug where the video had the wrong fit in PIP + const frame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(1); + + const pipVideo = videoElements[0]; + await expect(pipVideo).toHaveCSS("object-fit", "cover"); + } +}); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 6fe4479be..4562ba5a1 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -276,4 +276,16 @@ export class TestHelpers { }); } } + + /** + * Switches to a room in the room list by its name. + * @param page - The EW page + * @param roomName - The name of the room to switch to + */ + public static async switchToRoomNamed( + page: Page, + roomName: string, + ): Promise { + await page.getByRole("option", { name: `Open room ${roomName}` }).click(); + } } diff --git a/src/Slider.tsx b/src/Slider.tsx index c2465874b..29f9ef426 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -77,7 +77,14 @@ export const Slider: FC = ({ {/* Note: This is expected not to be visible on mobile.*/} - + diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f8ee22fbc..311011976 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -473,8 +473,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { homeserver: !isWidget ? parser.getParam("homeserver") : null, posthogApiHost: parser.getParam("posthogApiHost"), posthogApiKey: parser.getParam("posthogApiKey"), - posthogUserId: - parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"), + posthogUserId: parser.getParam("posthogUserId"), rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 3136e2da2..00d803f1f 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -23,6 +23,7 @@ import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; + size?: "sm" | "lg"; } export const MicButton: FC = ({ muted, ...props }) => { @@ -47,6 +48,7 @@ export const MicButton: FC = ({ muted, ...props }) => { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; + size?: "sm" | "lg"; } export const VideoButton: FC = ({ muted, ...props }) => { @@ -71,6 +73,7 @@ export const VideoButton: FC = ({ muted, ...props }) => { interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; + size: "sm" | "lg"; } export const ShareScreenButton: FC = ({ @@ -94,7 +97,11 @@ export const ShareScreenButton: FC = ({ ); }; -export const EndCallButton: FC> = ({ +interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> { + size?: "sm" | "lg"; +} + +export const EndCallButton: FC = ({ className, ...props }) => { @@ -114,9 +121,10 @@ export const EndCallButton: FC> = ({ ); }; -export const SettingsButton: FC> = ( - props, -) => { +interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { + size?: "sm" | "lg"; +} +export const SettingsButton: FC = (props) => { const { t } = useTranslation(); return ( diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 0c722bafa..28163321c 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -166,6 +166,7 @@ export function ReactionPopupMenu({ interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { identifier: string; vm: CallViewModel; + size?: "sm" | "lg"; } export function ReactionToggleButton({ diff --git a/src/grid/TileWrapper.tsx b/src/grid/TileWrapper.tsx index 1bed08daa..00689a78e 100644 --- a/src/grid/TileWrapper.tsx +++ b/src/grid/TileWrapper.tsx @@ -27,7 +27,13 @@ interface Props { state: Parameters>[0], ) => void > | null; + /** + * The width this tile will have once its animations have settled. + */ targetWidth: number; + /** + * The width this tile will have once its animations have settled. + */ targetHeight: number; model: M; Tile: ComponentType>; diff --git a/src/livekit/MatrixAudioRenderer.test.tsx b/src/livekit/MatrixAudioRenderer.test.tsx index c6122b4bf..bc6ef6687 100644 --- a/src/livekit/MatrixAudioRenderer.test.tsx +++ b/src/livekit/MatrixAudioRenderer.test.tsx @@ -30,7 +30,13 @@ import { } from "../utils/test"; import { initializeWidget } from "../widget"; initializeWidget(); -export const TestAudioContextConstructor = vi.fn(() => testAudioContext); +export const TestAudioContextConstructor = vi.fn( + class { + public constructor() { + return testAudioContext; + } + }, +); const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider; diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 96b8a368f..70f7c73a4 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -65,6 +65,7 @@ Please see LICENSE in the repository root for full details. .footer.overlay.hidden { display: grid; opacity: 0; + pointer-events: none; } .footer.overlay:has(:focus-visible) { @@ -107,14 +108,31 @@ Please see LICENSE in the repository root for full details. } } +@media (max-height: 800px) { + .footer { + padding-block: var(--cpd-space-8x); + } +} + +@media (max-height: 400px) { + .footer { + padding-block: var(--cpd-space-4x); + } +} + @media (max-width: 370px) { .shareScreen { display: none; } + /* PIP custom css */ @media (max-height: 400px) { + .shareScreen { + display: flex; + } .footer { - display: none; + padding-block-start: var(--cpd-space-3x); + padding-block-end: var(--cpd-space-2x); } } } @@ -126,18 +144,6 @@ Please see LICENSE in the repository root for full details. } } -@media (max-height: 400px) { - .footer { - padding-block: var(--cpd-space-4x); - } -} - -@media (max-height: 800px) { - .footer { - padding-block: var(--cpd-space-8x); - } -} - @media (min-width: 800px) { .buttons { gap: var(--cpd-space-4x); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 135745eb3..f1a872a08 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -9,8 +9,8 @@ import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk"; import { type FC, - type PointerEvent, - type TouchEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, @@ -110,8 +110,6 @@ import { ObservableScope } from "../state/ObservableScope.ts"; const logger = rootLogger.getChild("[InCallView]"); -const maxTapDurationMs = 400; - export interface ActiveCallProps extends Omit< InCallViewProps, "vm" | "livekitRoom" | "connState" @@ -334,40 +332,20 @@ export const InCallView: FC = ({ ) : null; }, [ringOverlay]); - // Ideally we could detect taps by listening for click events and checking - // that the pointerType of the event is "touch", but this isn't yet supported - // in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility - // Instead we have to watch for sufficiently fast touch events. - const touchStart = useRef(null); - const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []); - const onTouchEnd = useCallback(() => { - const start = touchStart.current; - if (start !== null && Date.now() - start <= maxTapDurationMs) - vm.tapScreen(); - touchStart.current = null; - }, [vm]); - const onTouchCancel = useCallback(() => (touchStart.current = null), []); - - // We also need to tell the footer controls to prevent touch events from - // bubbling up, or else the footer will be dismissed before a click/change - // event can be registered on the control - const onControlsTouchEnd = useCallback( - (e: TouchEvent) => { - // Somehow applying pointer-events: none to the controls when the footer - // is hidden is not enough to stop clicks from happening as the footer - // becomes visible, so we check manually whether the footer is shown - if (showFooter) { - e.stopPropagation(); - vm.tapControls(); - } else { - e.preventDefault(); - } + const onViewClick = useCallback( + (e: ReactMouseEvent) => { + if ( + (e.nativeEvent as PointerEvent).pointerType === "touch" && + // If an interactive element was tapped, don't count this as a tap on the screen + (e.target as Element).closest?.("button, input") === null + ) + vm.tapScreen(); }, - [vm, showFooter], + [vm], ); const onPointerMove = useCallback( - (e: PointerEvent) => { + (e: ReactPointerEvent) => { if (e.pointerType === "mouse") vm.hoverScreen(); }, [vm], @@ -606,8 +584,8 @@ export const InCallView: FC = ({ vm={layout.spotlight} expanded onToggleExpanded={null} - targetWidth={gridBounds.height} - targetHeight={gridBounds.width} + targetWidth={gridBounds.width} + targetHeight={gridBounds.height} showIndicators={false} focusable={!contentObscured} aria-hidden={contentObscured} @@ -662,20 +640,21 @@ export const InCallView: FC = ({ const buttons: JSX.Element[] = []; + const buttonSize = layout.type === "pip" ? "sm" : "lg"; buttons.push( , , @@ -683,11 +662,11 @@ export const InCallView: FC = ({ if (vm.toggleScreenSharing !== null) { buttons.push( , ); @@ -695,30 +674,30 @@ export const InCallView: FC = ({ if (supportsReactions) { buttons.push( , ); } if (layout.type !== "pip") buttons.push( , ); buttons.push( , ); @@ -751,7 +730,6 @@ export const InCallView: FC = ({ className={styles.layout} layout={gridMode} setLayout={setGridMode} - onTouchEnd={onControlsTouchEnd} /> )} @@ -760,12 +738,13 @@ export const InCallView: FC = ({ const allConnections = useBehavior(vm.allConnections$); return ( + // The onClick handler here exists to control the visibility of the footer, + // and the footer is also viewable by moving focus into it, so this is fine. + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
diff --git a/src/room/LayoutToggle.tsx b/src/room/LayoutToggle.tsx index 6cddc95f6..ca6aa467a 100644 --- a/src/room/LayoutToggle.tsx +++ b/src/room/LayoutToggle.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 ChangeEvent, type FC, type TouchEvent, useCallback } from "react"; +import { type ChangeEvent, type FC, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@vector-im/compound-web"; import { @@ -22,15 +22,9 @@ interface Props { layout: Layout; setLayout: (layout: Layout) => void; className?: string; - onTouchEnd?: (e: TouchEvent) => void; } -export const LayoutToggle: FC = ({ - layout, - setLayout, - className, - onTouchEnd, -}) => { +export const LayoutToggle: FC = ({ layout, setLayout, className }) => { const { t } = useTranslation(); const onChange = useCallback( @@ -47,7 +41,6 @@ export const LayoutToggle: FC = ({ value="spotlight" checked={layout === "spotlight"} onChange={onChange} - onTouchEnd={onTouchEnd} /> @@ -58,7 +51,6 @@ export const LayoutToggle: FC = ({ value="grid" checked={layout === "grid"} onChange={onChange} - onTouchEnd={onTouchEnd} /> diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index a79359b1f..91ac28fcf 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -11,11 +11,7 @@ import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; -import { - allowPipSetting, - useSetting, -} from "../settings/settings"; - +import { allowPipSetting, useSetting } from "../settings/settings"; import { TileAvatar } from "../tile/TileAvatar"; import styles from "./VideoPreview.module.css"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index c19e4f4d6..77ea81d63 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, expect, it, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; -import { type Room as LivekitRoom } from "livekit-client"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; +import { render, waitFor, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; import type { MatrixClient } from "matrix-js-sdk"; +import type { Room as LivekitRoom } from "livekit-client"; import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; - +import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; +import { customLivekitUrl as customLivekitUrlSetting } from "./settings"; // Mock url params hook to avoid environment-dependent snapshot churn. vi.mock("../UrlParams", () => ({ useUrlParams: (): { mocked: boolean; answer: number } => ({ @@ -20,6 +23,14 @@ vi.mock("../UrlParams", () => ({ }), })); +// IMPORTANT: mock the same specifier used by DeveloperSettingsTab +vi.mock("../livekit/openIDSFU", () => ({ + getSFUConfigWithOpenID: vi.fn().mockResolvedValue({ + url: "mock-url", + jwt: "mock-jwt", + }), +})); + // Provide a minimal mock of a Livekit Room structure used by the component. function createMockLivekitRoom( wsUrl: string, @@ -86,6 +97,7 @@ describe("DeveloperSettingsTab", () => { const { container } = render( , @@ -99,4 +111,141 @@ describe("DeveloperSettingsTab", () => { expect(container).toMatchSnapshot(); }); + describe("custom livekit url", () => { + afterEach(() => { + customLivekitUrlSetting.setValue(null); + }); + const client = { + doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true), + getCrypto: () => ({ getVersion: (): string => "x" }), + getUserId: () => "@u:hs", + getDeviceId: () => "DEVICE", + } as unknown as MatrixClient; + it("will not update custom livekit url without roomId", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.invalid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will not update custom livekit url without text in input", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will not update custom livekit url when pressing cancel", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.invalid"); + + const cancelButton = screen.getByRole("button", { + name: "Reset overwrite", + }); + await user.click(cancelButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will update custom livekit url", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.valid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "wss://example.livekit.valid", + "#testRoom", + ); + + expect(customLivekitUrlSetting.getValue()).toBe( + "wss://example.livekit.valid", + ); + }); + it("will show error on invalid url", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.valid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + (getSFUConfigWithOpenID as Mock).mockImplementation(() => { + throw new Error("Invalid URL"); + }); + await user.click(saveButton); + expect( + screen.getByText("invalid URL (did not update)"), + ).toBeInTheDocument(); + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + }); }); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 91a2e2415..9df6181f9 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -22,6 +22,7 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { EditInPlace, + ErrorMessage, Root as Form, Heading, HelpMessage, @@ -45,9 +46,11 @@ import { } from "./settings"; import styles from "./DeveloperSettingsTab.module.css"; import { useUrlParams } from "../UrlParams"; +import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; interface Props { client: MatrixClient; + roomId?: string; livekitRooms?: { room: LivekitRoom; url: string; @@ -60,6 +63,7 @@ interface Props { export const DeveloperSettingsTab: FC = ({ client, livekitRooms, + roomId, env, }) => { const { t } = useTranslation(); @@ -97,6 +101,8 @@ export const DeveloperSettingsTab: FC = ({ alwaysShowIphoneEarpieceSetting, ); + const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] = + useState(null); const [customLivekitUrl, setCustomLivekitUrl] = useSetting( customLivekitUrlSetting, ); @@ -234,14 +240,36 @@ export const DeveloperSettingsTab: FC = ({ savingLabel={t("developer_mode.custom_livekit_url.saving")} cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} onSave={useCallback( - (e: React.FormEvent) => { - setCustomLivekitUrl( - customLivekitUrlTextBuffer === "" - ? null - : customLivekitUrlTextBuffer, - ); + async (e: React.FormEvent): Promise => { + if ( + roomId === undefined || + customLivekitUrlTextBuffer === "" || + customLivekitUrlTextBuffer === null + ) { + setCustomLivekitUrl(null); + return; + } + + try { + const userId = client.getUserId(); + const deviceId = client.getDeviceId(); + + if (userId === null || deviceId === null) { + throw new Error("Invalid user or device ID"); + } + await getSFUConfigWithOpenID( + client, + { userId, deviceId, memberId: "" }, + customLivekitUrlTextBuffer, + roomId, + ); + setCustomLivekitUrlUpdateError(null); + setCustomLivekitUrl(customLivekitUrlTextBuffer); + } catch { + setCustomLivekitUrlUpdateError("invalid URL (did not update)"); + } }, - [setCustomLivekitUrl, customLivekitUrlTextBuffer], + [customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId], )} value={customLivekitUrlTextBuffer ?? ""} onChange={useCallback( @@ -256,7 +284,12 @@ export const DeveloperSettingsTab: FC = ({ }, [setCustomLivekitUrl], )} - /> + serverInvalid={customLivekitUrlUpdateError !== null} + > + {customLivekitUrlUpdateError !== null && ( + {customLivekitUrlUpdateError} + )} + {t("developer_mode.matrixRTCMode.title")} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b39c17a01..0daad7be9 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -223,9 +223,7 @@ export const SettingsModal: FC = ({