diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..787ddc73 --- /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 edd7b6ae..f5e37a52 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 4ca5ccad..300138e0 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 3f964ad4..76609328 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 32dde869..763d2eac 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 fc8a640f..1c6cd7b1 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 ceedf781..0675b1b1 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 012de7cb..cd1c94c5 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 45f366cd..ad9f4652 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 4c062513..daf96895 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 00000000..612adbd1 --- /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 a474daed..e24e9823 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 d23d42fe..3be3e653 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 0b0ac7b4..9b1a5675 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 705b0f10..9ee0ad26 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 b42c0ab2..d4ba0006 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 00000000..49ebec52 --- /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 6fe4479b..4562ba5a 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 f8ee22fb..31101197 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 1bed08da..00689a78 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 96b8a368..55724932 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 135745eb..d8803b22 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 6cddc95f..ca6aa467 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 71475b8c..9d873ccb 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 eff6d9c1..8c46aeb3 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 8da5e63a..16af7f26 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 d1640382..63fb1a1b 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 9c3adea7..c8052a65 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 622496d2..af0e0add 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 a5332194..aac81b9c 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 75c69479..aa66d6b6 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 && (