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/blocked.yaml b/.github/workflows/blocked.yaml index 12a4b020..cc7db747 100644 --- a/.github/workflows/blocked.yaml +++ b/.github/workflows/blocked.yaml @@ -1,7 +1,16 @@ name: Prevent blocked on: + # zizmor: ignore[dangerous-triggers] + # Reason: This workflow does not checkout code or use secrets. + # It only reads labels to set a failure status on the PR. pull_request_target: types: [opened, labeled, unlabeled, synchronize] + +permissions: + pull-requests: read + # Required to fail the check on the PR + statuses: write + jobs: prevent-blocked: name: Prevent blocked diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 4ad1a551..3b18b133 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -20,10 +20,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # required to upload release asset - packages: write + packages: write # needed for publishing packages to GHCR + id-token: write # needed for login into tailscale with GitHub OIDC Token steps: - name: Check it out uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -34,26 +37,64 @@ jobs: path: dist - name: Log in to container registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Connect to Tailscale + uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4 + if: github.event_name != 'pull_request' + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:github-actions + + - name: Compute vault jwt role name + id: vault-jwt-role + if: github.event_name != 'pull_request' + run: | + echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT" + + - name: Get team registry token + id: import-secrets + uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3 + if: github.event_name != 'pull_request' + with: + url: https://vault.infra.ci.i.element.dev + role: ${{ steps.vault-jwt-role.outputs.role_name }} + path: service-management/github-actions + jwtGithubAudience: https://vault.infra.ci.i.element.dev + method: jwt + secrets: | + services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ; + services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; + + - name: Login to oci.element.io Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: github.event_name != 'pull_request' + with: + registry: oci-push.vpn.infra.element.io + username: ${{ steps.import-secrets.outputs.OCI_USERNAME }} + password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }} + - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: ${{ inputs.docker_tags}} + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + oci-push.vpn.infra.element.io/element-call + tags: ${{ inputs.docker_tags }} labels: | org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 01553fec..3ddabb0b 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -7,7 +7,7 @@ on: type: string package: type: string # This would ideally be a `choice` type, but that isn't supported yet - description: The package type to be built. Must be one of 'full' or 'embedded' + description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk' required: true build_mode: type: string # This would ideally be a `choice` type, but that isn't supported yet @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache @@ -43,7 +45,7 @@ jobs: - name: Install dependencies run: "yarn install --immutable" - name: Build Element Call - run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }} + run: yarn run build:"$PACKAGE":"$BUILD_MODE" env: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} @@ -52,6 +54,8 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} VITE_APP_VERSION: ${{ inputs.vite_app_version }} NODE_OPTIONS: "--max-old-space-size=4096" + PACKAGE: ${{ inputs.package }} + BUILD_MODE: ${{ inputs.build_mode }} - name: Upload Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6aa5fae6..32ce25c9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -49,7 +49,9 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.run_id }} docker_tags: | @@ -69,3 +71,17 @@ jobs: SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build_sdk_element_call: + # Use the embedded package vite build + uses: ./.github/workflows/build-element-call.yaml + with: + package: sdk + vite_app_version: ${{ github.event.release.tag_name || github.sha }} + build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }} + secrets: + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_URL: ${{ secrets.SENTRY_URL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/changelog-label.yml b/.github/workflows/changelog-label.yml index 8d9acbc2..f11d3130 100644 --- a/.github/workflows/changelog-label.yml +++ b/.github/workflows/changelog-label.yml @@ -1,8 +1,16 @@ name: PR changelog label on: + # zizmor: ignore[dangerous-triggers] + # This is safe because we do not use actions/checkout or execute untrusted code. + # Using pull_request_target is necessary to allow status writes for PRs from forks. pull_request_target: types: [labeled, unlabeled, opened] + +permissions: + pull-requests: read + statuses: write + jobs: pr-changelog-label: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 388192e4..76609328 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -14,6 +14,10 @@ on: deployment_ref: required: true type: string + package: + required: true + type: string + description: Which package to deploy - 'full', 'embedded', or 'sdk' artifact_run_id: required: false type: string @@ -50,7 +54,7 @@ jobs: with: github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} run-id: ${{ inputs.artifact_run_id }} - name: build-output-full + name: build-output-${{ inputs.package }} path: webapp - name: Add redirects file @@ -58,15 +62,23 @@ jobs: run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects - name: Add config file - run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json - + 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 + 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 + 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 with: publish-dir: webapp deploy-message: "Deploy from GitHub Actions" - alias: pr${{ inputs.pr_number }} + alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }} env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 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/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 7b128352..0084b76b 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -1,5 +1,7 @@ name: Deploy previews for PRs on: + # zizmor: ignore[dangerous-triggers] + # Reason: This is now restricted to internal PRs only using the 'if' condition below. workflow_run: workflows: ["Build"] types: @@ -7,7 +9,14 @@ on: jobs: prdetails: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} + # Logic: + # 1. Build must be successful + # 2. Event must be a pull_request + # 3. Head repository must be the SAME as the base repository (No Forks!) + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest outputs: pr_number: ${{ steps.prdetails.outputs.pr_id }} @@ -20,7 +29,7 @@ jobs: owner: ${{ github.event.workflow_run.head_repository.owner.login }} branch: ${{ github.event.workflow_run.head_branch }} - netlify: + netlify-full: needs: prdetails permissions: deployments: write @@ -31,6 +40,24 @@ jobs: pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }} pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }} deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }} + package: full + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + + netlify-sdk: + needs: prdetails + permissions: + deployments: write + uses: ./.github/workflows/deploy-to-netlify.yaml + with: + artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} + pr_number: ${{ needs.prdetails.outputs.pr_number }} + pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }} + pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }} + deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }} + package: sdk secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} @@ -42,7 +69,9 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} docker_tags: | diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index 275397b5..3ab275a5 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -22,8 +22,18 @@ jobs: TAG: ${{ steps.tag.outputs.TAG }} steps: - name: Calculate VERSION - # We should only use the hard coded test value for a dry run - run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV" + # Safely store dynamic values in environment variables + # to prevent shell injection (template-injection) + run: | + # The logic is executed within the shell using the env variables + if [ "$EVENT_NAME" = "release" ]; then + echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV" + else + echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV" + fi + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT_NAME: ${{ github.event_name }} - id: dry_run name: Set DRY_RUN # We perform a dry run for all events except releases. @@ -71,7 +81,9 @@ jobs: contents: write # required to upload release asset steps: - name: Determine filename - run: echo "FILENAME_PREFIX=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 +92,9 @@ jobs: name: build-output-embedded path: ${{ env.FILENAME_PREFIX}} - name: Create Tarball - run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }} + run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX} - name: Create Checksum - run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 + run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -104,6 +116,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -123,13 +137,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 +160,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 @@ -153,7 +172,7 @@ jobs: path: embedded/android/lib/src/main/assets/element-call - name: ☕️ Setup Java - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: "temurin" java-version: "17" @@ -161,16 +180,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 +206,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 +222,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 +238,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 +261,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 +288,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 7f2c58fe..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: @@ -55,7 +55,9 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} docker_tags: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3251f50e..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 @@ -22,7 +24,7 @@ jobs: - name: Vitest run: "yarn run test:coverage" - name: Upload to codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -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 76fe418c..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 @@ -42,7 +44,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/localazy-download diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 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..d3b6e969 --- /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@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 diff --git a/config/config_netlify_preview_sdk.json b/config/config_netlify_preview_sdk.json new file mode 100644 index 00000000..784f0c7e --- /dev/null +++ b/config/config_netlify_preview_sdk.json @@ -0,0 +1,16 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://call-unstable.ems.host", + "server_name": "call-unstable.ems.host" + } + }, + "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_session": { + "wait_for_key_rotation_ms": 3000, + "membership_event_expiry_ms": 180000000, + "delayed_leave_event_delay_ms": 18000, + "delayed_leave_event_restart_ms": 4000, + "network_error_retry_ms": 100 + } +} diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index 31694c01..53bb2941 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -47,7 +47,7 @@ services: - ecbackend livekit: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu command: --dev --config /etc/livekit.yaml @@ -70,7 +70,7 @@ services: - ecbackend livekit-1: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu-1 command: --dev --config /etc/livekit.yaml @@ -94,7 +94,7 @@ services: synapse: hostname: homeserver - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml @@ -112,7 +112,7 @@ services: synapse-1: hostname: homeserver-1 - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml diff --git a/docs/url-params.md b/docs/url-params.md index 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/embedded/android/gradle/libs.versions.toml b/embedded/android/gradle/libs.versions.toml index 5a91e19e..a93dc56e 100644 --- a/embedded/android/gradle/libs.versions.toml +++ b/embedded/android/gradle/libs.versions.toml @@ -2,11 +2,11 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] -android_gradle_plugin = "8.13.1" +android_gradle_plugin = "8.13.2" [libraries] android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } [plugins] android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } -maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" } \ No newline at end of file +maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } \ No newline at end of file diff --git a/embedded/android/gradle/wrapper/gradle-wrapper.properties b/embedded/android/gradle/wrapper/gradle-wrapper.properties index 7705927e..de413606 100644 --- a/embedded/android/gradle/wrapper/gradle-wrapper.properties +++ b/embedded/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/knip.ts b/knip.ts index 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..f5749cf7 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -249,12 +249,14 @@ "version": "{{productName}} version: {{version}}", "video_tile": { "always_show": "Always show", + "call_ended": "Call ended", + "calling": "Calling…", "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 14193013..cc8a36eb 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "build:embedded": "yarn build:full --config vite-embedded.config.js", "build:embedded:production": "yarn build:embedded", "build:embedded:development": "yarn build:embedded --mode development", - "build:sdk": "yarn build:full --config vite-sdk.config.js", "build:sdk:development": "yarn build:sdk --mode development", + "build:sdk": "yarn build:full --config vite-sdk.config.js", + "build:sdk:production": "yarn build:sdk", "serve": "vite preview", "prettier:check": "prettier -c .", "prettier:format": "prettier -w .", @@ -42,12 +43,12 @@ "@codecov/vite-plugin": "^1.3.0", "@fontsource/inconsolata": "^5.1.0", "@fontsource/inter": "^5.1.0", - "@formatjs/intl-durationformat": "^0.9.0", + "@formatjs/intl-durationformat": "^0.10.0", "@formatjs/intl-segmenter": "^11.7.3", "@livekit/components-core": "^0.12.0", "@livekit/components-react": "^2.0.0", "@livekit/protocol": "^1.42.2", - "@livekit/track-processors": "^0.6.0 || ^0.7.1", + "@livekit/track-processors": "^0.7.1", "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.57.0", "@radix-ui/react-dialog": "^1.0.4", @@ -78,7 +79,7 @@ "@vector-im/compound-design-tokens": "^6.0.0", "@vector-im/compound-web": "^8.0.0", "@vitejs/plugin-react": "^4.0.1", - "@vitest/coverage-v8": "^3.0.0", + "@vitest/coverage-v8": "^4.0.18", "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", @@ -100,11 +101,11 @@ "i18next-browser-languagedetector": "^8.0.0", "i18next-parser": "^9.1.0", "jsdom": "^26.0.0", - "knip": "^5.27.2", + "knip": "^5.86.0", "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf", "matrix-widget-api": "^1.16.1", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", @@ -117,7 +118,7 @@ "qrcode": "^1.5.4", "react": "19", "react-dom": "19", - "react-i18next": "^16.0.0 <16.1.0", + "react-i18next": "^16.0.0 <16.6.0", "react-router-dom": "^7.0.0", "react-use-measure": "^2.1.1", "rxjs": "^7.8.1", @@ -127,17 +128,22 @@ "unique-names-generator": "^4.6.0", "uuid": "^13.0.0", "vaul": "^1.0.0", - "vite": "^7.0.0", + "vite": "^7.3.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-node-stdlib-browser": "^0.2.1", "vite-plugin-svgr": "^4.0.0", - "vitest": "^3.0.0", + "vitest": "^4.0.18", "vitest-axe": "^1.0.0-pre.3" }, "resolutions": { "@livekit/components-core/rxjs": "^7.8.1", - "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18" + "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", + "minimatch": "^10.2.3", + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts index 6f03272e..b71f39ad 100644 --- a/playwright/create-call.spec.ts +++ b/playwright/create-call.spec.ts @@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({ await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); // Check the button toolbar - // await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible(); - // await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible(); + // await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible(); + // await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible(); await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("button", { name: "End call" })).toBeVisible(); diff --git a/playwright/mobile/create-call-mobile.spec.ts b/playwright/mobile/create-call-mobile.spec.ts index 141ffaae..1c4eef57 100644 --- a/playwright/mobile/create-call-mobile.spec.ts +++ b/playwright/mobile/create-call-mobile.spec.ts @@ -100,8 +100,16 @@ mobileTest( { id: "earpiece", name: "Handset", isEarpiece: true }, { id: "headphones", name: "Headphones" }, ]); - window.controls.setAudioDevice("earpiece"); }); + + // Open settings to select earpiece + await guestPage.getByRole("button", { name: "Settings" }).click(); + await guestPage.getByText("Handset", { exact: true }).click(); + + // dismiss settings + await guestPage.locator("#root").getByLabel("Settings").press("Escape"); + + await guestPage.pause(); await expect( guestPage.getByRole("heading", { name: "Handset Mode" }), ).toBeVisible(); diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts index 3b419af4..1a8f2c28 100644 --- a/playwright/reconnect.spec.ts +++ b/playwright/reconnect.spec.ts @@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({ ).toBeVisible(); // Tab order should jump directly from header to footer, skipping media tiles - await page.getByRole("button", { name: "Mute microphone" }).focus(); + await page.getByRole("switch", { name: "Mute microphone" }).focus(); await expect( - page.getByRole("button", { name: "Mute microphone" }), + page.getByRole("switch", { name: "Mute microphone" }), ).toBeFocused(); await page.keyboard.press("Tab"); - await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused(); + await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused(); // Most critically, we should be able to press the hangup button await page.getByRole("button", { name: "End call" }).click(); }); diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index b42c0ab2..9c7cfc80 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { const frame = user.page .locator('iframe[title="Element Call"]') .contentFrame(); - // No lobby, should start with video on - // 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 - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + await expect( + frame.getByRole("switch", { name: "Stop video", checked: true }), + ).toBeVisible(); } // We should see 5 video tiles everywhere now @@ -101,13 +98,15 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { const florianFrame = florian.page .locator('iframe[title="Element Call"]') .contentFrame(); - const florianMuteButton = florianFrame.getByTestId("incall_videomute"); - await florianMuteButton.click(); + const florianVideoButton = florianFrame.getByRole("switch", { + name: /video/, + }); + await expect(florianVideoButton).toHaveAccessibleName("Stop video"); + await expect(florianVideoButton).toBeChecked(); + await florianVideoButton.click(); // Now the button should indicate we can start video - await expect(florianMuteButton).toHaveAttribute( - "aria-label", - /^Start video$/, - ); + await expect(florianVideoButton).toHaveAccessibleName("Start video"); + await expect(florianVideoButton).not.toBeChecked(); // wait a bit for the state to propagate await valere.page.waitForTimeout(3000); diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts new file mode 100644 index 00000000..5e59f822 --- /dev/null +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -0,0 +1,73 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); + + const valere = await addUser("Valere", HOST1); + + const callRoom = "CallRoom"; + await TestHelpers.createRoom("CallRoom", valere.page); + + await TestHelpers.createRoom("OtherRoom", valere.page); + + await TestHelpers.switchToRoomNamed(valere.page, callRoom); + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await TestHelpers.joinCallFromLobby(valere.page); + // wait a bit so that the PIP has rendered + await valere.page.waitForTimeout(600); + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom"); + + // We should see the PIP overlay + const iFrame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + { + // Check for a bug where the video had the wrong fit in PIP + const audioBtn = iFrame.getByRole("switch", { name: /microphone/ }); + const videoBtn = iFrame.getByRole("switch", { name: /video/ }); + await expect( + iFrame.getByRole("button", { name: "End call" }), + ).toBeVisible(); + await expect(audioBtn).toBeVisible(); + await expect(videoBtn).toBeVisible(); + await expect(audioBtn).toHaveAccessibleName("Mute microphone"); + await expect(audioBtn).toBeChecked(); + await expect(videoBtn).toHaveAccessibleName("Stop video"); + await expect(videoBtn).toBeChecked(); + + await videoBtn.click(); + await audioBtn.click(); + + // stop hovering on any of the buttons + await iFrame.getByTestId("videoTile").hover(); + + await expect(audioBtn).toHaveAccessibleName("Unmute microphone"); + await expect(audioBtn).not.toBeChecked(); + await expect(videoBtn).toHaveAccessibleName("Start video"); + await expect(videoBtn).not.toBeChecked(); + } +}); diff --git a/playwright/widget/pip-call.test.ts b/playwright/widget/pip-call.test.ts new file mode 100644 index 00000000..63ba2050 --- /dev/null +++ b/playwright/widget/pip-call.test.ts @@ -0,0 +1,72 @@ +/* +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(); + + // check that the video is on + await expect( + frame.getByRole("switch", { name: "Stop video", checked: true }), + ).toBeVisible(); + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); + + // We should see the PIP overlay + await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible(); + + { + // wait a bit so that the PIP has rendered the video + await valere.page.waitForTimeout(600); + + // Check for a bug where the video had the wrong fit in PIP + const frame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(1); + + const pipVideo = videoElements[0]; + await expect(pipVideo).toHaveCSS("object-fit", "cover"); + } +}); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 6fe4479b..630681bd 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -152,6 +152,22 @@ export class TestHelpers { } } + public static async maybeDismissKeyBackupToast(page: Page): Promise { + const toast = page + .locator(".mx_Toast_toast") + .getByText("Back up your chats"); + + try { + await expect(toast).toBeVisible({ timeout: 700 }); + await page + .locator(".mx_Toast_toast") + .getByRole("button", { name: "Dismiss" }) + .click(); + } catch { + // toast not visible, continue as normal + } + } + public static async createRoom( name: string, page: Page, @@ -167,6 +183,7 @@ export class TestHelpers { await page.getByRole("button", { name: "Create room" }).click(); await expect(page.getByText("You created this room.")).toBeVisible(); await expect(page.getByText("Encryption enabled")).toBeVisible(); + await TestHelpers.maybeDismissKeyBackupToast(page); // Invite users if any if (andInvite.length > 0) { @@ -201,6 +218,7 @@ export class TestHelpers { await expect( page.getByRole("main").getByRole("heading", { name: roomName }), ).toBeVisible(); + await TestHelpers.maybeDismissKeyBackupToast(page); } /** @@ -276,4 +294,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/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts index 6a8473cf..a4e6255b 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -34,9 +34,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming voice call")).toBeVisible(); @@ -51,34 +54,36 @@ widgetTest( .contentFrame(); // ASSERT the button states for whistler (the callee) - { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = whistlerFrame.getByTestId("incall_videomute"); - // video should be off by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); - - const audioButton = whistlerFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Start video", + checked: false, + }), + ).toBeVisible(); + // audio should be on for the voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).toBeVisible(); // ASSERT the button states for brools (the caller) - { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = brooksFrame.getByTestId("incall_videomute"); - // video should be off by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); - - const audioButton = brooksFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Start video", + checked: false, + }), + ).toBeVisible(); + // audio should be on for the voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).toBeVisible(); // In order to confirm that the call is disconnected we will check that the message composer is shown again. // So first we need to confirm that it is hidden when in the call. @@ -90,10 +95,7 @@ widgetTest( ).not.toBeVisible(); // ASSERT hanging up on one side ends the call for both - { - const hangupButton = brooksFrame.getByTestId("incall_leave"); - await hangupButton.click(); - } + await brooksFrame.getByRole("button", { name: "End call" }).click(); // The widget should be closed on both sides and the timeline should be back on screen await expect( @@ -125,9 +127,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); @@ -142,34 +147,30 @@ widgetTest( .contentFrame(); // ASSERT the button states for whistler (the callee) - { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = whistlerFrame.getByTestId("incall_videomute"); - // video should be on by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); - - const audioButton = whistlerFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a video call + await expect( + whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).toBeVisible(); // ASSERT the button states for brools (the caller) - { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = brooksFrame.getByTestId("incall_videomute"); - // video should be on by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); - - const audioButton = brooksFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a video call + await expect( + whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).toBeVisible(); // In order to confirm that the call is disconnected we will check that the message composer is shown again. // So first we need to confirm that it is hidden when in the call. @@ -181,10 +182,7 @@ widgetTest( ).not.toBeVisible(); // ASSERT hanging up on one side ends the call for both - { - const hangupButton = brooksFrame.getByTestId("incall_leave"); - await hangupButton.click(); - } + await brooksFrame.getByRole("button", { name: "End call" }).click(); // The widget should be closed on both sides and the timeline should be back on screen await expect( @@ -216,9 +214,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); diff --git a/sdk/README.md b/sdk/README.md index 91337f10..ad8ff97e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,4 +1,4 @@ -# SDK mode +# SDK mode (EXPERIMENTAL) EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps. diff --git a/sdk/helper.ts b/sdk/helper.ts index a3d597be..47de4a93 100644 --- a/sdk/helper.ts +++ b/sdk/helper.ts @@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details. import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { scan } from "rxjs"; -import { widget as _widget } from "../src/widget"; +import { type WidgetHelpers } from "../src/widget"; import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel"; export const logger = rootLogger.getChild("[MatrixRTCSdk]"); -if (!_widget) throw Error("No widget. This webapp can only start as a widget"); -export const widget = _widget; - -export const tryMakeSticky = (): void => { +export const tryMakeSticky = (widget: WidgetHelpers): void => { logger.info("try making sticky MatrixRTCSdk"); void widget.api .setAlwaysOnScreen(true) diff --git a/sdk/main.ts b/sdk/main.ts index a273ed8a..c65bf4a7 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details. */ /** + * EXPERIMENTAL + * * This file is the entrypoint for the sdk build of element call: `yarn build:sdk` * use in widgets. * It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that @@ -30,8 +32,8 @@ import { } from "rxjs"; import { type CallMembership, - MatrixRTCSession, MatrixRTCSessionEvent, + MatrixRTCSessionManager, } from "matrix-js-sdk/lib/matrixrtc"; import { type Room as LivekitRoom, @@ -50,14 +52,12 @@ import { getUrlParams } from "../src/UrlParams"; import { MuteStates } from "../src/state/MuteStates"; import { MediaDevices } from "../src/state/MediaDevices"; import { E2eeType } from "../src/e2ee/e2eeType"; +import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper"; import { - currentAndPrev, - logger, - TEXT_LK_TOPIC, - tryMakeSticky, - widget, -} from "./helper"; -import { ElementWidgetActions, initializeWidget } from "../src/widget"; + ElementWidgetActions, + widget as _widget, + initializeWidget, +} from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; interface MatrixRTCSdk { @@ -68,7 +68,13 @@ interface MatrixRTCSdk { join: () => void; /** @throws on leave errors */ leave: () => void; - data$: Observable<{ sender: string; data: string }>; + /** + * Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope. + * No updates can be received from the rtc sdk. The sdk cannot be restarted after. + * A new sdk needs to be created via createMatrixRTCSdk. + */ + stop: () => void; + data$: Observable<{ rtcBackendIdentity: string; data: string }>; /** * flattened list of members */ @@ -79,32 +85,54 @@ interface MatrixRTCSdk { participant: LocalParticipant | RemoteParticipant | null; }[] >; + /** + * flattened local members + */ + localMember$: Behavior<{ + connection: Connection | null; + membership: CallMembership; + participant: LocalParticipant | null; + } | null>; /** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */ connected$: Behavior; sendData?: (data: unknown) => Promise; + sendRoomMessage?: (message: string) => Promise; } export async function createMatrixRTCSdk( application: string = "m.call", id: string = "", + sticky: boolean = false, ): Promise { - initializeWidget(); + const scope = new ObservableScope(); + + // widget client + initializeWidget(application, true); + const widget = _widget; + if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; logger.info("client created"); - const scope = new ObservableScope(); + + // url params const { roomId } = getUrlParams(); if (roomId === null) throw Error("could not get roomId from url params"); - const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); + // rtc session + const slot = { application, id }; + const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot); + rtcSessionManager.start(); + const rtcSession = rtcSessionManager.getRoomSession(room); + + // media devices const mediaDevices = new MediaDevices(scope); const muteStates = new MuteStates(scope, mediaDevices, { - audioEnabled: true, - videoEnabled: true, + audioEnabled: false, + videoEnabled: false, }); - const slot = { application, id }; - const rtcSession = new MatrixRTCSession(client, room, slot); + + // call view model const callViewModel = createCallViewModel$( scope, rtcSession, @@ -117,8 +145,9 @@ export async function createMatrixRTCSdk( constant({ supported: false, processor: undefined }), ); logger.info("CallViewModelCreated"); + // create data listener - const data$ = new Subject<{ sender: string; data: string }>(); + const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>(); const lkTextStreamHandlerFunction = async ( reader: TextStreamReader, @@ -140,7 +169,7 @@ export async function createMatrixRTCSdk( if (participants && participants.includes(participantInfo.identity)) { const text = await reader.readAll(); logger.info(`Received text: ${text}`); - data$.next({ sender: participantInfo.identity, data: text }); + data$.next({ rtcBackendIdentity: participantInfo.identity, data: text }); } else { logger.warn( "Received text from unknown participant", @@ -230,6 +259,16 @@ export async function createMatrixRTCSdk( } }; + const sendRoomMessage = async (message: string): Promise => { + const messageString = JSON.stringify(message); + logger.info("try sending to room: ", messageString); + try { + await client.sendTextMessage(room.roomId, message); + } catch (e) { + logger.error("failed sending to room: ", messageString, e); + } + }; + // after hangup gets called const leaveSubs = callViewModel.leave$.subscribe(() => { const scheduleWidgetCloseOnLeave = async (): Promise => { @@ -257,9 +296,6 @@ export async function createMatrixRTCSdk( // schedule close first and then leave (scope.end) void scheduleWidgetCloseOnLeave(); - - // actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast) - scope.end(); }); logger.info("createMatrixRTCSdk done"); @@ -267,15 +303,40 @@ export async function createMatrixRTCSdk( return { join: (): void => { // first lets try making the widget sticky - tryMakeSticky(); + if (sticky) tryMakeSticky(widget); callViewModel.join(); }, leave: (): void => { - callViewModel.hangup(); + callViewModel.leave(); + }, + stop: (): void => { leaveSubs.unsubscribe(); livekitRoomItemsSub.unsubscribe(); + scope.end(); }, data$, + localMember$: scope.behavior( + callViewModel.localMatrixLivekitMember$.pipe( + tap((member) => + logger.info("localMatrixLivekitMember$ next: ", member), + ), + switchMap((member) => { + if (member === null) return of(null); + return combineLatest([ + member.connection$, + member.membership$, + member.participant.value$, + ]).pipe( + map(([connection, membership, participant]) => ({ + connection, + membership, + participant, + })), + ); + }), + tap((member) => logger.info("localMember$ next: ", member)), + ), + ), connected$: callViewModel.connected$, members$: scope.behavior( callViewModel.matrixLivekitMembers$.pipe( @@ -302,5 +363,6 @@ export async function createMatrixRTCSdk( [], ), sendData, + sendRoomMessage, }; } diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index a02963e0..25d2c42b 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,14 +9,18 @@ import { afterEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC, type PropsWithChildren } from "react"; +import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; +import { widget } from "./widget"; const TestComponent: FC< - PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }> -> = ({ client, children, supportsThumbnails }) => { + PropsWithChildren<{ + client: MatrixClient; + }> +> = ({ client, children }) => { return ( ({ + widget: { + api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough + }, +})); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -73,36 +82,7 @@ test("should just render a placeholder when the user has no avatar", () => { expect(client.mxcUrlToHttp).toBeCalledTimes(0); }); -test("should just render a placeholder when thumbnails are not supported", () => { - const client = vi.mocked({ - getAccessToken: () => "my-access-token", - mxcUrlToHttp: () => vi.fn(), - } as unknown as MatrixClient); - - vi.spyOn(client, "mxcUrlToHttp"); - const member = mockMatrixRoomMember( - mockRtcMembership("@alice:example.org", "AAAA"), - { - getMxcAvatarUrl: () => "mxc://example.org/alice-avatar", - }, - ); - const displayName = "Alice"; - render( - - - , - ); - const element = screen.getByRole("img", { name: "@alice:example.org" }); - expect(element.tagName).toEqual("SPAN"); - expect(client.mxcUrlToHttp).toBeCalledTimes(0); -}); - -test("should attempt to fetch authenticated media", async () => { +test("should attempt to fetch authenticated media from the server", async () => { const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedObjectURL = "my-object-url"; const accessToken = "my-access-token"; @@ -154,3 +134,47 @@ test("should attempt to fetch authenticated media", async () => { headers: { Authorization: `Bearer ${accessToken}` }, }); }); + +test("should attempt to use widget API if running as a widget", async () => { + const expectedMXCUrl = "mxc://example.org/alice-avatar"; + const expectedObjectURL = "my-object-url"; + const theBlob = new Blob([]); + + // vitest doesn't have a implementation of create/revokeObjectURL, so we need + // to delete the property. It's a bit odd, but it works. + Reflect.deleteProperty(global.window.URL, "createObjectURL"); + globalThis.URL.createObjectURL = vi.fn().mockReturnValue(expectedObjectURL); + Reflect.deleteProperty(global.window.URL, "revokeObjectURL"); + globalThis.URL.revokeObjectURL = vi.fn(); + + const client = vi.mocked({ + getAccessToken: () => undefined, + } as unknown as MatrixClient); + + widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; + vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); + const member = mockMatrixRoomMember( + mockRtcMembership("@alice:example.org", "AAAA"), + { + getMxcAvatarUrl: () => expectedMXCUrl, + }, + ); + const displayName = "Alice"; + render( + + + , + ); + + // Fetch is asynchronous, so wait for this to resolve. + await vi.waitUntil(() => + document.querySelector(`img[src='${expectedObjectURL}']`), + ); + + expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl); +}); diff --git a/src/Avatar.tsx b/src/Avatar.tsx index d862dbb1..d0cb243c 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,8 +14,10 @@ import { } from "react"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; +import { type WidgetApi } from "matrix-widget-api"; import { useClientState } from "./ClientContext"; +import { widget } from "./widget"; export enum Size { XS = "xs", @@ -78,50 +80,54 @@ export const Avatar: FC = ({ const sizePx = useMemo( () => Object.values(Size).includes(size as Size) - ? sizes.get(size as Size) + ? sizes.get(size as Size)! : (size as number), [size], ); const [avatarUrl, setAvatarUrl] = useState(undefined); + // In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs. useEffect(() => { - if (clientState?.state !== "valid") { - return; - } - const { authenticated, supportedFeatures } = clientState; - const client = authenticated?.client; - - if (!client || !src || !sizePx || !supportedFeatures.thumbnails) { + if (!src) { + setAvatarUrl(undefined); return; } - const token = client.getAccessToken(); - if (!token) { - return; - } - const resolveSrc = getAvatarUrl(client, src, sizePx); - if (!resolveSrc) { + let blob: Promise; + + if (widget?.api) { + blob = getAvatarFromWidgetAPI(widget.api, src); + } else if ( + clientState?.state === "valid" && + clientState.authenticated?.client && + sizePx + ) { + blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx); + } else { setAvatarUrl(undefined); return; } let objectUrl: string | undefined; - fetch(resolveSrc, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - .then(async (req) => req.blob()) + let stale = false; + blob .then((blob) => { + if (stale) { + return; + } objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); }) .catch((ex) => { + if (stale) { + return; + } setAvatarUrl(undefined); }); return (): void => { + stale = true; if (objectUrl) { URL.revokeObjectURL(objectUrl); } @@ -140,3 +146,44 @@ export const Avatar: FC = ({ /> ); }; + +async function getAvatarFromServer( + client: MatrixClient, + src: string, + sizePx: number, +): Promise { + const httpSrc = getAvatarUrl(client, src, sizePx); + if (!httpSrc) { + throw new Error("Failed to get http avatar URL"); + } + + const token = client.getAccessToken(); + if (!token) { + throw new Error("Failed to get access token"); + } + + const request = await fetch(httpSrc, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const blob = await request.blob(); + + return blob; +} + +async function getAvatarFromWidgetAPI( + api: WidgetApi, + src: string, +): Promise { + const response = await api.downloadFile(src); + const file = response.file; + + // element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that + if (!(file instanceof Blob)) { + throw new Error("Downloaded file is not a Blob"); + } + + return file; +} diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 1488965a..f2ff3dd4 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -48,7 +48,6 @@ export type ValidClientState = { disconnected: boolean; supportedFeatures: { reactions: boolean; - thumbnails: boolean; }; setClient: (client: MatrixClient, session: Session) => void; }; @@ -249,7 +248,6 @@ export const ClientProvider: FC = ({ children }) => { const [isDisconnected, setIsDisconnected] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false); - const [supportsThumbnails, setSupportsThumbnails] = useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -275,7 +273,6 @@ export const ClientProvider: FC = ({ children }) => { disconnected: isDisconnected, supportedFeatures: { reactions: supportsReactions, - thumbnails: supportsThumbnails, }, }; }, [ @@ -286,7 +283,6 @@ export const ClientProvider: FC = ({ children }) => { setClient, isDisconnected, supportsReactions, - supportsThumbnails, ]); const onSync = useCallback( @@ -312,8 +308,6 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - // There is currently no widget API for authenticated media thumbnails. - setSupportsThumbnails(false); const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -335,7 +329,6 @@ export const ClientProvider: FC = ({ children }) => { } } else { setSupportsReactions(true); - setSupportsThumbnails(true); } return (): void => { 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/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap index c769ec12..7247c6b9 100644 --- a/src/__snapshots__/AppBar.test.tsx.snap +++ b/src/__snapshots__/AppBar.test.tsx.snap @@ -14,7 +14,7 @@ exports[`AppBar > renders 1`] = ` > + } + > + + + + + + ) + ); +}; + interface Props { ref?: Ref; vm: SpotlightTileViewModel; @@ -232,6 +407,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; @@ -339,16 +515,21 @@ export const SpotlightTile: FC = ({ /> ))} -
- +
+ {visibleMedia?.type === "screen share" && !visibleMedia.local && ( + + )} + {platform === "desktop" && ( + + )} {onToggleExpanded && (