diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..787ddc730 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ + + +## Content + + + +## Motivation and context + + + +## Screenshots / GIFs + + + +## Tests + + + +- Step 1 +- Step 2 +- Step ... +- + +## Checklist + +- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md). +- [ ] Pull request includes screenshots or videos if containing UI changes +- [ ] Tests written for new code (and old code if feasible). +- [ ] Linter and other CI checks pass. +- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index edd7b6ae5..f5e37a52e 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -25,6 +25,8 @@ jobs: steps: - name: Check it out uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 4ca5ccade..300138e0a 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 diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 3f964ad47..766093289 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -63,11 +63,15 @@ jobs: - name: Add config file run: | - if [ "${{ inputs.package }}" = "full" ]; then - curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json + if [ "${INPUTS_PACKAGE}" = "full" ]; then + curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json else - curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json + curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview_sdk.json" > webapp/config.json fi + env: + INPUTS_PACKAGE: ${{ inputs.package }} + INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }} + INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }} - name: ☁️ Deploy to Netlify id: netlify uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0 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 fc8a640fa..1c6cd7b1c 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -71,7 +71,9 @@ jobs: contents: write # required to upload release asset steps: - name: Determine filename - run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + env: + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -80,9 +82,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 +106,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -123,13 +127,16 @@ jobs: - name: Publish npm working-directory: embedded/web 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' || '' }} + env: + NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }} + NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }} - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" publish_android: needs: [build_element_call, versioning] @@ -143,6 +150,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -161,16 +170,19 @@ jobs: - name: Get artifact version # Anything that is not a final release will be tagged as a snapshot run: | - if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" - elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" else - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV" + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV" fi + env: + NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }} + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: Set version string - run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt + run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt - name: Publish AAR working-directory: embedded/android @@ -184,7 +196,7 @@ jobs: - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" publish_ios: needs: [build_element_call, versioning] @@ -200,6 +212,7 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: path: element-call + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -215,15 +228,18 @@ jobs: repository: element-hq/element-call-swift path: element-call-swift token: ${{ secrets.SWIFT_RELEASE_TOKEN }} + persist-credentials: false - name: Copy files run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift - name: Get artifact version - run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + env: + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: Set version string - run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift + run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift - name: Test build working-directory: element-call-swift @@ -235,17 +251,22 @@ jobs: git config --global user.email "ci@element.io" git config --global user.name "Element CI" git add -A - git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}" - git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}" + git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}" + git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}" + env: + NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }} + GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }} - name: Push working-directory: element-call-swift run: | - git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + env: + SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }} - 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, publish_android, publish_ios] @@ -257,9 +278,13 @@ jobs: steps: - name: Log versions run: | - echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}" - echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}" - echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" + echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}" + echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}" + echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}" + env: + NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }} + NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }} + NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.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/publish.yaml b/.github/workflows/publish.yaml index ceedf7813..0675b1b1e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -38,9 +38,9 @@ jobs: name: build-output-full path: ${{ env.FILENAME_PREFIX }} - name: Create Tarball - run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }} + run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -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 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: 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/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 45f366cdd..ad9f46526 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout the code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 4c0625131..daf968958 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout the code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Upload uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 000000000..612adbd14 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,23 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["livekit", "full-mesh"] + pull_request: {} + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 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/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..9b1a56750 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -250,11 +250,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 705b0f103..9ee0ad263 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@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", @@ -101,7 +101,7 @@ "i18next-browser-languagedetector": "^8.0.0", "i18next-parser": "^9.1.0", "jsdom": "^26.0.0", - "knip": "5.82.1", + "knip": "^5.86.0", "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", 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.test.ts b/playwright/widget/pip-call.test.ts new file mode 100644 index 000000000..49ebec524 --- /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.locator(".mx_WidgetPip_overlay")).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/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/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/room/InCallView.module.css b/src/room/InCallView.module.css index 96b8a368f..55724932d 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) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 135745eb3..d8803b22e 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} @@ -667,7 +645,6 @@ export const InCallView: FC = ({ key="audio" muted={!audioEnabled} onClick={toggleAudio ?? undefined} - onTouchEnd={onControlsTouchEnd} disabled={toggleAudio === null} data-testid="incall_mute" />, @@ -675,7 +652,6 @@ export const InCallView: FC = ({ key="video" muted={!videoEnabled} onClick={toggleVideo ?? undefined} - onTouchEnd={onControlsTouchEnd} disabled={toggleVideo === null} data-testid="incall_videomute" />, @@ -687,7 +663,6 @@ export const InCallView: FC = ({ className={styles.shareScreen} enabled={sharingScreen} onClick={vm.toggleScreenSharing} - onTouchEnd={onControlsTouchEnd} data-testid="incall_screenshare" />, ); @@ -699,18 +674,11 @@ export const InCallView: FC = ({ key="raise_hand" className={styles.raiseHand} identifier={`${client.getUserId()}:${client.getDeviceId()}`} - onTouchEnd={onControlsTouchEnd} />, ); } if (layout.type !== "pip") - buttons.push( - , - ); + buttons.push(); buttons.push( = ({ onClick={function (): void { vm.hangup(); }} - onTouchEnd={onControlsTouchEnd} data-testid="incall_leave" />, ); @@ -751,7 +718,6 @@ export const InCallView: FC = ({ className={styles.layout} layout={gridMode} setLayout={setGridMode} - onTouchEnd={onControlsTouchEnd} /> )} @@ -760,12 +726,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/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index 71475b8c9..9d873ccba 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -9,6 +9,7 @@ import { expect, onTestFinished, test, vi } from "vitest"; import { type LocalTrackPublication, LocalVideoTrack, + Track, TrackEvent, } from "livekit-client"; import { waitFor } from "@testing-library/dom"; @@ -21,6 +22,7 @@ import { mockRemoteMedia, withTestScheduler, mockRemoteParticipant, + mockRemoteScreenShare, } from "../../utils/test"; import { constant } from "../Behavior"; @@ -91,17 +93,69 @@ test("control a participant's volume", () => { }); }); -test("toggle fit/contain for a participant's video", () => { - const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); +test("control a participant's screen share volume", () => { + const setVolumeSpy = vi.fn(); + const vm = mockRemoteScreenShare( + rtcMembership, + {}, + mockRemoteParticipant({ setVolume: setVolumeSpy }), + ); withTestScheduler(({ expectObservable, schedule }) => { - schedule("-ab|", { - a: () => vm.toggleCropVideo(), - b: () => vm.toggleCropVideo(), + schedule("-ab---c---d|", { + a() { + // Try muting by toggling + vm.togglePlaybackMuted(); + expect(setVolumeSpy).toHaveBeenLastCalledWith( + 0, + Track.Source.ScreenShareAudio, + ); + }, + b() { + // Try unmuting by dragging the slider back up + vm.adjustPlaybackVolume(0.6); + vm.adjustPlaybackVolume(0.8); + vm.commitPlaybackVolume(); + expect(setVolumeSpy).toHaveBeenCalledWith( + 0.6, + Track.Source.ScreenShareAudio, + ); + expect(setVolumeSpy).toHaveBeenLastCalledWith( + 0.8, + Track.Source.ScreenShareAudio, + ); + }, + c() { + // Try muting by dragging the slider back down + vm.adjustPlaybackVolume(0.2); + vm.adjustPlaybackVolume(0); + vm.commitPlaybackVolume(); + expect(setVolumeSpy).toHaveBeenCalledWith( + 0.2, + Track.Source.ScreenShareAudio, + ); + expect(setVolumeSpy).toHaveBeenLastCalledWith( + 0, + Track.Source.ScreenShareAudio, + ); + }, + d() { + // Try unmuting by toggling + vm.togglePlaybackMuted(); + // The volume should return to the last non-zero committed volume + expect(setVolumeSpy).toHaveBeenLastCalledWith( + 0.8, + Track.Source.ScreenShareAudio, + ); + }, }); - expectObservable(vm.cropVideo$).toBe("abc", { - a: true, - b: false, - c: true, + expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", { + a: 1, + b: 0, + c: 0.6, + d: 0.8, + e: 0.2, + f: 0, + g: 0.8, }); }); }); diff --git a/src/state/media/RemoteScreenShareViewModel.ts b/src/state/media/RemoteScreenShareViewModel.ts index eff6d9c14..8c46aeb33 100644 --- a/src/state/media/RemoteScreenShareViewModel.ts +++ b/src/state/media/RemoteScreenShareViewModel.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type RemoteParticipant } from "livekit-client"; -import { map } from "rxjs"; +import { Track, type RemoteParticipant } from "livekit-client"; +import { map, of, switchMap } from "rxjs"; import { type Behavior } from "../Behavior"; import { @@ -16,13 +16,20 @@ import { createBaseScreenShare, } from "./ScreenShareViewModel"; import { type ObservableScope } from "../ObservableScope"; +import { createVolumeControls, type VolumeControls } from "../VolumeControls"; +import { observeTrackReference$ } from "../observeTrackReference"; -export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { +export interface RemoteScreenShareViewModel + extends BaseScreenShareViewModel, VolumeControls { local: false; /** * Whether this screen share's video should be displayed. */ videoEnabled$: Behavior; + /** + * Whether this screen share should be considered to have an audio track. + */ + audioEnabled$: Behavior; } export interface RemoteScreenShareInputs extends BaseScreenShareInputs { @@ -36,9 +43,30 @@ export function createRemoteScreenShare( ): RemoteScreenShareViewModel { return { ...createBaseScreenShare(scope, inputs), + ...createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + inputs.participant$.pipe( + map( + (p) => (volume) => + p?.setVolume(volume, Track.Source.ScreenShareAudio), + ), + ), + ), + }), local: false, videoEnabled$: scope.behavior( pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), ), + audioEnabled$: scope.behavior( + inputs.participant$.pipe( + switchMap((p) => + p + ? observeTrackReference$(p, Track.Source.ScreenShareAudio) + : of(null), + ), + map(Boolean), + ), + ), }; } diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index 8da5e63aa..16af7f262 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. */ import { + BehaviorSubject, combineLatest, map, type Observable, @@ -30,9 +31,9 @@ import { } from "./MemberMediaViewModel"; import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; import { type ObservableScope } from "../ObservableScope"; -import { createToggle$ } from "../../utils/observable"; import { showConnectionStats } from "../../settings/settings"; import { observeRtpStreamStats$ } from "./observeRtpStreamStats"; +import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts"; /** * A participant's user media (i.e. their microphone and camera feed). @@ -46,7 +47,7 @@ export interface BaseUserMediaViewModel extends MemberMediaViewModel { speaking$: Behavior; audioEnabled$: Behavior; videoEnabled$: Behavior; - cropVideo$: Behavior; + videoFit$: Behavior<"cover" | "contain">; toggleCropVideo: () => void; /** * The expected identity of the LiveKit participant. Exposed for debugging. @@ -60,6 +61,13 @@ export interface BaseUserMediaViewModel extends MemberMediaViewModel { videoStreamStats$: Observable< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; + /** + * Set the target dimensions of the HTML element (final dimension after anim). + * This can be used to determine the best video fit (fit to frame / keep ratio). + * @param targetWidth - The target width of the HTML element displaying the video. + * @param targetHeight - The target height of the HTML element displaying the video. + */ + setTargetDimensions: (targetWidth: number, targetHeight: number) => void; } export interface BaseUserMediaInputs extends Omit< @@ -90,6 +98,12 @@ export function createBaseUserMedia( ); const toggleCropVideo$ = new Subject(); + // The target size of the video element, used to determine the best video fit. + // The target size is the final size of the HTML element after any animations have completed. + const targetSize$ = new BehaviorSubject< + { width: number; height: number } | undefined + >(undefined); + return { ...createMemberMedia(scope, { ...inputs, @@ -115,7 +129,11 @@ export function createBaseUserMedia( videoEnabled$: scope.behavior( media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), ), - cropVideo$: createToggle$(scope, true, toggleCropVideo$), + videoFit$: videoFit$( + scope, + videoSizeFromParticipant$(participant$), + targetSize$, + ), toggleCropVideo: () => toggleCropVideo$.next(), rtcBackendIdentity, handRaised$, @@ -139,5 +157,8 @@ export function createBaseUserMedia( return observeRtpStreamStats$(p, Track.Source.Camera, statsType); }), ), + setTargetDimensions: (targetWidth: number, targetHeight: number): void => { + targetSize$.next({ width: targetWidth, height: targetHeight }); + }, }; } diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts index d1640382a..63fb1a1b0 100644 --- a/src/state/media/observeRtpStreamStats.ts +++ b/src/state/media/observeRtpStreamStats.ts @@ -67,3 +67,12 @@ export function observeInboundRtpStreamStats$( map((x) => x as RTCInboundRtpStreamStats | undefined), ); } + +export function observeOutboundRtpStreamStats$( + participant: Participant, + source: Track.Source, +): Observable { + return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe( + map((x) => x as RTCOutboundRtpStreamStats | undefined), + ); +} diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 9c3adea78..c8052a659 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -11,6 +11,7 @@ import { type ReactNode, type Ref, useCallback, + useEffect, useRef, useState, } from "react"; @@ -26,7 +27,6 @@ import { VolumeOffIcon, VisibilityOnIcon, UserProfileIcon, - ExpandIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -87,6 +87,8 @@ const UserMediaTile: FC = ({ displayName, mxcAvatarUrl, focusable, + targetWidth, + targetHeight, ...props }) => { const { toggleRaisedHand } = useReactionsSender(); @@ -103,18 +105,19 @@ const UserMediaTile: FC = ({ const audioEnabled = useBehavior(vm.audioEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$); const speaking = useBehavior(vm.speaking$); - const cropVideo = useBehavior(vm.cropVideo$); - const onSelectFitContain = useCallback( - (e: Event) => { - e.preventDefault(); - vm.toggleCropVideo(); - }, - [vm], - ); + const videoFit = useBehavior(vm.videoFit$); + const rtcBackendIdentity = vm.rtcBackendIdentity; const handRaised = useBehavior(vm.handRaised$); const reaction = useBehavior(vm.reaction$); + // Whenever bounds change, inform the viewModel + useEffect(() => { + if (targetWidth > 0 && targetHeight > 0) { + vm.setTargetDimensions(targetWidth, targetHeight); + } + }, [targetWidth, targetHeight, vm]); + const AudioIcon = playbackMuted ? VolumeOffSolidIcon : audioEnabled @@ -130,12 +133,10 @@ const UserMediaTile: FC = ({ const menu = ( <> {menuStart} - + {/* + No additional menu item (used to be the manual fit to frame. + Placeholder for future menu items that should be placed here. + */} {menuEnd} ); @@ -154,7 +155,7 @@ const UserMediaTile: FC = ({ unencryptedWarning={unencryptedWarning} encryptionStatus={encryptionStatus} videoEnabled={videoEnabled} - videoFit={cropVideo ? "cover" : "contain"} + videoFit={videoFit} className={classNames(className, styles.tile, { [styles.speaking]: showSpeaking, [styles.handRaised]: !showSpeaking && handRaised, @@ -200,6 +201,8 @@ const UserMediaTile: FC = ({ audioStreamStats={audioStreamStats} videoStreamStats={videoStreamStats} rtcBackendIdentity={rtcBackendIdentity} + targetWidth={targetWidth} + targetHeight={targetHeight} {...props} /> ); diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 622496d23..af0e0add5 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -84,7 +84,6 @@ Please see LICENSE in the repository root for full details. .expand { appearance: none; cursor: pointer; - opacity: 0; padding: var(--cpd-space-2x); border: none; border-radius: var(--cpd-radius-pill-effect); @@ -108,6 +107,35 @@ Please see LICENSE in the repository root for full details. z-index: 1; } +.volumeSlider { + width: 100%; + min-width: 172px; +} + +/* Disable the hover effect for the screen share volume menu button */ +.volumeMenuItem:hover { + background: transparent; + cursor: default; +} + +.volumeMenuItem { + gap: var(--cpd-space-3x); +} + +.menuMuteButton { + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; +} + +/* Make icons change color with the theme */ +.menuMuteButton > svg { + color: var(--cpd-color-icon-primary); +} + .expand > svg { display: block; color: var(--cpd-color-icon-primary); @@ -119,17 +147,22 @@ Please see LICENSE in the repository root for full details. } } -.expand:active { +.expand:active, +.expand[data-state="open"] { background: var(--cpd-color-gray-100); } @media (hover) { + .tile > div > button { + opacity: 0; + } .tile:hover > div > button { opacity: 1; } } -.tile:has(:focus-visible) > div > button { +.tile:has(:focus-visible) > div > button, +.tile > div:has([data-state="open"]) > button { opacity: 1; } diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index a53321942..aac81b9cd 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -9,6 +9,7 @@ import { test, expect, vi } from "vitest"; import { isInaccessible, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; import { SpotlightTile } from "./SpotlightTile"; import { @@ -18,6 +19,7 @@ import { mockLocalMedia, mockRemoteMedia, mockRemoteParticipant, + mockRemoteScreenShare, } from "../utils/test"; import { SpotlightTileViewModel } from "../state/TileViewModel"; import { constant } from "../state/Behavior"; @@ -78,3 +80,63 @@ test("SpotlightTile is accessible", async () => { await user.click(screen.getByRole("button", { name: "Expand" })); expect(toggleExpanded).toHaveBeenCalled(); }); + +test("Screen share volume UI is shown when screen share has audio", async () => { + const vm = mockRemoteScreenShare( + mockRtcMembership("@alice:example.org", "AAAA"), + {}, + mockRemoteParticipant({}), + ); + + vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true)); + + const toggleExpanded = vi.fn(); + const { container } = render( + + + , + ); + + expect(await axe(container)).toHaveNoViolations(); + + // Volume menu button should exist + expect(screen.queryByRole("button", { name: /volume/i })).toBeInTheDocument(); +}); + +test("Screen share volume UI is hidden when screen share has no audio", async () => { + const vm = mockRemoteScreenShare( + mockRtcMembership("@alice:example.org", "AAAA"), + {}, + mockRemoteParticipant({}), + ); + + vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false)); + + const toggleExpanded = vi.fn(); + const { container } = render( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + + // Volume menu button should not exist + expect( + screen.queryByRole("button", { name: /volume/i }), + ).not.toBeInTheDocument(); +}); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 75c69479a..aa66d6b60 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -20,6 +20,10 @@ import { CollapseIcon, ChevronLeftIcon, ChevronRightIcon, + VolumeOffIcon, + VolumeOnIcon, + VolumeOffSolidIcon, + VolumeOnSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; @@ -27,6 +31,7 @@ import { useObservableRef } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; +import { Menu, MenuItem } from "@vector-im/compound-web"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; @@ -45,6 +50,8 @@ import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel"; import { type MediaViewModel } from "../state/media/MediaViewModel"; +import { Slider } from "../Slider"; +import { platform } from "../Platform"; interface SpotlightItemBaseProps { ref?: Ref; @@ -104,12 +111,12 @@ const SpotlightUserMediaItem: FC = ({ vm, ...props }) => { - const cropVideo = useBehavior(vm.cropVideo$); + const videoFit = useBehavior(vm.videoFit$); const videoEnabled = useBehavior(vm.videoEnabled$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { - videoFit: cropVideo ? "cover" : "contain", + videoFit, videoEnabled, ...props, }; @@ -151,7 +158,13 @@ const SpotlightRemoteScreenShareItem: FC< interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; + /** + * The width this tile will have once its animations have settled. + */ targetWidth: number; + /** + * The height this tile will have once its animations have settled. + */ targetHeight: number; focusable: boolean; intersectionObserver$: Observable; @@ -173,6 +186,16 @@ const SpotlightItem: FC = ({ "aria-hidden": ariaHidden, }) => { const ourRef = useRef(null); + + // Whenever target bounds change, inform the viewModel + useEffect(() => { + if (targetWidth > 0 && targetHeight > 0) { + if (vm.type != "screen share") { + vm.setTargetDimensions(targetWidth, targetHeight); + } + } + }, [targetWidth, targetHeight, vm]); + const ref = useMergedRefs(ourRef, theirRef); const focusUrl = useBehavior(vm.focusUrl$); const displayName = useBehavior(vm.displayName$); @@ -224,6 +247,73 @@ const SpotlightItem: FC = ({ SpotlightItem.displayName = "SpotlightItem"; +interface ScreenShareVolumeButtonProps { + vm: RemoteScreenShareViewModel; +} + +const ScreenShareVolumeButton: FC = ({ vm }) => { + const { t } = useTranslation(); + + const audioEnabled = useBehavior(vm.audioEnabled$); + const playbackMuted = useBehavior(vm.playbackMuted$); + const playbackVolume = useBehavior(vm.playbackVolume$); + + const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon; + const VolumeSolidIcon = playbackMuted + ? VolumeOffSolidIcon + : VolumeOnSolidIcon; + + const [volumeMenuOpen, setVolumeMenuOpen] = useState(false); + const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]); + const onVolumeChange = useCallback( + (v: number) => vm.adjustPlaybackVolume(v), + [vm], + ); + const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]); + + return ( + audioEnabled && ( + + + + } + > + + + + + + ) + ); +}; + interface Props { ref?: Ref; vm: SpotlightTileViewModel; @@ -258,6 +348,7 @@ export const SpotlightTile: FC = ({ const latestMedia = useLatest(media); const latestVisibleId = useLatest(visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId); + const visibleMedia = media.at(visibleIndex); const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; @@ -365,16 +456,21 @@ export const SpotlightTile: FC = ({ /> ))}
-
- +
+ {visibleMedia?.type === "screen share" && !visibleMedia.local && ( + + )} + {platform === "desktop" && ( + + )} {onToggleExpanded && (