mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
4 Commits
robin/knip
...
fkwp-patch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3b3e847f | ||
|
|
2314417397 | ||
|
|
b2aa3adda3 | ||
|
|
a287d0671b |
@@ -38,6 +38,15 @@ module.exports = {
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
// We should use the js-sdk logger, never console directly.
|
||||
"no-console": ["error"],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
name: "@react-rxjs/core",
|
||||
importNames: ["Subscribe", "RemoveSubscribe"],
|
||||
message:
|
||||
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
|
||||
},
|
||||
],
|
||||
"react/display-name": "error",
|
||||
},
|
||||
settings: {
|
||||
|
||||
49
.github/workflows/build.yaml
vendored
49
.github/workflows/build.yaml
vendored
@@ -1,25 +1,34 @@
|
||||
name: Build
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- synchronize
|
||||
- opened
|
||||
- labeled
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
- "docs/**"
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit, full-mesh]
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
- "docs/**"
|
||||
jobs:
|
||||
build_element_call:
|
||||
uses: ./.github/workflows/element-call.yaml
|
||||
with:
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Build
|
||||
run: "yarn run build"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: dist
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: Element Call - Build
|
||||
name: Build resuable
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
vite_app_version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
SENTRY_ORG:
|
||||
required: true
|
||||
@@ -16,17 +12,16 @@ on:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build_resuable:
|
||||
name: Build Element Call
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Build
|
||||
@@ -36,10 +31,10 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist
|
||||
74
.github/workflows/docker-pr.yaml
vendored
Normal file
74
.github/workflows/docker-pr.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Docker PR Preview
|
||||
# Note pushing docker images is not possible from a public forked repo (see: Can't push to ghcr on a pull request, https://github.com/docker/build-push-action/issues/870)
|
||||
# Hence, this workflow is limited to PRs from element-hq/element-call
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- synchronize
|
||||
- opened
|
||||
- labeled
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# due to https://github.com/actions/runner/issues/2372
|
||||
# and not wanting to workaround like https://stackoverflow.com/questions/73797254/environment-variables-in-github-actions/74217028#74217028
|
||||
# we disable for now a gloabl env variable since env is not available in job.if
|
||||
# DO_DOCKER_BUILD: ${{ contains(github.event.pull_request.labels.*.name, 'docker build') }}
|
||||
# NOT_FROM_FORK: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
|
||||
jobs:
|
||||
build_element_call:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'docker build') && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: ./.github/workflows/build_resuable.yaml
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
build_docker:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'docker build') && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
needs: build_element_call
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5f4866a30a54f16a52d2ecb4a3898e9e424939cf
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c314eea2b27e3cb3c7d7be0618510234d8f6178e
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=raw,value=pr_${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f8bc7f46003ef0cf98b715743d86c2f1ccd01436
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
60
.github/workflows/docker.yaml
vendored
60
.github/workflows/docker.yaml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Docker - Deploy
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
docker_tags:
|
||||
required: true
|
||||
type: string
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
default: ${{ github.run_id }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build & publish docker
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out test private repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: element-hq/static-call-participant
|
||||
ref: refs/heads/main
|
||||
|
||||
7
.github/workflows/lint.yaml
vendored
7
.github/workflows/lint.yaml
vendored
@@ -7,12 +7,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Prettier
|
||||
@@ -23,5 +22,3 @@ jobs:
|
||||
run: "yarn run lint:eslint"
|
||||
- name: Type check
|
||||
run: "yarn run lint:types"
|
||||
- name: Dead code analysis
|
||||
run: "yarn run lint:knip"
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
name: Netlify - Deploy
|
||||
name: Netlify PR Preview
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
required: true
|
||||
type: string
|
||||
pr_head_full_name:
|
||||
required: true
|
||||
type: string
|
||||
pr_head_ref:
|
||||
required: true
|
||||
type: string
|
||||
deployment_ref:
|
||||
required: true
|
||||
type: string
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
default: ${{ github.run_id }}
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
NETLIFY_AUTH_TOKEN:
|
||||
required: true
|
||||
NETLIFY_SITE_ID:
|
||||
required: true
|
||||
pull_request:
|
||||
types:
|
||||
- synchronize
|
||||
- opened
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
build_element_call:
|
||||
uses: ./.github/workflows/build_resuable.yaml
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
deploy:
|
||||
needs: build_element_call
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
deployments: write
|
||||
environment: Netlify
|
||||
steps:
|
||||
- name: 📝 Create Deployment
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
uses: bobheadxi/deployments@v1
|
||||
id: deployment
|
||||
with:
|
||||
step: start
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
env: Netlify
|
||||
ref: ${{ inputs.deployment_ref }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref || github.head_ref }}
|
||||
desc: |
|
||||
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
||||
Exercise caution. Use test accounts.
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output
|
||||
path: webapp
|
||||
|
||||
@@ -58,22 +44,22 @@ 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: curl -s "https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/${{ github.event.pull_request.head.ref }}/config/element_io_preview.json" > webapp/config.json
|
||||
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
uses: nwtgck/actions-netlify@v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
alias: pr${{ inputs.pr_number }}
|
||||
alias: pr${{ github.event.pull_request.number }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: 🚦 Update deployment status
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
uses: bobheadxi/deployments@v1
|
||||
if: always()
|
||||
with:
|
||||
step: finish
|
||||
50
.github/workflows/pr-deploy.yaml
vendored
50
.github/workflows/pr-deploy.yaml
vendored
@@ -1,50 +0,0 @@
|
||||
name: PR Preview Deployments
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
prdetails:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
steps:
|
||||
- id: prdetails
|
||||
uses: matrix-org/pr-details-action@15bde5285d7850ba276cc3bd8a03733e3f24622a # v1.3
|
||||
continue-on-error: true
|
||||
with:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
netlify:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
uses: ./.github/workflows/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 }}
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
docker:
|
||||
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
||||
needs: prdetails
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
uses: ./.github/workflows/docker.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=raw,value=pr_${{ needs.prdetails.outputs.pr_number }}
|
||||
94
.github/workflows/publish.yaml
vendored
94
.github/workflows/publish.yaml
vendored
@@ -3,34 +3,17 @@ name: Build & publish images to the package registry for tags
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
push:
|
||||
branches: [livekit]
|
||||
types:
|
||||
- completed
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build_element_call:
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
uses: ./.github/workflows/element-call.yaml
|
||||
with:
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
publish_tarball:
|
||||
needs: build_element_call
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
build:
|
||||
name: Build & publish
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
unix_time: ${{steps.current-time.outputs.unix_time}}
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
@@ -38,35 +21,64 @@ jobs:
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
|
||||
- name: Check it out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5f4866a30a54f16a52d2ecb4a3898e9e424939cf
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
name: build-output
|
||||
path: dist
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Build
|
||||
run: "yarn run build"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ github.event.release.tag_name || github.sha }}
|
||||
|
||||
- name: Create Tarball
|
||||
env:
|
||||
TARBALL_VERSION: ${{ github.event.release.tag_name || github.sha }}
|
||||
run: |
|
||||
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@ef09cdac3e2d3e60d8ccadda691f4f1cec5035cb
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
path: "./element-call-*.tar.gz"
|
||||
publish_docker:
|
||||
needs: publish_tarball
|
||||
if: always()
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
uses: ./.github/workflows/docker.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=semver,pattern=v{{version}}
|
||||
type=raw,value=latest-ci,enable={{is_default_branch}}
|
||||
type=raw,value=latest-ci_${{needs.publish_tarball.outputs.unix_time}},enable={{is_default_branch}}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c314eea2b27e3cb3c7d7be0618510234d8f6178e
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,format=short,event=branch
|
||||
type=semver,pattern=v{{version}}
|
||||
type=raw,value=latest-ci,enable={{is_default_branch}}
|
||||
type=raw,value=latest-ci_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f8bc7f46003ef0cf98b715743d86c2f1ccd01436
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
11
.github/workflows/test.yaml
vendored
11
.github/workflows/test.yaml
vendored
@@ -9,19 +9,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
run: "yarn run test"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
flags: unittests
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
9
.github/workflows/translations-download.yaml
vendored
9
.github/workflows/translations-download.yaml
vendored
@@ -13,12 +13,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
@@ -27,7 +26,7 @@ jobs:
|
||||
run: "rm -R public/locales"
|
||||
|
||||
- name: Download translation files
|
||||
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
|
||||
uses: localazy/download@v1.1.0
|
||||
with:
|
||||
groups: "-p includeSourceLang:true"
|
||||
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
uses: peter-evans/create-pull-request@v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
6
.github/workflows/translations-upload.yaml
vendored
6
.github/workflows/translations-upload.yaml
vendored
@@ -3,8 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
@@ -14,9 +12,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
uses: localazy/upload@v1
|
||||
with:
|
||||
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
|
||||
|
||||
25
.storybook/main.js
Normal file
25
.storybook/main.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const svgrPlugin = require("vite-plugin-svgr");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
framework: "@storybook/react",
|
||||
core: {
|
||||
builder: "storybook-builder-vite",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
config.plugins = config.plugins.filter(
|
||||
(item) =>
|
||||
!(
|
||||
Array.isArray(item) &&
|
||||
item.length > 0 &&
|
||||
item[0].name === "vite-plugin-mdx"
|
||||
),
|
||||
);
|
||||
config.plugins.push(svgrPlugin());
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.dedupe = config.resolve.dedupe || [];
|
||||
config.resolve.dedupe.push("react", "react-dom", "matrix-js-sdk");
|
||||
return config;
|
||||
},
|
||||
};
|
||||
24
.storybook/preview.jsx
Normal file
24
.storybook/preview.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { addDecorator } from "@storybook/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { usePageFocusStyle } from "../src/usePageFocusStyle";
|
||||
import { OverlayProvider } from "@react-aria/overlays";
|
||||
import "../src/index.css";
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
addDecorator((story) => {
|
||||
usePageFocusStyle();
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<OverlayProvider>{story()}</OverlayProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
62
README.md
62
README.md
@@ -22,7 +22,7 @@ yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server that can be configured with custom routes (see below).
|
||||
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server of your choice.
|
||||
|
||||
You may also wish to add a configuration file (Element Call uses the domain it's hosted on as a Homeserver URL by default,
|
||||
but you can change this in the config file). This goes in `public/config.json` - you can use the sample as a starting point:
|
||||
@@ -54,38 +54,6 @@ Therefore, to use a self-hosted homeserver, this is recommended to be a new serv
|
||||
|
||||
There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found in [ConfigOptions.ts](src/config/ConfigOptions.ts).
|
||||
|
||||
If you're using [Synapse](https://github.com/element-hq/synapse/), you'll need to additionally add the following to `homeserver.yaml` or Element Call won't work:
|
||||
|
||||
```
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
```
|
||||
|
||||
MSC3266 allows to request a room summary of rooms you are not joined.
|
||||
The summary contains the room join rules. We need that to decide if the user gets prompted with the option to knock ("ask to join"), a cannot join error or the join view.
|
||||
|
||||
Element Call requires a Livekit SFU behind a Livekit jwt service to work. The url to the Livekit jwt service can either be configured in the config of Element Call (fallback/legacy configuration) or be configured by your homeserver via the `.well-known`.
|
||||
This is the recommended method.
|
||||
|
||||
The configuration is a list of Foci configs:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://someurl.com"
|
||||
},
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://livekit2.com"
|
||||
},
|
||||
{
|
||||
"type": "another_foci",
|
||||
"props_for_another_foci": "val"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## Translation
|
||||
|
||||
If you'd like to help translate Element Call, head over to [Localazy](https://localazy.com/p/element-call). You're also encouraged to join the [Element Translators](https://matrix.to/#/#translators:element.io) space to discuss and coordinate translation efforts.
|
||||
@@ -125,13 +93,11 @@ service for development. These use a test 'secret' published in this
|
||||
repository, so this must be used only for local development and
|
||||
**_never be exposed to the public Internet._**
|
||||
|
||||
To use it, add a SFU parameter in your local config `./public/config.json`:
|
||||
(Be aware, that this is only the fallback Livekit SFU. If the homeserver
|
||||
advertises one in the client well-known, this will not be used.)
|
||||
To use it, add SFU parameter in your local config `./public/config.json`:
|
||||
|
||||
```json
|
||||
"livekit": {
|
||||
"livekit_service_url": "http://localhost:7881"
|
||||
"livekit_service_url": "http://localhost:8881"
|
||||
},
|
||||
```
|
||||
|
||||
@@ -141,28 +107,6 @@ Run backend components:
|
||||
yarn backend
|
||||
```
|
||||
|
||||
### Add a new translation key
|
||||
|
||||
To add a new translation key you can do these steps:
|
||||
|
||||
1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
|
||||
1. Run `yarn i18n` to extract the new key and update the translation files. This will add a skeleton entry to the `public/locales/en-GB/app.json` file:
|
||||
```jsonc
|
||||
{
|
||||
...
|
||||
"some_new_key": "",
|
||||
...
|
||||
}
|
||||
```
|
||||
1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with the English translation:
|
||||
```jsonc
|
||||
{
|
||||
...
|
||||
"some_new_key": "Some new key",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Usage and other technical details about the project can be found here:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
port: 7880
|
||||
environment: dev
|
||||
bind_addresses:
|
||||
- "0.0.0.0"
|
||||
rtc:
|
||||
|
||||
13
codecov.yaml
13
codecov.yaml
@@ -1,13 +0,0 @@
|
||||
# Don't post comments on PRs; they're noisy and the same information can be
|
||||
# gotten through the checks section at the bottom of the PR anyways
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
# Track the impact of changes on overall coverage without blocking PRs
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
# Expect 80% coverage on all lines that a PR touches
|
||||
target: 80%
|
||||
@@ -5,11 +5,5 @@
|
||||
"server_name": "call.ems.host"
|
||||
}
|
||||
},
|
||||
"livekit": {
|
||||
"livekit_service_url": "http://localhost:7881"
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"eula": "https://static.element.io/legal/online-EULA.pdf"
|
||||
}
|
||||
|
||||
19
config/element_io_develop.json
Normal file
19
config/element_io_develop.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://call.ems.host",
|
||||
"server_name": "call.ems.host"
|
||||
}
|
||||
},
|
||||
"posthog": {
|
||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
},
|
||||
"sentry": {
|
||||
"environment": "main-branch-cd",
|
||||
"DSN": "https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41"
|
||||
},
|
||||
"rageshake": {
|
||||
"submit_url": "https://element.io/bugreports/submit"
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,6 @@
|
||||
"livekit": {
|
||||
"livekit_service_url": "https://livekit-jwt.call.element.dev"
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"posthog": {
|
||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
@@ -2,6 +2,5 @@
|
||||
|
||||
This folder contains documentation for Element Call setup and usage.
|
||||
|
||||
- [Embedded vs standalone mode](./embedded-standalone.md)
|
||||
- [Url format and parameters](./url-params.md)
|
||||
- [Global JS controls](./controls.md)
|
||||
- [Embedded vs standalone mode](./embedded-standalone.md)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Global JS controls
|
||||
|
||||
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
|
||||
|
||||
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
|
||||
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
|
||||
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
|
||||
30
knip.ts
30
knip.ts
@@ -1,30 +0,0 @@
|
||||
import { KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
||||
ignoreBinaries: [
|
||||
// This is deprecated, so Knip doesn't actually recognize it as a globally
|
||||
// installed binary. TODO We should switch to Compose v2:
|
||||
// https://docs.docker.com/compose/migrate/
|
||||
"docker-compose",
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
// Used for its global type declarations
|
||||
"@types/grecaptcha",
|
||||
// Because we use matrix-js-sdk as a Git dependency rather than consuming
|
||||
// the proper release artifacts, and also import directly from src/, we're
|
||||
// forced to re-install some of the types that it depends on even though
|
||||
// these look unused to Knip
|
||||
"@types/content-type",
|
||||
"@types/sdp-transform",
|
||||
"@types/uuid",
|
||||
// We obviously use this, but if the package has been linked with yarn link,
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
} satisfies KnipConfig;
|
||||
143
package.json
143
package.json
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"name": "element-call",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,81 +7,103 @@
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
|
||||
"lint": "yarn lint:types && yarn lint:eslint",
|
||||
"lint:eslint": "eslint --max-warnings 0 src",
|
||||
"lint:eslint-fix": "eslint --max-warnings 0 src --fix",
|
||||
"lint:knip": "knip",
|
||||
"lint:types": "tsc",
|
||||
"i18n": "i18next",
|
||||
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
|
||||
"i18n": "node_modules/i18next-parser/bin/cli.js",
|
||||
"i18n:check": "node_modules/i18next-parser/bin/cli.js --fail-on-warnings --fail-on-update",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"backend": "docker-compose -f backend-docker-compose.yml up"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.23.0",
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@livekit/components-core": "^0.11.0",
|
||||
"@livekit/components-core": "^0.9.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/core": "^1.25.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/context-zone": "^1.9.1",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.36.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
"@react-aria/button": "^3.3.4",
|
||||
"@react-aria/focus": "^3.5.0",
|
||||
"@react-aria/interactions": "^3.9.1",
|
||||
"@react-aria/listbox": "^3.5.1",
|
||||
"@react-aria/menu": "^3.3.0",
|
||||
"@react-aria/overlays": "^3.7.3",
|
||||
"@react-aria/select": "^3.6.0",
|
||||
"@react-aria/tabs": "^3.1.0",
|
||||
"@react-aria/tooltip": "^3.1.3",
|
||||
"@react-aria/utils": "^3.10.0",
|
||||
"@react-rxjs/core": "^0.10.7",
|
||||
"@react-spring/web": "^9.4.4",
|
||||
"@react-stately/collections": "^3.3.4",
|
||||
"@react-stately/list": "^3.5.1",
|
||||
"@react-stately/menu": "^3.3.1",
|
||||
"@react-stately/select": "^3.1.3",
|
||||
"@react-stately/tabs": "^3.1.1",
|
||||
"@react-stately/tooltip": "^3.0.5",
|
||||
"@react-stately/tree": "^3.2.0",
|
||||
"@react-types/dialog": "^3.5.5",
|
||||
"@react-types/menu": "^3.6.1",
|
||||
"@react-types/overlays": "^3.6.1",
|
||||
"@react-types/shared": "^3.13.1",
|
||||
"@react-types/tabs": "^3.1.1",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/vite-plugin": "^2.0.0",
|
||||
"@testing-library/dom": "^10.1.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@sentry/react": "^7.0.0",
|
||||
"@sentry/tracing": "^7.0.0",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^1.0.0",
|
||||
"@vector-im/compound-web": "^6.0.0",
|
||||
"@vector-im/compound-web": "^3.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"events": "^3.3.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^7.0.0",
|
||||
"i18next-http-backend": "^2.0.0",
|
||||
"livekit-client": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#d55c6a36df539f6adacc335efe5b9be27c9cee4a",
|
||||
"matrix-widget-api": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pako": "^2.0.4",
|
||||
"postcss-preset-env": "^9.0.0",
|
||||
"posthog-js": "^1.29.0",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react-i18next": "^14.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"tinyqueue": "^2.0.3",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "9",
|
||||
"vaul": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.23.0",
|
||||
"@react-spring/rafz": "^9.7.3",
|
||||
"@react-types/dialog": "^3.5.5",
|
||||
"@sentry/vite-plugin": "^2.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/dom-screen-wake-lock": "^1.0.1",
|
||||
"@types/dompurify": "^3.0.2",
|
||||
"@types/grecaptcha": "^3.0.4",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/request": "^2.48.8",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "9",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"babel-loader": "^9.0.0",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
@@ -92,42 +113,16 @@
|
||||
"eslint-plugin-matrix-org": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"global-jsdom": "^24.0.0",
|
||||
"history": "^4.0.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.0.0",
|
||||
"i18next-parser": "^9.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#467908703bc67fa3e23d978f5549e2709d4acf74",
|
||||
"matrix-widget-api": "^1.8.2",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"posthog-js": "^1.29.0",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"i18next-parser": "^8.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.42.1",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"vaul": "^0.9.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"analytics": "Analytics",
|
||||
"audio": "Audio",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"camera": "Camera",
|
||||
"copied": "Copied!",
|
||||
"display_name": "Display name",
|
||||
@@ -50,7 +49,6 @@
|
||||
"home": "Home",
|
||||
"loading": "Loading…",
|
||||
"microphone": "Microphone",
|
||||
"next": "Next",
|
||||
"options": "Options",
|
||||
"password": "Password",
|
||||
"profile": "Profile",
|
||||
@@ -59,22 +57,11 @@
|
||||
"username": "Username",
|
||||
"video": "Video"
|
||||
},
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"device_id": "Device ID: {{id}}",
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
||||
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
||||
"group_call_loader": {
|
||||
"banned_body": "You have been banned from the room.",
|
||||
"banned_heading": "Banned",
|
||||
"call_ended_body": "You have been removed from the call.",
|
||||
"call_ended_heading": "Call ended",
|
||||
"failed_heading": "Failed to join",
|
||||
"failed_text": "Call not found or is not accessible.",
|
||||
"knock_reject_body": "The room members declined your request to join.",
|
||||
"knock_reject_heading": "Not allowed to join",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"group_call_loader_failed_heading": "Call not found",
|
||||
"group_call_loader_failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
|
||||
"hangup_button_label": "End call",
|
||||
"header_label": "Element Call Home",
|
||||
"header_participants_label": "Participants",
|
||||
@@ -90,10 +77,8 @@
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
"leave_button": "Back to recents"
|
||||
},
|
||||
"log_in": "Log In",
|
||||
"logging_in": "Logging in…",
|
||||
@@ -101,7 +86,6 @@
|
||||
"login_auth_links_prompt": "Not registered yet?",
|
||||
"login_subheading": "To continue to Element",
|
||||
"login_title": "Login",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"microphone_off": "Microphone off",
|
||||
"microphone_on": "Microphone on",
|
||||
"mute_microphone_button_label": "Mute microphone",
|
||||
@@ -135,7 +119,6 @@
|
||||
"developer_settings_label": "Developer Settings",
|
||||
"developer_settings_label_description": "Expose developer settings in the settings window.",
|
||||
"developer_tab_title": "Developer",
|
||||
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
||||
"feedback_tab_description_label": "Your feedback",
|
||||
"feedback_tab_h4": "Submit feedback",
|
||||
@@ -144,6 +127,7 @@
|
||||
"feedback_tab_title": "Feedback",
|
||||
"more_tab_title": "More",
|
||||
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
||||
"show_connection_stats_label": "Show connection stats",
|
||||
"speaker_device_selection_label": "Speaker"
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} stars",
|
||||
@@ -157,13 +141,14 @@
|
||||
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
||||
"unauthenticated_view_login_button": "Login to your account",
|
||||
"unmute_microphone_button_label": "Unmute microphone",
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"version": "Version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"exit_full_screen": "Exit full screen",
|
||||
"full_screen": "Full screen",
|
||||
"mute_for_me": "Mute for me",
|
||||
"sfu_participant_local": "You",
|
||||
"volume": "Volume"
|
||||
}
|
||||
},
|
||||
"waiting_for_participants": "Waiting for other participants…"
|
||||
}
|
||||
|
||||
@@ -3,53 +3,15 @@
|
||||
"extends": ["config:base"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"extends": ["schedule:weekly"]
|
||||
},
|
||||
{
|
||||
"groupName": "GitHub Actions",
|
||||
"matchDepTypes": ["action"],
|
||||
"pinDigests": true,
|
||||
"extends": ["schedule:monthly"]
|
||||
},
|
||||
{
|
||||
"description": "Disable Renovate for packages we want to monitor ourselves",
|
||||
"groupName": "manually updated packages",
|
||||
"matchDepNames": ["matrix-js-sdk"],
|
||||
"description": "Disable renoavte for packages we want to monitor ourselves",
|
||||
"matchPackagePatterns": ["matrix-js-sdk"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"groupName": "matrix-widget-api",
|
||||
"matchDepNames": ["matrix-widget-api"]
|
||||
},
|
||||
{
|
||||
"groupName": "Compound",
|
||||
"matchPackagePrefixes": ["@vector-im/compound-"],
|
||||
"schedule": "before 5am on Tuesday and Friday"
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit client",
|
||||
"matchDepNames": ["livekit-client"]
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit components",
|
||||
"matchPackagePrefixes": ["@livekit/components-"]
|
||||
},
|
||||
{
|
||||
"groupName": "Vaul",
|
||||
"matchDepNames": ["vaul"],
|
||||
"extends": ["schedule:monthly"],
|
||||
"prHeader": "Please review modals on mobile for visual regressions."
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
"ignoreDeps": [
|
||||
"@react-aria/button",
|
||||
"@react-aria/focus",
|
||||
"@react-aria/interactions",
|
||||
"@react-aria/listbox",
|
||||
"@react-aria/menu",
|
||||
"@react-aria/overlays",
|
||||
"@react-aria/select",
|
||||
@@ -57,16 +19,9 @@
|
||||
"@react-aria/tooltip",
|
||||
"@react-aria/utils",
|
||||
"@react-stately/collections",
|
||||
"@react-stately/list",
|
||||
"@react-stately/menu",
|
||||
"@react-stately/select",
|
||||
"@react-stately/tabs",
|
||||
"@react-stately/tooltip",
|
||||
"@react-stately/tree",
|
||||
"@react-types/dialog",
|
||||
"@react-types/menu",
|
||||
"@react-types/overlays",
|
||||
"@react-types/shared",
|
||||
"@react-types/tabs"
|
||||
"@react-types/dialog"
|
||||
]
|
||||
}
|
||||
|
||||
10
src/@types/global.d.ts
vendored
10
src/@types/global.d.ts
vendored
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global";
|
||||
import { Controls } from "../controls";
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
@@ -25,7 +24,14 @@ declare global {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
controls: Controls;
|
||||
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
OLM_OPTIONS: Record<string, string>;
|
||||
}
|
||||
|
||||
// TypeScript doesn't know about the experimental setSinkId method, so we
|
||||
// declare it ourselves
|
||||
interface MediaElement extends HTMLVideoElement {
|
||||
setSinkId: (id: string) => void;
|
||||
}
|
||||
|
||||
interface HTMLElement {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { useMemo, FC } from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
|
||||
import { getAvatarUrl } from "./utils/matrix";
|
||||
import { getAvatarUrl } from "./matrix-utils";
|
||||
import { useClient } from "./ClientContext";
|
||||
|
||||
export enum Size {
|
||||
|
||||
27
src/Banner.tsx
Normal file
27
src/Banner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
import styles from "./Banner.module.css";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Banner: FC<Props> = ({ children }) => {
|
||||
return <div className={styles.banner}>{children}</div>;
|
||||
};
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import {
|
||||
ClientEvent,
|
||||
ICreateClientOpts,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/client";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
@@ -39,7 +35,7 @@ import {
|
||||
CryptoStoreIntegrityError,
|
||||
fallbackICEServerAllowed,
|
||||
initClient,
|
||||
} from "./utils/matrix";
|
||||
} from "./matrix-utils";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
@@ -321,7 +317,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
return () => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
@@ -364,13 +360,13 @@ async function loadClient(): Promise<InitResult | null> {
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||
const initClientParams: ICreateClientOpts = {
|
||||
const initClientParams = {
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
livekitServiceURL: Config.get().livekit!.livekit_service_url,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,6 @@ interface ErrorViewProps {
|
||||
|
||||
export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
||||
const location = useLocation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,26 +78,25 @@ export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
||||
: error.message}
|
||||
</p>
|
||||
<RageshakeButton description={`***Error View***: ${error.message}`} />
|
||||
{!confineToRoom &&
|
||||
(location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
))}
|
||||
{location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
)}
|
||||
</FullScreenView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -90,7 +90,6 @@ limitations under the License.
|
||||
.nameLine {
|
||||
grid-area: name;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-1x);
|
||||
@@ -98,6 +97,8 @@ limitations under the License.
|
||||
|
||||
.nameLine > h1 {
|
||||
margin: 0;
|
||||
/* XXX I can't actually get this ellipsis overflow to trigger, because
|
||||
constraint propagation in a nested flexbox layout is a massive pain */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -108,7 +109,6 @@ limitations under the License.
|
||||
|
||||
.participantsLine {
|
||||
grid-area: participants;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-1-5x);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,11 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
|
||||
|
||||
import styles from "./Header.module.css";
|
||||
import Logo from "./icons/Logo.svg?react";
|
||||
@@ -32,21 +32,13 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HTMLElement, HeaderProps>(
|
||||
({ children, className, ...rest }, ref) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Header.displayName = "Header";
|
||||
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
|
||||
return (
|
||||
<header className={classNames(styles.header, className)} {...rest}>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
@@ -125,7 +117,7 @@ interface RoomHeaderInfoProps {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
encrypted: boolean;
|
||||
participantCount: number | null;
|
||||
participantCount: number;
|
||||
}
|
||||
|
||||
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
@@ -158,7 +150,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
</Heading>
|
||||
<EncryptionLock encrypted={encrypted} />
|
||||
</div>
|
||||
{(participantCount ?? 0) > 0 && (
|
||||
{participantCount > 0 && (
|
||||
<div className={styles.participantsLine}>
|
||||
<UserProfileIcon
|
||||
width={20}
|
||||
@@ -166,7 +158,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
aria-label={t("header_participants_label")}
|
||||
/>
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
{t("participant_count", { count: participantCount ?? 0 })}
|
||||
{t("participant_count", { count: participantCount })}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@radix-ui/react-dialog";
|
||||
import { Drawer } from "vaul";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/icons/close.svg?react";
|
||||
import classNames from "classnames";
|
||||
import { Heading, Glass } from "@vector-im/compound-web";
|
||||
|
||||
|
||||
@@ -36,8 +36,3 @@ if (/android/i.test(navigator.userAgent)) {
|
||||
} else {
|
||||
platform = "desktop";
|
||||
}
|
||||
|
||||
export const isFirefox = (): boolean => {
|
||||
const { userAgent } = navigator;
|
||||
return userAgent.includes("Firefox");
|
||||
};
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, configure } from "@testing-library/react";
|
||||
|
||||
import { Toast } from "../src/Toast";
|
||||
import { withFakeTimers } from "./utils/test";
|
||||
|
||||
configure({
|
||||
defaultHidden: true,
|
||||
});
|
||||
|
||||
describe("Toast", () => {
|
||||
test("renders", () => {
|
||||
const { queryByRole } = render(
|
||||
<Toast open={false} onDismiss={() => {}}>
|
||||
Hello world!
|
||||
</Toast>,
|
||||
);
|
||||
expect(queryByRole("dialog")).toBe(null);
|
||||
const { getByRole } = render(
|
||||
<Toast open={true} onDismiss={() => {}}>
|
||||
Hello world!
|
||||
</Toast>,
|
||||
);
|
||||
expect(getByRole("dialog")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("dismisses itself after the specified timeout", () => {
|
||||
withFakeTimers(() => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast open={true} onDismiss={onDismiss} autoDismiss={2000}>
|
||||
Hello world!
|
||||
</Toast>,
|
||||
);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export const Toast: FC<Props> = ({
|
||||
useEffect(() => {
|
||||
if (open && autoDismiss !== undefined) {
|
||||
const timeout = setTimeout(onDismiss, autoDismiss);
|
||||
return (): void => clearTimeout(timeout);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [open, autoDismiss, onDismiss]);
|
||||
|
||||
@@ -86,7 +86,7 @@ export const Toast: FC<Props> = ({
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
<DialogContent aria-describedby={undefined} asChild>
|
||||
<DialogContent asChild>
|
||||
<DialogClose
|
||||
className={classNames(
|
||||
overlayStyles.overlay,
|
||||
|
||||
@@ -16,11 +16,10 @@ limitations under the License.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
import { EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
|
||||
export const PASSWORD_STRING = "password=";
|
||||
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
@@ -329,32 +328,3 @@ export const useRoomIdentifier = (): RoomIdentifier => {
|
||||
[pathname, search, hash],
|
||||
);
|
||||
};
|
||||
|
||||
export function generateUrlSearchParams(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
viaServers?: string[],
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||
// it in case it came from another client that generated a non url-safe one
|
||||
switch (encryptionSystem?.kind) {
|
||||
case E2eeType.SHARED_KEY: {
|
||||
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
|
||||
if (encodedPassword !== encryptionSystem.secret) {
|
||||
logger.info(
|
||||
"Encoded call password used non URL-safe chars: buggy client?",
|
||||
);
|
||||
}
|
||||
params.set("password", encodedPassword);
|
||||
break;
|
||||
}
|
||||
case E2eeType.PER_PARTICIPANT:
|
||||
params.set("perParticipantE2EE", "true");
|
||||
break;
|
||||
}
|
||||
params.set("roomId", roomId);
|
||||
viaServers?.forEach((s) => params.set("viaServers", s));
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-:r4:"
|
||||
class="overlay animate toast"
|
||||
data-state="open"
|
||||
id="radix-:r3:"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<h3
|
||||
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45"
|
||||
id="radix-:r4:"
|
||||
>
|
||||
Hello world!
|
||||
</h3>
|
||||
</button>
|
||||
`;
|
||||
@@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -30,11 +31,10 @@ import {
|
||||
UndecryptableToDeviceEventTracker,
|
||||
QualitySurveyEventTracker,
|
||||
CallDisconnectedEventTracker,
|
||||
CallConnectDurationTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
import { localStorageBus } from "../useLocalStorage";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
@@ -130,7 +130,7 @@ export class PosthogAnalytics {
|
||||
const { analyticsID } = getUrlParams();
|
||||
// if the embedding platform (element web) already got approval to communicating with posthog
|
||||
// element call can also send events to posthog
|
||||
optInAnalytics.setValue(Boolean(analyticsID));
|
||||
setSetting("opt-in-analytics", Boolean(analyticsID));
|
||||
}
|
||||
|
||||
this.posthog.init(posthogConfig.project_api_key, {
|
||||
@@ -144,13 +144,15 @@ export class PosthogAnalytics {
|
||||
advanced_disable_decide: true,
|
||||
});
|
||||
this.enabled = true;
|
||||
} else if (import.meta.env.MODE !== "test") {
|
||||
} else {
|
||||
logger.info(
|
||||
"Posthog is not enabled because there is no api key or no host given in the config",
|
||||
);
|
||||
this.enabled = false;
|
||||
}
|
||||
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
|
||||
this.startListeningToSettingsChanges();
|
||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
}
|
||||
|
||||
private sanitizeProperties = (
|
||||
@@ -333,7 +335,8 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
public onLoginStatusChanged(): void {
|
||||
this.maybeIdentifyUser();
|
||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
}
|
||||
|
||||
private updateSuperProperties(): void {
|
||||
@@ -356,12 +359,20 @@ export class PosthogAnalytics {
|
||||
return this.eventSignup.getSignupEndTime() > new Date(0);
|
||||
}
|
||||
|
||||
private async maybeIdentifyUser(): Promise<void> {
|
||||
private async updateAnonymityAndIdentifyUser(
|
||||
pseudonymousOptIn: boolean,
|
||||
): Promise<void> {
|
||||
// Update this.anonymity based on the user's analytics opt-in settings
|
||||
const anonymity = pseudonymousOptIn
|
||||
? Anonymity.Pseudonymous
|
||||
: Anonymity.Disabled;
|
||||
this.setAnonymity(anonymity);
|
||||
|
||||
// We may not yet have a Matrix client at this point, if not, bail. This should get
|
||||
// triggered again by onLoginStatusChanged once we do have a client.
|
||||
if (!window.matrixclient) return;
|
||||
|
||||
if (this.anonymity === Anonymity.Pseudonymous) {
|
||||
if (anonymity === Anonymity.Pseudonymous) {
|
||||
this.setRegistrationType(
|
||||
window.matrixclient.isGuest() || window.passwordlessUser
|
||||
? RegistrationType.Guest
|
||||
@@ -377,7 +388,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.anonymity !== Anonymity.Disabled) {
|
||||
if (anonymity !== Anonymity.Disabled) {
|
||||
this.updateSuperProperties();
|
||||
}
|
||||
}
|
||||
@@ -407,9 +418,8 @@ export class PosthogAnalytics {
|
||||
// * When the user changes their preferences on this device
|
||||
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
||||
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
|
||||
optInAnalytics.value.subscribe((optIn) => {
|
||||
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
|
||||
this.maybeIdentifyUser();
|
||||
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,5 +444,4 @@ export class PosthogAnalytics {
|
||||
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
|
||||
public eventQualitySurvey = new QualitySurveyEventTracker();
|
||||
public eventCallDisconnected = new CallDisconnectedEventTracker();
|
||||
public eventCallConnectDuration = new CallConnectDurationTracker();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { DisconnectReason } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import {
|
||||
IPosthogEvent,
|
||||
@@ -202,38 +201,3 @@ export class CallDisconnectedEventTracker {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface CallConnectDuration extends IPosthogEvent {
|
||||
eventName: "CallConnectDuration";
|
||||
totalDuration: number;
|
||||
websocketDuration: number;
|
||||
peerConnectionDuration: number;
|
||||
}
|
||||
|
||||
export class CallConnectDurationTracker {
|
||||
private connectStart = 0;
|
||||
private websocketConnected = 0;
|
||||
public cacheConnectStart(): void {
|
||||
this.connectStart = Date.now();
|
||||
}
|
||||
public cacheWsConnect(): void {
|
||||
this.websocketConnected = Date.now();
|
||||
}
|
||||
|
||||
public track(options = { log: false }): void {
|
||||
const now = Date.now();
|
||||
const totalDuration = now - this.connectStart;
|
||||
const websocketDuration = this.websocketConnected - this.connectStart;
|
||||
const peerConnectionDuration = now - this.websocketConnected;
|
||||
PosthogAnalytics.instance.trackEvent<CallConnectDuration>({
|
||||
eventName: "CallConnectDuration",
|
||||
totalDuration,
|
||||
websocketDuration,
|
||||
peerConnectionDuration,
|
||||
});
|
||||
if (options.log)
|
||||
logger.log(
|
||||
`Time to connect:\ntotal: ${totalDuration}ms\npeerConnection: ${websocketDuration}ms\nwebsocket: ${peerConnectionDuration}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
44
src/array-utils.ts
Normal file
44
src/array-utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the index of the last element in the array to satsify the given
|
||||
* predicate.
|
||||
*/
|
||||
// TODO: remove this once TypeScript recognizes the existence of
|
||||
// Array.prototype.findLastIndex
|
||||
export function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (item: T, index: number) => boolean,
|
||||
): number | null {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i], i)) return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of elements in an array that satsify the given predicate.
|
||||
*/
|
||||
export const count = <T>(
|
||||
array: T[],
|
||||
predicate: (item: T, index: number) => boolean,
|
||||
): number =>
|
||||
array.reduce(
|
||||
(acc, item, index) => (predicate(item, index) ? acc + 1 : acc),
|
||||
0,
|
||||
);
|
||||
@@ -16,13 +16,9 @@ limitations under the License.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import {
|
||||
createClient,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
|
||||
export function useInteractiveLogin(): (
|
||||
@@ -41,7 +37,7 @@ export function useInteractiveLogin(): (
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: (): Promise<LoginResponse> =>
|
||||
doRequest: () =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
|
||||
@@ -16,13 +16,9 @@ limitations under the License.
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import {
|
||||
createClient,
|
||||
MatrixClient,
|
||||
RegisterResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { widget } from "../widget";
|
||||
@@ -73,7 +69,7 @@ export const useInteractiveRegistration = (): {
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: (auth): Promise<RegisterResponse> =>
|
||||
doRequest: (auth) =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
|
||||
@@ -13,17 +13,17 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
// import "@types/grecaptcha";
|
||||
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { translatedError } from "../TranslatedError";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mxOnRecaptchaLoaded: () => void;
|
||||
// grecaptcha: any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,16 +20,14 @@ import { useButton } from "@react-aria/button";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
MicOnSolidIcon,
|
||||
MicOffSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
VideoCallOffSolidIcon,
|
||||
EndCallIcon,
|
||||
ShareScreenSolidIcon,
|
||||
SettingsSolidIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
|
||||
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
|
||||
import VideoCallSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-solid.svg?react";
|
||||
import VideoCallOffSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-off-solid.svg?react";
|
||||
import EndCallIcon from "@vector-im/compound-design-tokens/icons/end-call.svg?react";
|
||||
import ShareScreenSolidIcon from "@vector-im/compound-design-tokens/icons/share-screen-solid.svg?react";
|
||||
import SettingsSolidIcon from "@vector-im/compound-design-tokens/icons/settings-solid.svg?react";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/icons/chevron-down.svg?react";
|
||||
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
@@ -151,7 +149,7 @@ export const MicButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={muted}>
|
||||
<Icon aria-hidden width={24} height={24} />
|
||||
<Icon aria-label={label} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -171,7 +169,7 @@ export const VideoButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={muted}>
|
||||
<Icon aria-hidden width={24} height={24} />
|
||||
<Icon aria-label={label} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -191,7 +189,7 @@ export const ScreenshareButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={enabled}>
|
||||
<ShareScreenSolidIcon aria-hidden width={24} height={24} />
|
||||
<ShareScreenSolidIcon aria-label={label} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -211,7 +209,7 @@ export const HangupButton: FC<{
|
||||
className={classNames(styles.hangupButton, className)}
|
||||
{...rest}
|
||||
>
|
||||
<EndCallIcon aria-hidden width={24} height={24} />
|
||||
<EndCallIcon aria-label={t("hangup_button_label")} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -227,7 +225,7 @@ export const SettingsButton: FC<{
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<Button variant="toolbar" {...rest}>
|
||||
<SettingsSolidIcon aria-hidden width={24} height={24} />
|
||||
<SettingsSolidIcon aria-label={t("common.settings")} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { ComponentPropsWithoutRef, FC } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/icons/user-add.svg?react";
|
||||
|
||||
export const InviteButton: FC<
|
||||
Omit<ComponentPropsWithoutRef<"button">, "children">
|
||||
|
||||
@@ -44,18 +44,6 @@ export class Config {
|
||||
return Config.internalInstance.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a alternative initializer that does not load anything
|
||||
* from a hosted config file but instead just initializes the conifg using the
|
||||
* default config.
|
||||
*
|
||||
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
|
||||
*/
|
||||
public static initDefault(): void {
|
||||
Config.internalInstance = new Config();
|
||||
Config.internalInstance.config = { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
// Convenience accessors
|
||||
public static defaultHomeserverUrl(): string | undefined {
|
||||
return (
|
||||
|
||||
@@ -55,31 +55,16 @@ export interface ConfigOptions {
|
||||
|
||||
// Describes the LiveKit configuration to be used.
|
||||
livekit?: {
|
||||
// The link to the service that returns a livekit url and token to use it.
|
||||
// This is a fallback link in case the homeserver in use does not advertise
|
||||
// a livekit service url in the client well-known.
|
||||
// The well known needs to be formatted like so:
|
||||
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
|
||||
// and stored under the key: "livekit_focus"
|
||||
// The link to the service that returns a livekit url and token to use it
|
||||
livekit_service_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* TEMPORARY experimental features.
|
||||
* Allow to join a group calls without audio and video.
|
||||
* TEMPORARY: Is a feature that's not proved and experimental
|
||||
*/
|
||||
features?: {
|
||||
/**
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
* This setting has no effect when the user joins an active call with
|
||||
* legacy state events. For compatibility, Element Call will always join
|
||||
* active legacy calls with legacy state events.
|
||||
*/
|
||||
feature_use_device_session_member_events?: boolean;
|
||||
feature_group_calls_without_video_and_audio: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
export interface Controls {
|
||||
canEnterPip: () => boolean;
|
||||
enablePip: () => void;
|
||||
disablePip: () => void;
|
||||
}
|
||||
|
||||
export const setPipEnabled = new Subject<boolean>();
|
||||
|
||||
window.controls = {
|
||||
canEnterPip(): boolean {
|
||||
return setPipEnabled.observed;
|
||||
},
|
||||
enablePip(): void {
|
||||
if (!setPipEnabled.observed) throw new Error("No call is running");
|
||||
setPipEnabled.next(true);
|
||||
},
|
||||
disablePip(): void {
|
||||
if (!setPipEnabled.observed) throw new Error("No call is running");
|
||||
setPipEnabled.next(false);
|
||||
},
|
||||
};
|
||||
@@ -15,11 +15,12 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Room } from "matrix-js-sdk";
|
||||
|
||||
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { widget } from "../widget";
|
||||
|
||||
export function saveKeyForRoom(roomId: string, password: string): void {
|
||||
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||
@@ -67,37 +68,30 @@ const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
|
||||
: [undefined, undefined];
|
||||
};
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
|
||||
export const useRoomSharedKey = (roomId: string): string | undefined => {
|
||||
// make sure we've extracted the key from the URL first
|
||||
// (and we still need to take the value it returns because
|
||||
// the effect won't run in time for it to save to localstorage in
|
||||
// time for us to read it out again).
|
||||
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||
const [urlRoomId, passwordFormUrl] = useKeyFromUrl();
|
||||
|
||||
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: storedPassword,
|
||||
};
|
||||
if (urlRoomId === roomId)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: passwordFromUrl,
|
||||
};
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return { kind: E2eeType.PER_PARTICIPANT };
|
||||
}
|
||||
return { kind: E2eeType.NONE };
|
||||
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||
return e2eeSystem;
|
||||
|
||||
if (storedPassword) return storedPassword;
|
||||
if (urlRoomId === roomId) return passwordFormUrl;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
||||
const { client } = useClient();
|
||||
const room = useMemo(() => client?.getRoom(roomId), [roomId, client]);
|
||||
|
||||
return useMemo(() => !room || isRoomE2EE(room), [room]);
|
||||
};
|
||||
|
||||
export function isRoomE2EE(room: Room): boolean {
|
||||
// For now, rooms in widget mode are never considered encrypted.
|
||||
// In the future, when widget mode gains encryption support, then perhaps we
|
||||
// should inspect the e2eEnabled URL parameter here?
|
||||
return widget === null && !room.getCanonicalAlias();
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
|
||||
import { LayoutProps } from "./Grid";
|
||||
|
||||
export interface Bounds {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export const defaultSpotlightAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
};
|
||||
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
|
||||
|
||||
export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
minBounds: Observable<Bounds>;
|
||||
/**
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
*/
|
||||
spotlightAlignment: BehaviorSubject<Alignment>;
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present.
|
||||
*/
|
||||
pipAlignment: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface GridTileModel {
|
||||
type: "grid";
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface SpotlightTileModel {
|
||||
type: "spotlight";
|
||||
vms: MediaViewModel[];
|
||||
maximised: boolean;
|
||||
}
|
||||
|
||||
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
/**
|
||||
* Whether the scrolling layer of the layout should appear on top.
|
||||
*/
|
||||
scrollingOnTop: boolean;
|
||||
/**
|
||||
* The visually fixed (non-scrolling) layer of the layout.
|
||||
*/
|
||||
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||
/**
|
||||
* The layer of the layout that can overflow and be scrolled.
|
||||
*/
|
||||
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout system for media tiles.
|
||||
*/
|
||||
export type CallLayout<Model> = (
|
||||
inputs: CallLayoutInputs,
|
||||
) => CallLayoutOutputs<Model>;
|
||||
|
||||
export interface GridArrangement {
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
gap: number;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const tileMaxAspectRatio = 17 / 9;
|
||||
const tileMinAspectRatio = 4 / 3;
|
||||
|
||||
/**
|
||||
* Determine the ideal arrangement of tiles into a grid of a particular size.
|
||||
*/
|
||||
export function arrangeTiles(
|
||||
width: number,
|
||||
minHeight: number,
|
||||
tileCount: number,
|
||||
): GridArrangement {
|
||||
// The goal here is to determine the grid size and padding that maximizes
|
||||
// use of screen space for n tiles without making those tiles too small or
|
||||
// too cropped (having an extreme aspect ratio)
|
||||
const gap = width < 800 ? 16 : 20;
|
||||
const area = width * minHeight;
|
||||
// Magic numbers that make tiles scale up nicely as the window gets larger
|
||||
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
|
||||
const tilesPerPage = Math.min(tileCount, area / tileArea);
|
||||
|
||||
let columns = Math.min(
|
||||
// Don't create more columns than we have items for
|
||||
tilesPerPage,
|
||||
// The ideal number of columns is given by a packing of equally-sized
|
||||
// squares into a grid.
|
||||
// width / column = height / row.
|
||||
// columns * rows = number of squares.
|
||||
// ∴ columns = sqrt(width / height * number of squares).
|
||||
// Except we actually want 16:9-ish tiles rather than squares, so we
|
||||
// divide the width-to-height ratio by the target aspect ratio.
|
||||
Math.round(
|
||||
Math.sqrt((width / minHeight / tileMinAspectRatio) * tilesPerPage),
|
||||
),
|
||||
);
|
||||
let rows = tilesPerPage / columns;
|
||||
// If all the tiles could fit on one page, we want to ensure that they do by
|
||||
// not leaving fractional rows hanging off the bottom
|
||||
if (tilesPerPage === tileCount) {
|
||||
rows = Math.ceil(rows);
|
||||
// We may now be able to fit the tiles into fewer columns
|
||||
columns = Math.ceil(tileCount / rows);
|
||||
}
|
||||
|
||||
let tileWidth = (width - (columns + 1) * gap) / columns;
|
||||
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||
|
||||
// Impose a minimum and maximum aspect ratio on the tiles
|
||||
const tileAspectRatio = tileWidth / tileHeight;
|
||||
if (tileAspectRatio > tileMaxAspectRatio)
|
||||
tileWidth = tileHeight * tileMaxAspectRatio;
|
||||
else if (tileAspectRatio < tileMinAspectRatio)
|
||||
tileHeight = tileWidth / tileMinAspectRatio;
|
||||
|
||||
return { tileWidth, tileHeight, gap, columns };
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
SpringRef,
|
||||
TransitionFn,
|
||||
animated,
|
||||
useTransition,
|
||||
} from "@react-spring/web";
|
||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||
import {
|
||||
CSSProperties,
|
||||
ComponentProps,
|
||||
ComponentType,
|
||||
Dispatch,
|
||||
FC,
|
||||
LegacyRef,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
forwardRef,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./Grid.module.css";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Tile<Model> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag: DragCallback | undefined;
|
||||
}
|
||||
|
||||
type PlacedTile<Model> = Tile<Model> & Rect;
|
||||
|
||||
interface TileSpring {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
zIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TileSpringUpdate extends Partial<TileSpring> {
|
||||
from?: Partial<TileSpring>;
|
||||
reset?: boolean;
|
||||
immediate?: boolean | ((key: string) => boolean);
|
||||
delay?: (key: string) => number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
tileId: string;
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag?: DragCallback;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the offset of one element relative to an ancestor.
|
||||
*/
|
||||
function offset(element: HTMLElement, relativeTo: Element): Offset {
|
||||
if (
|
||||
!(element.offsetParent instanceof HTMLElement) ||
|
||||
element.offsetParent === relativeTo
|
||||
) {
|
||||
return { x: element.offsetLeft, y: element.offsetTop };
|
||||
} else {
|
||||
const o = offset(element.offsetParent, relativeTo);
|
||||
o.x += element.offsetLeft;
|
||||
o.y += element.offsetTop;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
interface LayoutContext {
|
||||
setGeneration: Dispatch<SetStateAction<number | null>>;
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContext | null>(null);
|
||||
|
||||
/**
|
||||
* Enables Grid to react to layout changes. You must call this in your Layout
|
||||
* component or else Grid will not be reactive.
|
||||
*/
|
||||
export function useUpdateLayout(): void {
|
||||
const context = useContext(LayoutContext);
|
||||
if (context === null)
|
||||
throw new Error("useUpdateLayout called outside a Grid layout context");
|
||||
|
||||
// On every render, tell Grid that the layout may have changed
|
||||
useEffect(() =>
|
||||
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||
ref: LegacyRef<R>;
|
||||
model: LayoutModel;
|
||||
/**
|
||||
* Component creating an invisible "slot" for a tile to go in.
|
||||
*/
|
||||
Slot: ComponentType<SlotProps<TileModel>>;
|
||||
}
|
||||
|
||||
export interface TileProps<Model, R extends HTMLElement> {
|
||||
ref: LegacyRef<R>;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
/**
|
||||
* 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;
|
||||
model: Model;
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
/**
|
||||
* The X coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* The X coordinate of the dragged tile, as a scalar of the grid width.
|
||||
*/
|
||||
xRatio: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile, as a scalar of the grid height.
|
||||
*/
|
||||
yRatio: number;
|
||||
}
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
|
||||
extends LayoutProps<LayoutModel, TileModel, R> {
|
||||
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
|
||||
}
|
||||
|
||||
interface Props<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
LayoutRef extends HTMLElement,
|
||||
TileRef extends HTMLElement,
|
||||
> {
|
||||
/**
|
||||
* Data with which to populate the layout.
|
||||
*/
|
||||
model: LayoutModel;
|
||||
/**
|
||||
* A component which creates an invisible layout grid of "slots" for tiles to
|
||||
* go in. The root element must have a data-generation attribute which
|
||||
* increments whenever the layout may have changed.
|
||||
*/
|
||||
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, LayoutRef>>;
|
||||
/**
|
||||
* The component used to render each tile in the layout.
|
||||
*/
|
||||
Tile: ComponentType<TileProps<TileModel, TileRef>>;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid of animated tiles.
|
||||
*/
|
||||
export function Grid<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
LayoutRef extends HTMLElement,
|
||||
TileRef extends HTMLElement,
|
||||
>({
|
||||
model,
|
||||
Layout,
|
||||
Tile,
|
||||
className,
|
||||
style,
|
||||
}: Props<LayoutModel, TileModel, LayoutRef, TileRef>): ReactNode {
|
||||
// Overview: This component places tiles by rendering an invisible layout grid
|
||||
// of "slots" for tiles to go in. Once rendered, it uses the DOM API to get
|
||||
// the dimensions of each slot, feeding these numbers back into react-spring
|
||||
// to let the actual tiles move freely atop the layout.
|
||||
|
||||
// To tell us when the layout has changed, the layout system increments its
|
||||
// data-generation attribute, which we watch with a MutationObserver.
|
||||
|
||||
const [gridRef1, gridBounds] = useMeasure();
|
||||
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const Slot: FC<SlotProps<TileModel>> = useMemo(
|
||||
() =>
|
||||
function Slot({ id, model, onDrag, style, className, ...props }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
tiles.set(id, { id, model, onDrag });
|
||||
return (): void => void tiles.delete(id);
|
||||
}, [id, model, onDrag]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.slot)}
|
||||
data-id={id}
|
||||
style={style}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[tiles],
|
||||
);
|
||||
|
||||
// We must memoize the Layout component to break the update loop where a
|
||||
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
|
||||
const LayoutMemo = useMemo(
|
||||
() =>
|
||||
memo(
|
||||
forwardRef<
|
||||
LayoutRef,
|
||||
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
|
||||
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
|
||||
return <Layout {...props} ref={ref} />;
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
|
||||
|
||||
// Combine the tile definitions and slots together to create placed tiles
|
||||
const placedTiles = useMemo(() => {
|
||||
const result: PlacedTile<TileModel>[] = [];
|
||||
|
||||
if (gridRoot !== null && layoutRoot !== null) {
|
||||
const slots = layoutRoot.getElementsByClassName(
|
||||
styles.slot,
|
||||
) as HTMLCollectionOf<HTMLElement>;
|
||||
for (const slot of slots) {
|
||||
const id = slot.getAttribute("data-id")!;
|
||||
if (slot.offsetWidth > 0 && slot.offsetHeight > 0)
|
||||
result.push({
|
||||
...tiles.get(id)!,
|
||||
...offset(slot, gridRoot),
|
||||
width: slot.offsetWidth,
|
||||
height: slot.offsetHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
// The rects may change due to the grid resizing or updating to a new
|
||||
// generation, but eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
||||
|
||||
// Drag state is stored in a ref rather than component state, because we use
|
||||
// react-spring's imperative API during gestures to improve responsiveness
|
||||
const dragState = useRef<DragState | null>(null);
|
||||
|
||||
const [tileTransitions, springRef] = useTransition(
|
||||
placedTiles,
|
||||
() => ({
|
||||
key: ({ id }: Tile<TileModel>): string => id,
|
||||
from: ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}: PlacedTile<TileModel>): TileSpringUpdate => ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
zIndex: 1,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
|
||||
update: ({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}: PlacedTile<TileModel>): TileSpringUpdate | null =>
|
||||
id === dragState.current?.tileId
|
||||
? null
|
||||
: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
|
||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
}),
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
) as unknown as [
|
||||
TransitionFn<PlacedTile<TileModel>, TileSpring>,
|
||||
SpringRef<TileSpring>,
|
||||
];
|
||||
|
||||
// Because we're using react-spring in imperative mode, we're responsible for
|
||||
// firing animations manually whenever the tiles array updates
|
||||
useEffect(() => {
|
||||
springRef.start();
|
||||
}, [placedTiles, springRef]);
|
||||
|
||||
const animateDraggedTile = (
|
||||
endOfGesture: boolean,
|
||||
callback: DragCallback,
|
||||
): void => {
|
||||
const { tileId, tileX, tileY } = dragState.current!;
|
||||
const tile = placedTiles.find((t) => t.id === tileId)!;
|
||||
|
||||
springRef.current
|
||||
.find((c) => (c.item as Tile<TileModel>).id === tileId)
|
||||
?.start(
|
||||
endOfGesture
|
||||
? {
|
||||
scale: 1,
|
||||
zIndex: 1,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate:
|
||||
prefersReducedMotion || ((key): boolean => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
||||
}
|
||||
: {
|
||||
scale: 1.1,
|
||||
zIndex: 2,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
prefersReducedMotion ||
|
||||
((key): boolean =>
|
||||
key === "zIndex" || key === "x" || key === "y"),
|
||||
},
|
||||
);
|
||||
|
||||
if (endOfGesture)
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
});
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||
// ends up breaking memoization and ruining this component's performance.
|
||||
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
||||
// gesture using the much more sensible ref-based method.
|
||||
const onTileDrag = (
|
||||
tileId: string,
|
||||
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
tap,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
initial: [initialX, initialY],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delta: [dx, dy],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
last,
|
||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||
): void => {
|
||||
if (!tap) {
|
||||
const tileController = springRef.current.find(
|
||||
(c) => (c.item as Tile<TileModel>).id === tileId,
|
||||
)!;
|
||||
const callback = tiles.get(tileController.item.id)!.onDrag;
|
||||
|
||||
if (callback != null) {
|
||||
if (dragState.current === null) {
|
||||
const tileSpring = tileController.get();
|
||||
dragState.current = {
|
||||
tileId,
|
||||
tileX: tileSpring.x,
|
||||
tileY: tileSpring.y,
|
||||
cursorX: initialX - gridBounds.x,
|
||||
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||
};
|
||||
}
|
||||
|
||||
dragState.current.tileX += dx;
|
||||
dragState.current.tileY += dy;
|
||||
dragState.current.cursorX += dx;
|
||||
dragState.current.cursorY += dy;
|
||||
|
||||
animateDraggedTile(last, callback);
|
||||
|
||||
if (last) dragState.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTileDragRef = useRef(onTileDrag);
|
||||
onTileDragRef.current = onTileDrag;
|
||||
|
||||
const scrollOffset = useRef(0);
|
||||
|
||||
useScroll(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
({ xy: [, y], delta: [, dy] }) => {
|
||||
scrollOffset.current = y;
|
||||
|
||||
if (dragState.current !== null) {
|
||||
dragState.current.tileY += dy;
|
||||
dragState.current.cursorY += dy;
|
||||
animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
|
||||
}
|
||||
},
|
||||
{ target: gridRoot ?? undefined },
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className={classNames(className, styles.grid)}
|
||||
style={style}
|
||||
>
|
||||
<LayoutContext.Provider value={context}>
|
||||
<LayoutMemo
|
||||
ref={setLayoutRoot}
|
||||
Layout={Layout}
|
||||
model={model}
|
||||
Slot={Slot}
|
||||
/>
|
||||
</LayoutContext.Provider>
|
||||
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
|
||||
<TileWrapper
|
||||
key={id}
|
||||
id={id}
|
||||
onDrag={onDrag ? onTileDragRef : null}
|
||||
targetWidth={width}
|
||||
targetHeight={height}
|
||||
model={model}
|
||||
Tile={Tile}
|
||||
{...spring}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.fixed,
|
||||
.scrolling {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.scrolling {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.scrolling > .slot {
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fixed > .slot {
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset-block: 0;
|
||||
inset-inline: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="end"] {
|
||||
inset-block-start: unset;
|
||||
}
|
||||
|
||||
.fixed > .slot[data-inline-alignment="start"] {
|
||||
inset-inline-end: unset;
|
||||
}
|
||||
|
||||
.fixed > .slot[data-inline-alignment="end"] {
|
||||
inset-inline-start: unset;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { CSSProperties, forwardRef, useCallback, useMemo } from "react";
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { useInitial } from "../useInitial";
|
||||
import {
|
||||
CallLayout,
|
||||
GridTileModel,
|
||||
TileModel,
|
||||
arrangeTiles,
|
||||
} from "./CallLayout";
|
||||
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--gap": string;
|
||||
"--width": string;
|
||||
"--height": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of the "grid" layout, in which all participants are shown
|
||||
* together in a scrolling grid.
|
||||
*/
|
||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
minBounds,
|
||||
spotlightAlignment,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||
// lives
|
||||
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
spotlightAlignment.pipe(
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const tileModel: TileModel | undefined = useMemo(
|
||||
() =>
|
||||
model.spotlight && {
|
||||
type: "spotlight",
|
||||
vms: model.spotlight,
|
||||
maximised: false,
|
||||
},
|
||||
[model.spotlight],
|
||||
);
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
spotlightAlignment.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.fixed}>
|
||||
{tileModel && (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={tileModel}
|
||||
onDrag={onDragSpotlight}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
||||
// The scrolling part of the layout is where all the grid tiles live
|
||||
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
||||
useUpdateLayout();
|
||||
const { width, height: minHeight } = useObservableEagerState(minBounds);
|
||||
const { gap, tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||
[width, minHeight, model.grid.length],
|
||||
);
|
||||
|
||||
const tileModels: GridTileModel[] = useMemo(
|
||||
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||
[model.grid],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.scrolling}
|
||||
style={
|
||||
{
|
||||
width,
|
||||
"--gap": `${gap}px`,
|
||||
"--width": `${Math.floor(tileWidth)}px`,
|
||||
"--height": `${Math.floor(tileHeight)}px`,
|
||||
} as GridCSSProperties
|
||||
}
|
||||
>
|
||||
{tileModels.map((m) => (
|
||||
<Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.local {
|
||||
position: absolute;
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
inset: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="end"] {
|
||||
inset-block-start: unset;
|
||||
}
|
||||
|
||||
.slot[data-inline-alignment="start"] {
|
||||
inset-inline-end: unset;
|
||||
}
|
||||
|
||||
.slot[data-inline-alignment="end"] {
|
||||
inset-inline-start: unset;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||
*/
|
||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
minBounds,
|
||||
pipAlignment,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
}),
|
||||
|
||||
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds);
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
const remoteTileModel: GridTileModel = useMemo(
|
||||
() => ({ type: "grid", vm: model.remote }),
|
||||
[model.remote],
|
||||
);
|
||||
const localTileModel: GridTileModel = useMemo(
|
||||
() => ({ type: "grid", vm: model.local }),
|
||||
[model.local],
|
||||
);
|
||||
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
pipAlignment.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
id={remoteTileModel.vm.id}
|
||||
model={remoteTileModel}
|
||||
className={styles.container}
|
||||
style={{ width: tileWidth, height: tileHeight }}
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={localTileModel.vm.id}
|
||||
model={localTileModel}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
/>
|
||||
</Slot>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.pip {
|
||||
position: absolute;
|
||||
inline-size: 135px;
|
||||
block-size: 160px;
|
||||
inset: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.pip {
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
}
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="end"] {
|
||||
inset-block-start: unset;
|
||||
}
|
||||
|
||||
.pip[data-inline-alignment="start"] {
|
||||
inset-inline-end: unset;
|
||||
}
|
||||
|
||||
.pip[data-inline-alignment="end"] {
|
||||
inset-inline-start: unset;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
|
||||
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
|
||||
/**
|
||||
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
|
||||
*/
|
||||
export const makeSpotlightExpandedLayout: CallLayout<
|
||||
SpotlightExpandedLayoutModel
|
||||
> = ({ pipAlignment }) => ({
|
||||
scrollingOnTop: true,
|
||||
|
||||
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
const spotlightTileModel: SpotlightTileModel = useMemo(
|
||||
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
|
||||
[model.spotlight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
className={styles.spotlight}
|
||||
id="spotlight"
|
||||
model={spotlightTileModel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
||||
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||
|
||||
const pipTileModel: GridTileModel | undefined = useMemo(
|
||||
() => model.pip && { type: "grid", vm: model.pip },
|
||||
[model.pip],
|
||||
);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
pipAlignment.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{pipTileModel && (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id="pip"
|
||||
model={pipTileModel}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
display: grid;
|
||||
--gap: 20px;
|
||||
gap: var(--gap);
|
||||
--grid-slot-width: 180px;
|
||||
grid-template-columns: 1fr var(--grid-slot-width);
|
||||
grid-template-rows: minmax(1fr, auto);
|
||||
padding-inline: var(--gap);
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
container: spotlight / size;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* CSS makes us put a condition here, even though all we want to do is
|
||||
unconditionally select the container so we can use cq units */
|
||||
@container spotlight (width > 0) {
|
||||
.spotlight > .slot {
|
||||
inline-size: min(100cqi, 100cqb * (17 / 9));
|
||||
block-size: min(100cqb, 100cqi / (4 / 3));
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap);
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.grid > .slot {
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
||||
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
|
||||
/**
|
||||
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||
* tile takes up most of the space on the left, and the grid of participants is
|
||||
* shown as a scrolling rail on the right.
|
||||
*/
|
||||
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
SpotlightLandscapeLayoutModel
|
||||
> = ({ minBounds }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds);
|
||||
const tileModel: TileModel = useMemo(
|
||||
() => ({
|
||||
type: "spotlight",
|
||||
vms: model.spotlight,
|
||||
maximised: false,
|
||||
}),
|
||||
[model.spotlight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div className={styles.spotlight}>
|
||||
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||
</div>
|
||||
<div className={styles.grid} />
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
||||
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds);
|
||||
const tileModels: GridTileModel[] = useMemo(
|
||||
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||
[model.grid],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: model.spotlight.length > 1,
|
||||
})}
|
||||
/>
|
||||
<div className={styles.grid}>
|
||||
{tileModels.map((m) => (
|
||||
<Slot
|
||||
key={m.vm.id}
|
||||
className={styles.slot}
|
||||
id={m.vm.id}
|
||||
model={m}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
display: grid;
|
||||
--gap: 20px;
|
||||
gap: var(--gap);
|
||||
margin-inline: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
container: spotlight / size;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-block-end: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.spotlight.withIndicators {
|
||||
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
||||
}
|
||||
|
||||
.spotlight > .slot {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--grid-gap);
|
||||
justify-content: center;
|
||||
align-content: start;
|
||||
padding-inline: var(--grid-gap);
|
||||
}
|
||||
|
||||
.grid > .slot {
|
||||
inline-size: var(--grid-tile-width);
|
||||
block-size: var(--grid-tile-height);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { CSSProperties, forwardRef, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import {
|
||||
CallLayout,
|
||||
GridTileModel,
|
||||
TileModel,
|
||||
arrangeTiles,
|
||||
} from "./CallLayout";
|
||||
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--grid-gap": string;
|
||||
"--grid-tile-width": string;
|
||||
"--grid-tile-height": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of the "spotlight portrait" layout, in which the spotlight
|
||||
* tile is shown across the top of the screen, and the grid of participants
|
||||
* scrolls behind it.
|
||||
*/
|
||||
export const makeSpotlightPortraitLayout: CallLayout<
|
||||
SpotlightPortraitLayoutModel
|
||||
> = ({ minBounds }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
const tileModel: TileModel = useMemo(
|
||||
() => ({
|
||||
type: "spotlight",
|
||||
vms: model.spotlight,
|
||||
maximised: true,
|
||||
}),
|
||||
[model.spotlight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div className={styles.spotlight}>
|
||||
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
||||
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
useUpdateLayout();
|
||||
const { width } = useObservableEagerState(minBounds);
|
||||
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||
width,
|
||||
// TODO: We pretend that the minimum height is the width, because the
|
||||
// actual minimum height is difficult to calculate
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const tileModels: GridTileModel[] = useMemo(
|
||||
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||
[model.grid],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.layer}
|
||||
style={
|
||||
{
|
||||
"--grid-gap": `${gap}px`,
|
||||
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
|
||||
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
|
||||
} as GridCSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: model.spotlight.length > 1,
|
||||
})}
|
||||
/>
|
||||
<div className={styles.grid}>
|
||||
{tileModels.map((m) => (
|
||||
<Slot
|
||||
key={m.vm.id}
|
||||
className={styles.slot}
|
||||
id={m.vm.id}
|
||||
model={m}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -23,10 +23,10 @@ import { FC } from "react";
|
||||
import { CopyButton } from "../button";
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import styles from "./CallList.module.css";
|
||||
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../utils/matrix";
|
||||
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface CallListProps {
|
||||
rooms: GroupCallRoom[];
|
||||
@@ -66,11 +66,16 @@ interface CallTileProps {
|
||||
}
|
||||
|
||||
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||
const roomEncryptionSystem = useRoomEncryptionSystem(room.roomId);
|
||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
||||
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
<Link
|
||||
to={getRelativeRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||
to={getRelativeRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined,
|
||||
)}
|
||||
className={styles.callTileLink}
|
||||
>
|
||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
@@ -84,8 +89,11 @@ const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
variant="icon"
|
||||
// Todo add the viaServers to the created link
|
||||
value={getAbsoluteRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||
value={getAbsoluteRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
getRelativeRoomUrl,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../utils/matrix";
|
||||
} from "../matrix-utils";
|
||||
import { useGroupCallRooms } from "./useGroupCallRooms";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import commonStyles from "./common.module.css";
|
||||
@@ -38,9 +38,9 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { Caption } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -78,14 +78,12 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
roomName,
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
createRoomResult.password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
getRelativeRoomUrl,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../utils/matrix";
|
||||
} from "../matrix-utils";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
@@ -41,9 +41,9 @@ import styles from "./UnauthenticatedView.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
@@ -116,15 +116,13 @@ export const UnauthenticatedView: FC = () => {
|
||||
if (!setClient) {
|
||||
throw new Error("setClient is undefined");
|
||||
}
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
setClient({ client, session });
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
createRoomResult.password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,22 +15,20 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
||||
import { useState, useEffect } from "react";
|
||||
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { getKeyForRoom } from "../e2ee/sharedKeyManagement";
|
||||
import { getKeyForRoom, isRoomE2EE } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export interface GroupCallRoom {
|
||||
roomAlias?: string;
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
room: Room;
|
||||
session: MatrixRTCSession;
|
||||
groupCall: GroupCall;
|
||||
participants: RoomMember[];
|
||||
}
|
||||
const tsCache: { [index: string]: number } = {};
|
||||
@@ -48,7 +46,7 @@ function getLastTs(client: MatrixClient, r: Room): number {
|
||||
|
||||
const myUserId = client.getUserId()!;
|
||||
|
||||
if (r.getMyMembership() !== KnownMembership.Join) {
|
||||
if (r.getMyMembership() !== "join") {
|
||||
const membershipEvent = r.currentState.getStateEvents(
|
||||
"m.room.member",
|
||||
myUserId,
|
||||
@@ -82,77 +80,38 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
||||
});
|
||||
}
|
||||
|
||||
const roomIsJoinable = (room: Room): boolean => {
|
||||
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
// in case this key also does not exists we cannot join the room.
|
||||
return false;
|
||||
function roomIsJoinable(room: Room): boolean {
|
||||
if (isRoomE2EE(room)) {
|
||||
return Boolean(getKeyForRoom(room.roomId));
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
// otherwise we can always join rooms because we will automatically decide if we want to use perParticipant or password
|
||||
switch (room.getJoinRule()) {
|
||||
case JoinRule.Public:
|
||||
return true;
|
||||
case JoinRule.Knock:
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
case KnownMembership.Knock:
|
||||
return true;
|
||||
case KnownMembership.Invite:
|
||||
return (
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomMember, room.myUserId)
|
||||
?.getPrevContent().membership === JoinRule.Knock
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
// TODO: check JoinRule.Restricted and return true if join condition is satisfied
|
||||
default:
|
||||
return room.getMyMembership() === KnownMembership.Join;
|
||||
}
|
||||
};
|
||||
|
||||
const roomHasCallMembershipEvents = (room: Room): boolean => {
|
||||
switch (room.getMyMembership()) {
|
||||
case KnownMembership.Join:
|
||||
return !!room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.events?.get(EventType.GroupCallMemberPrefix);
|
||||
case KnownMembership.Knock:
|
||||
// Assume that a room you've knocked on is able to hold calls
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
const [rooms, setRooms] = useState<GroupCallRoom[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
function updateRooms(): void {
|
||||
// We want to show all rooms that historically had a call and which we are (can become) part of.
|
||||
const rooms = client
|
||||
.getRooms()
|
||||
.filter(roomHasCallMembershipEvents)
|
||||
if (!client.groupCallEventHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCalls = client.groupCallEventHandler.groupCalls.values();
|
||||
const rooms = Array.from(groupCalls)
|
||||
.map((groupCall) => groupCall.room)
|
||||
.filter(roomIsJoinable);
|
||||
const sortedRooms = sortRooms(client, rooms);
|
||||
const items = sortedRooms.map((room) => {
|
||||
const session = client.matrixRTC.getRoomSession(room);
|
||||
session.memberships;
|
||||
const groupCall = client.getGroupCallForRoom(room.roomId)!;
|
||||
|
||||
return {
|
||||
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||
roomName: room.name,
|
||||
avatarUrl: room.getMxcAvatarUrl()!,
|
||||
room,
|
||||
session,
|
||||
participants: session.memberships
|
||||
.filter((m) => m.sender)
|
||||
.map((m) => room.getMember(m.sender!))
|
||||
.filter((m) => m) as RoomMember[],
|
||||
groupCall,
|
||||
participants: [...groupCall!.participants.keys()],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -161,17 +120,15 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
|
||||
updateRooms();
|
||||
|
||||
client.matrixRTC.on(
|
||||
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||
updateRooms,
|
||||
);
|
||||
client.on(RoomEvent.MyMembership, updateRooms);
|
||||
return (): void => {
|
||||
client.matrixRTC.off(
|
||||
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||
client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
|
||||
|
||||
return () => {
|
||||
client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.removeListener(
|
||||
GroupCallEventHandlerEvent.Participants,
|
||||
updateRooms,
|
||||
);
|
||||
client.off(RoomEvent.MyMembership, updateRooms);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { BrowserTracing } from "@sentry/browser";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
@@ -32,6 +33,8 @@ enum LoadState {
|
||||
}
|
||||
|
||||
class DependencyLoadStates {
|
||||
// TODO: decide where olm should be initialized (see TODO comment below)
|
||||
// olm: LoadState = LoadState.None;
|
||||
public config: LoadState = LoadState.None;
|
||||
public sentry: LoadState = LoadState.None;
|
||||
public openTelemetry: LoadState = LoadState.None;
|
||||
@@ -52,7 +55,7 @@ export class Initializer {
|
||||
public static initBeforeReact(): void {
|
||||
// this maybe also needs to return a promise in the future,
|
||||
// if we have to do async inits before showing the loading screen
|
||||
// but this should be avoided if possible
|
||||
// but this should be avioded if possible
|
||||
|
||||
//i18n
|
||||
const languageDetector = new LanguageDetector();
|
||||
@@ -116,7 +119,7 @@ export class Initializer {
|
||||
}
|
||||
Initializer.internalInstance = new Initializer();
|
||||
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||
// initStep calls itself recursively until everything is initialized in the correct order.
|
||||
// initStep calls itself recursivly until everything is initialized in the correct order.
|
||||
// Then the promise gets resolved.
|
||||
Initializer.internalInstance.initStep(resolve);
|
||||
});
|
||||
@@ -126,6 +129,18 @@ export class Initializer {
|
||||
private loadStates = new DependencyLoadStates();
|
||||
|
||||
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
|
||||
// TODO: Olm is initialized with the client currently (see `initClient()` and `olm.ts`)
|
||||
// we need to decide if we want to init it here or keep it in initClient
|
||||
// if (this.loadStates.olm === LoadState.None) {
|
||||
// this.loadStates.olm = LoadState.Loading;
|
||||
// // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
// window.OLM_OPTIONS = {};
|
||||
// Olm.init({ locateFile: () => olmWasmPath }).then(() => {
|
||||
// this.loadStates.olm = LoadState.Loaded;
|
||||
// this.initStep(resolve);
|
||||
// });
|
||||
// }
|
||||
|
||||
// config
|
||||
if (this.loadStates.config === LoadState.None) {
|
||||
this.loadStates.config = LoadState.Loading;
|
||||
@@ -145,7 +160,10 @@ export class Initializer {
|
||||
dsn: Config.get().sentry?.DSN,
|
||||
environment: Config.get().sentry?.environment,
|
||||
integrations: [
|
||||
Sentry.reactRouterV5BrowserTracingIntegration({ history }),
|
||||
new BrowserTracing({
|
||||
routingInstrumentation:
|
||||
Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
contain: layout style;
|
||||
}
|
||||
import { Focus } from "matrix-js-sdk/src/matrixrtc/focus";
|
||||
|
||||
.slot {
|
||||
contain: strict;
|
||||
export interface LivekitFocus extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
livekit_alias: string;
|
||||
}
|
||||
@@ -29,12 +29,11 @@ import { Observable } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import {
|
||||
useSetting,
|
||||
audioInput as audioInputSetting,
|
||||
audioOutput as audioOutputSetting,
|
||||
videoInput as videoInputSetting,
|
||||
} from "../settings/settings";
|
||||
import { isFirefox } from "../Platform";
|
||||
isFirefox,
|
||||
useAudioInput,
|
||||
useAudioOutput,
|
||||
useVideoInput,
|
||||
} from "../settings/useSetting";
|
||||
|
||||
export interface MediaDevice {
|
||||
available: MediaDeviceInfo[];
|
||||
@@ -60,7 +59,7 @@ function useObservableState<T>(
|
||||
// observable state doesn't run in SSR
|
||||
if (typeof window === "undefined" || !observable) return;
|
||||
const subscription = observable.subscribe(setState);
|
||||
return (): void => subscription.unsubscribe();
|
||||
return () => subscription.unsubscribe();
|
||||
}, [observable]);
|
||||
return state;
|
||||
}
|
||||
@@ -146,36 +145,43 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
// for ouput devices because the selector wont be shown on FF.
|
||||
const useOutputNames = usingNames && !isFirefox();
|
||||
|
||||
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
|
||||
const [storedAudioOutput, setStoredAudioOutput] =
|
||||
useSetting(audioOutputSetting);
|
||||
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
|
||||
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
|
||||
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
|
||||
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
|
||||
|
||||
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
|
||||
const audioInput = useMediaDevice(
|
||||
"audioinput",
|
||||
audioInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const audioOutput = useMediaDevice(
|
||||
"audiooutput",
|
||||
storedAudioOutput,
|
||||
audioOutputSetting,
|
||||
useOutputNames,
|
||||
alwaysUseDefaultAudio,
|
||||
);
|
||||
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
|
||||
const videoInput = useMediaDevice(
|
||||
"videoinput",
|
||||
videoInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioInput.selectedId !== undefined)
|
||||
setStoredAudioInput(audioInput.selectedId);
|
||||
}, [setStoredAudioInput, audioInput.selectedId]);
|
||||
setAudioInputSetting(audioInput.selectedId);
|
||||
}, [setAudioInputSetting, audioInput.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
||||
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
||||
if (audioOutput.selectedId !== undefined && !isFirefox())
|
||||
setStoredAudioOutput(audioOutput.selectedId);
|
||||
}, [setStoredAudioOutput, audioOutput.selectedId]);
|
||||
setAudioOutputSetting(audioOutput.selectedId);
|
||||
}, [setAudioOutputSetting, audioOutput.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoInput.selectedId !== undefined)
|
||||
setStoredVideoInput(videoInput.selectedId);
|
||||
}, [setStoredVideoInput, videoInput.selectedId]);
|
||||
setVideoInputSetting(videoInput.selectedId);
|
||||
}, [setVideoInputSetting, videoInput.selectedId]);
|
||||
|
||||
const startUsingDeviceNames = useCallback(
|
||||
() => setNumCallersUsingNames((n) => n + 1),
|
||||
|
||||
@@ -18,9 +18,9 @@ import { IOpenIDToken, MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||
import { LivekitFocus } from "./LivekitFocus";
|
||||
import { useActiveFocus } from "../room/useActiveFocus";
|
||||
|
||||
export interface SFUConfig {
|
||||
url: string;
|
||||
@@ -46,7 +46,7 @@ export function useOpenIDSFU(
|
||||
): SFUConfig | undefined {
|
||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||
|
||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||
const activeFocus = useActiveFocus(rtcSession);
|
||||
|
||||
useEffect(() => {
|
||||
(async (): Promise<void> => {
|
||||
|
||||
@@ -59,5 +59,4 @@ export const defaultLiveKitOptions: RoomOptions = {
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||
disconnectOnPageLeave: true,
|
||||
webAudioMix: false,
|
||||
};
|
||||
|
||||
@@ -27,14 +27,6 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import { SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
peerConnectionTimeout?: number;
|
||||
websocketTimeout?: number;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Additional values for states that a call can be in, beyond what livekit
|
||||
@@ -132,21 +124,7 @@ async function connectAndPublish(
|
||||
micTrack: LocalTrack | undefined,
|
||||
screenshareTracks: MediaStreamTrack[],
|
||||
): Promise<void> {
|
||||
const tracker = PosthogAnalytics.instance.eventCallConnectDuration;
|
||||
// Track call connect duration
|
||||
tracker.cacheConnectStart();
|
||||
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
||||
// Due to stability issues on Firefox we are testing the effect of different
|
||||
// timeouts, and allow these values to be set through the console
|
||||
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
|
||||
websocketTimeout: window.websocketTimeout ?? 45000,
|
||||
});
|
||||
|
||||
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
|
||||
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||
tracker.track({ log: true });
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
|
||||
if (micTrack) {
|
||||
logger.info(`Publishing precreated mic track`);
|
||||
@@ -192,7 +170,7 @@ export function useECConnectionState(
|
||||
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
return () => {
|
||||
if (oldRoom)
|
||||
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
} from "./useECConnectionState";
|
||||
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type E2EEConfig = {
|
||||
mode: E2eeType;
|
||||
sharedKey?: string;
|
||||
};
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
@@ -52,35 +56,41 @@ export function useLiveKit(
|
||||
rtcSession: MatrixRTCSession,
|
||||
muteStates: MuteStates,
|
||||
sfuConfig: SFUConfig | undefined,
|
||||
e2eeSystem: EncryptionSystem,
|
||||
e2eeConfig: E2EEConfig,
|
||||
): UseLivekitResult {
|
||||
const e2eeOptions = useMemo((): E2EEOptions | undefined => {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
if (e2eeConfig.mode === E2eeType.NONE) return undefined;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
||||
return {
|
||||
keyProvider: new MatrixKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
} else if (
|
||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
||||
e2eeConfig.sharedKey
|
||||
) {
|
||||
return {
|
||||
keyProvider: new ExternalE2EEKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
}
|
||||
}, [e2eeSystem]);
|
||||
}, [e2eeConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||
if (e2eeConfig.mode === E2eeType.NONE || !e2eeOptions) return;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
} else if (
|
||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
||||
e2eeConfig.sharedKey
|
||||
) {
|
||||
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey(
|
||||
e2eeSystem.secret,
|
||||
e2eeConfig.sharedKey,
|
||||
);
|
||||
}
|
||||
}, [e2eeOptions, e2eeSystem, rtcSession]);
|
||||
}, [e2eeOptions, e2eeConfig, rtcSession]);
|
||||
|
||||
const initialMuteStates = useRef<MuteStates>(muteStates);
|
||||
const devices = useMediaDevices();
|
||||
@@ -121,9 +131,9 @@ export function useLiveKit(
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
const room = useMemo(() => {
|
||||
const r = new Room(roomOptions);
|
||||
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE);
|
||||
r.setE2EEEnabled(e2eeConfig.mode !== E2eeType.NONE);
|
||||
return r;
|
||||
}, [roomOptions, e2eeSystem]);
|
||||
}, [roomOptions, e2eeConfig]);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
{
|
||||
|
||||
@@ -19,24 +19,25 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
||||
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
|
||||
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
|
||||
import {
|
||||
createClient,
|
||||
ICreateClientOpts,
|
||||
Preset,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { EncryptionSystem, saveKeyForRoom } from "../e2ee/sharedKeyManagement";
|
||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||
import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
|
||||
import { loadOlm } from "./olm";
|
||||
import { Config } from "./config/Config";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
||||
|
||||
export const fallbackICEServerAllowed =
|
||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||
@@ -54,13 +55,7 @@ const SYNC_STORE_NAME = "element-call-sync";
|
||||
// (It's a good opportunity to make the database names consistent.)
|
||||
const CRYPTO_STORE_NAME = "element-call-crypto";
|
||||
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
function waitForSync(client: MatrixClient): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
@@ -68,12 +63,8 @@ async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
@@ -96,6 +87,8 @@ export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
await loadOlm();
|
||||
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
@@ -195,16 +188,10 @@ export async function initClient(
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
await client.initRustCrypto();
|
||||
await client.initCrypto();
|
||||
client.setGlobalErrorOnUnknownDevices(false);
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
await client.startClient();
|
||||
await waitForSync(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -246,6 +233,38 @@ export function sanitiseRoomNameInput(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* XXX: What is this trying to do? It looks like it's getting the localpart from
|
||||
* a room alias, but why is it splitting on hyphens and then putting spaces in??
|
||||
* @param roomId
|
||||
* @returns
|
||||
*/
|
||||
export function roomNameFromRoomId(roomId: string): string {
|
||||
return roomId
|
||||
.match(/([^:]+):.*$/)![1]
|
||||
.substring(1)
|
||||
.split("-")
|
||||
.map((part) =>
|
||||
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part,
|
||||
)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
|
||||
if (!roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = roomId.match(/[^:]+:(.*)$/)!;
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parts[1] === client.getDomain();
|
||||
}
|
||||
|
||||
interface CreateRoomResult {
|
||||
roomId: string;
|
||||
alias?: string;
|
||||
@@ -319,6 +338,16 @@ export async function createRoom(
|
||||
|
||||
const result = await createPromise;
|
||||
|
||||
logger.log(`Creating group call in ${result.room_id}`);
|
||||
|
||||
await client.createGroupCall(
|
||||
result.room_id,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
GroupCallIntent.Room,
|
||||
true,
|
||||
);
|
||||
|
||||
let password;
|
||||
if (e2ee == E2eeType.SHARED_KEY) {
|
||||
password = secureRandomBase64Url(16);
|
||||
@@ -336,35 +365,39 @@ export async function createRoom(
|
||||
* Returns an absolute URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||
* @param password e2e key for the room
|
||||
*/
|
||||
export function getAbsoluteRoomUrl(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
roomName?: string,
|
||||
viaServers?: string[],
|
||||
password?: string,
|
||||
): string {
|
||||
return `${window.location.protocol}//${
|
||||
window.location.host
|
||||
}${getRelativeRoomUrl(roomId, encryptionSystem, roomName, viaServers)}`;
|
||||
}${getRelativeRoomUrl(roomId, roomName, password)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a relative URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||
* @param password e2e key for the room
|
||||
*/
|
||||
export function getRelativeRoomUrl(
|
||||
roomId: string,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
roomName?: string,
|
||||
viaServers?: string[],
|
||||
password?: string,
|
||||
): string {
|
||||
const roomPart = roomName
|
||||
? "/" + roomAliasLocalpartFromRoomName(roomName)
|
||||
: "";
|
||||
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
|
||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||
// it in case it came from another client that generated a non url-safe one
|
||||
const encodedPassword = password ? encodeURIComponent(password) : undefined;
|
||||
if (password && encodedPassword !== password) {
|
||||
logger.info("Encoded call password used non URL-safe chars: buggy client?");
|
||||
}
|
||||
|
||||
return `/room/#${
|
||||
roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
|
||||
}?roomId=${roomId}${password ? "&" + PASSWORD_STRING + encodedPassword : ""}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||
import { Observable, defer, finalize, tap } from "rxjs";
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
@@ -35,15 +35,3 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RxJS operator that accumulates a state from a source of events. This is like
|
||||
* scan, except it emits an initial value immediately before any events arrive.
|
||||
*/
|
||||
export function accumulate<State, Event>(
|
||||
initial: State,
|
||||
update: (state: State, event: Event) => State,
|
||||
) {
|
||||
return (events: Observable<Event>): Observable<State> =>
|
||||
events.pipe(scan(update, initial), startWith(initial));
|
||||
}
|
||||
29
src/olm.ts
Normal file
29
src/olm.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Olm from "@matrix-org/olm";
|
||||
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
|
||||
|
||||
// https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
window.OLM_OPTIONS = {};
|
||||
|
||||
let olmLoaded: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Loads Olm, if not already loaded.
|
||||
*/
|
||||
export const loadOlm = (): Promise<void> =>
|
||||
(olmLoaded ??= Olm.init({ locateFile: () => olmWasmPath }));
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
SummaryStatsReport,
|
||||
CallFeedReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
@@ -445,7 +446,7 @@ export class OTelGroupCallMembership {
|
||||
const type = OTelStatsReportType.SummaryReport;
|
||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
const ctx = setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan,
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
||||
user?.on(UserEvent.AvatarUrl, onChangeUser);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
return () => {
|
||||
if (user) {
|
||||
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
||||
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
||||
@@ -118,7 +118,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
||||
displayName,
|
||||
avatarUrl: removeAvatar
|
||||
? undefined
|
||||
: (mxcAvatarUrl ?? prev.avatarUrl),
|
||||
: mxcAvatarUrl ?? prev.avatarUrl,
|
||||
loading: false,
|
||||
success: true,
|
||||
}));
|
||||
|
||||
@@ -17,18 +17,17 @@ limitations under the License.
|
||||
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import PopOutIcon from "@vector-im/compound-design-tokens/icons/pop-out.svg?react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||
import styles from "./AppSelectionModal.module.css";
|
||||
import { editFragmentQuery } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
interface Props {
|
||||
roomId: string;
|
||||
roomId: string | null;
|
||||
}
|
||||
|
||||
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
@@ -43,9 +42,10 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
const e2eeSystem = useRoomEncryptionSystem(roomId);
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.NONE) {
|
||||
const roomSharedKey = useRoomSharedKey(roomId ?? "");
|
||||
const roomIsEncrypted = useIsRoomE2EE(roomId ?? "");
|
||||
if (roomIsEncrypted && roomSharedKey === undefined) {
|
||||
logger.error(
|
||||
"Generating app redirect URL for encrypted room but don't have key available!",
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
const url = new URL(
|
||||
roomId === null
|
||||
? window.location.href
|
||||
: getAbsoluteRoomUrl(roomId, e2eeSystem),
|
||||
: getAbsoluteRoomUrl(roomId, undefined, roomSharedKey ?? undefined),
|
||||
);
|
||||
// Edit the URL to prevent the app selection prompt from appearing a second
|
||||
// time within the app, and to keep the user confined to the current room
|
||||
@@ -73,7 +73,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||
const result = new URL("io.element.call:/");
|
||||
result.searchParams.set("url", url.toString());
|
||||
return result.toString();
|
||||
}, [e2eeSystem, roomId]);
|
||||
}, [roomId, roomSharedKey]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -51,11 +51,11 @@ export const CallEndedView: FC<Props> = ({
|
||||
const history = useHistory();
|
||||
|
||||
const { displayName } = useProfile(client);
|
||||
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
||||
const [surveySubmitted, setSurverySubmitted] = useState(false);
|
||||
const [starRating, setStarRating] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitDone, setSubmitDone] = useState(false);
|
||||
const submitSurvey: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
@@ -75,7 +75,7 @@ export const CallEndedView: FC<Props> = ({
|
||||
setTimeout(() => {
|
||||
if (isPasswordlessUser) {
|
||||
// setting this renders the callEndedView with the invitation to create an account
|
||||
setSurveySubmitted(true);
|
||||
setSurverySubmitted(true);
|
||||
} else if (!confineToRoom) {
|
||||
// if the user already has an account immediately go back to the home screen
|
||||
history.push("/");
|
||||
@@ -113,7 +113,7 @@ export const CallEndedView: FC<Props> = ({
|
||||
We'd love to hear your feedback so we can improve your experience.
|
||||
</p>
|
||||
</Trans>
|
||||
<form onSubmit={submitSurvey}>
|
||||
<form onSubmit={submitSurvery}>
|
||||
<FieldRow>
|
||||
<StarRatingInput starCount={5} onChange={setStarRating} required />
|
||||
</FieldRow>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -17,10 +17,8 @@ limitations under the License.
|
||||
import { FC } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LockSolidIcon,
|
||||
LockOffIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import LockSolidIcon from "@vector-im/compound-design-tokens/icons/lock-solid.svg?react";
|
||||
import LockOffIcon from "@vector-im/compound-design-tokens/icons/lock-off.svg?react";
|
||||
|
||||
import styles from "./EncryptionLock.module.css";
|
||||
|
||||
@@ -34,13 +32,13 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
||||
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
|
||||
|
||||
return (
|
||||
<Tooltip label={label} placement="right" isTriggerInteractive={false}>
|
||||
<Tooltip label={label} side="right" isTriggerInteractive={false}>
|
||||
<Icon
|
||||
width={16}
|
||||
height={16}
|
||||
className={styles.lock}
|
||||
data-encrypted={encrypted}
|
||||
aria-hidden
|
||||
aria-label={label}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -14,25 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ReactNode, useCallback } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
CallTerminatedMessage,
|
||||
} from "./useLoadGroupCall";
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||
children: (rtcSession: MatrixRTCSession) => ReactNode;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
@@ -54,22 +51,20 @@ export function GroupCallLoader({
|
||||
);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
case "canKnock":
|
||||
return children(groupCallState);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "loaded":
|
||||
return <>{children(groupCallState.rtcSession)}</>;
|
||||
case "failed":
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
<Heading>{t("group_call_loader_failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader_failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
@@ -77,22 +72,6 @@ export function GroupCallLoader({
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("common.home")}
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@ limitations under the License.
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
Room,
|
||||
isE2EESupported as isE2EESupportedBrowser,
|
||||
} from "livekit-client";
|
||||
import { Room, isE2EESupported } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
@@ -29,24 +26,25 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||
import { FullScreenView } from "../FullScreenView";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../utils/media";
|
||||
import { findDeviceByName } from "../media-utils";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
|
||||
import { MuteStates, useMuteStates } from "./MuteStates";
|
||||
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { E2EEConfig } from "../livekit/useLiveKit";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
@@ -64,7 +62,6 @@ interface Props {
|
||||
skipLobby: boolean;
|
||||
hideHeader: boolean;
|
||||
rtcSession: MatrixRTCSession;
|
||||
muteStates: MuteStates;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -75,21 +72,13 @@ export const GroupCallView: FC<Props> = ({
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
rtcSession,
|
||||
muteStates,
|
||||
}) => {
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
return (): void => {
|
||||
return () => {
|
||||
delete window.rtcSession;
|
||||
};
|
||||
}, [rtcSession]);
|
||||
@@ -97,8 +86,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(rtcSession.room);
|
||||
const roomAvatar = useRoomAvatar(rtcSession.room);
|
||||
const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
|
||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const roomEncrypted =
|
||||
useIsRoomE2EE(rtcSession.room.roomId) || perParticipantE2EE;
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
return {
|
||||
@@ -109,16 +100,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
roomName,
|
||||
roomAlias: rtcSession.room.getCanonicalAlias(),
|
||||
roomAvatar,
|
||||
e2eeSystem,
|
||||
roomEncrypted,
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
rtcSession.room,
|
||||
rtcSession,
|
||||
roomName,
|
||||
roomAvatar,
|
||||
e2eeSystem,
|
||||
roomEncrypted,
|
||||
client,
|
||||
]);
|
||||
|
||||
// Count each member only once, regardless of how many devices they use
|
||||
@@ -131,9 +122,20 @@ export const GroupCallView: FC<Props> = ({
|
||||
const latestDevices = useRef<MediaDevices>();
|
||||
latestDevices.current = deviceContext;
|
||||
|
||||
const muteStates = useMuteStates(memberships.length);
|
||||
const latestMuteStates = useRef<MuteStates>();
|
||||
latestMuteStates.current = muteStates;
|
||||
|
||||
const e2eeConfig = useMemo((): E2EEConfig => {
|
||||
if (perParticipantE2EE) {
|
||||
return { mode: E2eeType.PER_PARTICIPANT };
|
||||
} else if (e2eeSharedKey) {
|
||||
return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
|
||||
} else {
|
||||
return { mode: E2eeType.NONE };
|
||||
}
|
||||
}, [perParticipantE2EE, e2eeSharedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDeviceSetup = async (
|
||||
requestedDeviceData: JoinCallData,
|
||||
@@ -190,21 +192,18 @@ export const GroupCallView: FC<Props> = ({
|
||||
const onJoin = async (
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
return () => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
const join = async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
};
|
||||
// No lobby and no preload: we enter the RTC Session right away.
|
||||
join();
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
@@ -253,7 +252,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
await leaveRTCSession(rtcSession);
|
||||
};
|
||||
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
||||
return (): void => {
|
||||
return () => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
||||
};
|
||||
}
|
||||
@@ -289,8 +288,17 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
||||
// If we have a encryption system but the browser does not support it.
|
||||
if (roomEncrypted && !perParticipantE2EE && !e2eeSharedKey) {
|
||||
return (
|
||||
<ErrorView
|
||||
error={
|
||||
new Error(
|
||||
"No E2EE key provided: please make sure the URL you're using to join this call has been retrieved using the in-app button.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (!isE2EESupported() && roomEncrypted) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
|
||||
@@ -316,7 +324,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||
onEnter={(): void => enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={participantCount}
|
||||
@@ -337,7 +345,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
onLeave={onLeave}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
e2eeConfig={e2eeConfig}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
onShareClick={onShareClick}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021-2024 New Vector Ltd
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -19,31 +19,36 @@ limitations under the License.
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
inset-block-start: 0;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
.controlsOverlay {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overflow-inline: hidden;
|
||||
/* There used to be a contain: strict here, but due to some bugs in Firefox,
|
||||
this was causing the Z-ordering of modals to glitch out. It can be added back
|
||||
if those issues appear to be resolved. */
|
||||
}
|
||||
|
||||
.header.filler {
|
||||
block-size: var(--cpd-space-6x);
|
||||
background: none;
|
||||
.centerMessage {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centerMessage p {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: "logo buttons layout";
|
||||
@@ -58,29 +63,6 @@ limitations under the License.
|
||||
);
|
||||
}
|
||||
|
||||
.footer.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer.overlay {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.footer.overlay.hidden {
|
||||
display: grid;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.logo {
|
||||
grid-area: logo;
|
||||
justify-self: start;
|
||||
@@ -103,13 +85,13 @@ limitations under the License.
|
||||
|
||||
@media (min-height: 400px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
padding-block: var(--cpd-space-10x);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-10x);
|
||||
padding-block: var(--cpd-space-15x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,37 +101,11 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
|
||||
.fixedGrid {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
align-self: center;
|
||||
.footerThin {
|
||||
padding-top: var(--cpd-space-3x);
|
||||
padding-bottom: var(--cpd-space-5x);
|
||||
}
|
||||
|
||||
.scrollingGrid {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
inline-size: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.fixedGrid,
|
||||
.scrollingGrid {
|
||||
/* Disable pointer events so the overlay doesn't block interaction with
|
||||
elements behind it */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fixedGrid > :not(:first-child),
|
||||
.scrollingGrid > :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.tile.maximised {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
.footerHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 - 2024 New Vector Ltd
|
||||
Copyright 2022 - 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,31 +14,31 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import {
|
||||
RoomAudioRenderer,
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
useTracks,
|
||||
} from "@livekit/components-react";
|
||||
import { usePreventScroll } from "@react-aria/overlays";
|
||||
import { ConnectionState, Room } from "livekit-client";
|
||||
import { ConnectionState, Room, Track } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
FC,
|
||||
PointerEvent,
|
||||
PropsWithoutRef,
|
||||
TouchEvent,
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useStateObservable } from "@react-rxjs/core";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -51,16 +51,21 @@ import {
|
||||
SettingsButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { VideoTile } from "../video-grid/VideoTile";
|
||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useLiveKit } from "../livekit/useLiveKit";
|
||||
import { E2EEConfig, useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useFullscreen } from "./useFullscreen";
|
||||
import { useLayoutStates } from "../video-grid/Layout";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
@@ -69,32 +74,15 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import { CallViewModel, GridMode, Layout } from "../state/CallViewModel";
|
||||
import { Grid, TileProps } from "../grid/Grid";
|
||||
import { useObservable } from "../state/useObservable";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
CallLayoutOutputs,
|
||||
TileModel,
|
||||
defaultPipAlignment,
|
||||
defaultSpotlightAlignment,
|
||||
} from "../grid/CallLayout";
|
||||
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
import { useCallViewModel } from "../state/CallViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
e2eeConfig: E2EEConfig;
|
||||
}
|
||||
|
||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
@@ -103,53 +91,27 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.rtcSession,
|
||||
props.muteStates,
|
||||
sfuConfig,
|
||||
props.e2eeSystem,
|
||||
props.e2eeConfig,
|
||||
);
|
||||
const connStateObservable = useObservable(connState);
|
||||
const [vm, setVm] = useState<CallViewModel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
return () => {
|
||||
livekitRoom?.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession.room,
|
||||
livekitRoom,
|
||||
props.e2eeSystem.kind !== E2eeType.NONE,
|
||||
connStateObservable,
|
||||
);
|
||||
setVm(vm);
|
||||
return (): void => vm.destroy();
|
||||
}
|
||||
}, [
|
||||
props.rtcSession.room,
|
||||
livekitRoom,
|
||||
props.e2eeSystem.kind,
|
||||
connStateObservable,
|
||||
]);
|
||||
|
||||
if (livekitRoom === undefined || vm === null) return null;
|
||||
if (!livekitRoom) return null;
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={livekitRoom}>
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
livekitRoom={livekitRoom}
|
||||
connState={connState}
|
||||
/>
|
||||
<InCallView {...props} livekitRoom={livekitRoom} connState={connState} />
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export interface InCallViewProps {
|
||||
client: MatrixClient;
|
||||
vm: CallViewModel;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
livekitRoom: Room;
|
||||
@@ -162,428 +124,315 @@ export interface InCallViewProps {
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
vm,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
({
|
||||
client,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === ConnectionState.Disconnected) {
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
onLeave(new Error("Disconnected from call server"));
|
||||
}
|
||||
}, [connState, onLeave]);
|
||||
useEffect(() => {
|
||||
if (connState === ConnectionState.Disconnected) {
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
onLeave(new Error("Disconnected from call server"));
|
||||
}
|
||||
}, [connState, onLeave]);
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure();
|
||||
const boundsValid = bounds.height > 0;
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
const boundsValid = bounds.height > 0;
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const { hideScreensharing, showControls } = useUrlParams();
|
||||
|
||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const toggleMicrophone = useCallback(
|
||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
const toggleCamera = useCallback(
|
||||
() => muteStates.video.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleMicrophone,
|
||||
toggleCamera,
|
||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||
);
|
||||
|
||||
const mobile = boundsValid && bounds.width <= 660;
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
|
||||
const windowMode = useObservableEagerState(vm.windowMode);
|
||||
const layout = useObservableEagerState(vm.layout);
|
||||
const gridMode = useObservableEagerState(vm.gridMode);
|
||||
const showHeader = useObservableEagerState(vm.showHeader);
|
||||
const showFooter = useObservableEagerState(vm.showFooter);
|
||||
|
||||
// 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<number | null>(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 layout toggle to prevent touch events from
|
||||
// bubbling up, or else the controls will be dismissed before a change event
|
||||
// can be registered on the toggle
|
||||
const onLayoutToggleTouchEnd = useCallback(
|
||||
(e: TouchEvent) => e.stopPropagation(),
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
if (e.pointerType === "mouse") vm.hoverScreen();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const openProfile = useCallback(() => {
|
||||
setSettingsTab("profile");
|
||||
setSettingsModalOpen(true);
|
||||
}, [setSettingsTab, setSettingsModalOpen]);
|
||||
|
||||
const [headerRef, headerBounds] = useMeasure();
|
||||
const [footerRef, footerBounds] = useMeasure();
|
||||
|
||||
const gridBounds = useMemo(
|
||||
() => ({
|
||||
width: bounds.width,
|
||||
height:
|
||||
bounds.height -
|
||||
headerBounds.height -
|
||||
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||
}),
|
||||
[
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
headerBounds.height,
|
||||
footerBounds.height,
|
||||
windowMode,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable = useObservable(gridBounds);
|
||||
|
||||
const spotlightAlignment = useInitial(
|
||||
() => new BehaviorSubject(defaultSpotlightAlignment),
|
||||
);
|
||||
const pipAlignment = useInitial(
|
||||
() => new BehaviorSubject(defaultPipAlignment),
|
||||
);
|
||||
|
||||
const setGridMode = useCallback(
|
||||
(mode: GridMode) => vm.setGridMode(mode),
|
||||
[vm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
gridMode === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
const screenSharingTracks = useTracks(
|
||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||
{
|
||||
room: livekitRoom,
|
||||
},
|
||||
);
|
||||
const { layout, setLayout } = useVideoGridLayout(
|
||||
screenSharingTracks.length > 0,
|
||||
);
|
||||
}, [gridMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const { hideScreensharing, showControls } = useUrlParams();
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const toggleMicrophone = useCallback(
|
||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
const toggleCamera = useCallback(
|
||||
() => muteStates.video.setEnabled?.((e) => !e),
|
||||
[muteStates],
|
||||
);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleMicrophone,
|
||||
toggleCamera,
|
||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
layout === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
);
|
||||
}, [layout]);
|
||||
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLayout("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): void => {
|
||||
setLayout("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
const Tile = useMemo(
|
||||
() =>
|
||||
forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
|
||||
>(function Tile(
|
||||
{ className, style, targetWidth, targetHeight, model },
|
||||
ref,
|
||||
) {
|
||||
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
|
||||
const onToggleExpanded = useObservableEagerState(
|
||||
vm.toggleSpotlightExpanded,
|
||||
);
|
||||
const showVideo = useObservableEagerState(
|
||||
useMemo(
|
||||
() =>
|
||||
model.type === "grid" ? vm.showGridVideo(model.vm) : of(true),
|
||||
[model],
|
||||
),
|
||||
);
|
||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||
vm.showSpeakingIndicators,
|
||||
);
|
||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||
vm.showSpotlightIndicators,
|
||||
);
|
||||
return () => {
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.TileLayout,
|
||||
onTileLayout,
|
||||
);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setLayout]);
|
||||
|
||||
return model.type === "grid" ? (
|
||||
<GridTile
|
||||
ref={ref}
|
||||
vm={model.vm}
|
||||
const mobile = boundsValid && bounds.width <= 660;
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
|
||||
const vm = useCallViewModel(
|
||||
rtcSession.room,
|
||||
livekitRoom,
|
||||
matrixInfo.roomEncrypted,
|
||||
connState,
|
||||
);
|
||||
const items = useStateObservable(vm.tiles);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(items);
|
||||
|
||||
// The maximised participant: either the participant that the user has
|
||||
// manually put in fullscreen, or the focused (active) participant if the
|
||||
// window is too small to show everyone
|
||||
const maximisedParticipant = useMemo(
|
||||
() =>
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
: null),
|
||||
[fullscreenItem, noControls, items],
|
||||
);
|
||||
|
||||
const Grid =
|
||||
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
|
||||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// This state is lifted out of NewVideoGrid so that layout states can be
|
||||
// restored after a layout switch or upon exiting fullscreen
|
||||
const layoutStates = useLayoutStates();
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>{t("waiting_for_participants")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTile
|
||||
vm={maximisedParticipant.data}
|
||||
maximised={true}
|
||||
fullscreen={maximisedParticipant === fullscreenItem}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
showSpeakingIndicator={false}
|
||||
onOpenProfile={openProfile}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
showVideo={showVideo}
|
||||
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightTile
|
||||
ref={ref}
|
||||
vms={model.vms}
|
||||
maximised={model.maximised}
|
||||
expanded={spotlightExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicatorsValue}
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[vm, openProfile],
|
||||
);
|
||||
}
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
const inputs = {
|
||||
minBounds: gridBoundsObservable,
|
||||
spotlightAlignment,
|
||||
pipAlignment,
|
||||
};
|
||||
return {
|
||||
grid: makeGridLayout(inputs),
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||
"one-on-one": makeOneOnOneLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
vms={layout.spotlight!}
|
||||
maximised
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
targetWidth={gridBounds.height}
|
||||
targetHeight={gridBounds.width}
|
||||
showIndicators={false}
|
||||
/>
|
||||
<Grid
|
||||
items={items}
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
layoutStates={layoutStates}
|
||||
>
|
||||
{({ data: vm, ...props }): ReactNode => (
|
||||
<VideoTile
|
||||
vm={vm}
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
onOpenProfile={openProfile}
|
||||
{...props}
|
||||
ref={props.ref as Ref<HTMLDivElement>}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const openProfile = useCallback(() => {
|
||||
setSettingsTab("profile");
|
||||
setSettingsModalOpen(true);
|
||||
}, [setSettingsTab, setSettingsModalOpen]);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
exitFullscreen();
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||
audio: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
});
|
||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
if (noControls) {
|
||||
footer = null;
|
||||
} else {
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="1"
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={toggleMicrophone}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={toggleCamera}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreenShareEnabled}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<HangupButton
|
||||
key="6"
|
||||
onPress={function (): void {
|
||||
onLeave();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
footer = (
|
||||
<div
|
||||
className={classNames(
|
||||
showControls
|
||||
? styles.footer
|
||||
: hideHeader
|
||||
? [styles.footer, styles.footerHidden]
|
||||
: [styles.footer, styles.footerThin],
|
||||
)}
|
||||
>
|
||||
{!mobile && !hideHeader && (
|
||||
<div className={styles.logo}>
|
||||
<LogoMark width={24} height={24} aria-hidden />
|
||||
<LogoType
|
||||
width={80}
|
||||
height={11}
|
||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{!mobile && !hideHeader && showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
|
||||
const fixedGrid = (
|
||||
<Grid
|
||||
key="fixed"
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart: headerBounds.bottom,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
Layout={layers.fixed}
|
||||
Tile={Tile}
|
||||
/>
|
||||
);
|
||||
const scrollingGrid = (
|
||||
<Grid
|
||||
key="scrolling"
|
||||
className={styles.scrollingGrid}
|
||||
model={layout}
|
||||
Layout={layers.scrolling}
|
||||
Tile={Tile}
|
||||
/>
|
||||
);
|
||||
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||
// *over* the spotlight in the expanded layout
|
||||
return layout.type === "spotlight-expanded" ? (
|
||||
<>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||
audio: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
});
|
||||
}, [localParticipant, isScreenShareEnabled]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
if (noControls) {
|
||||
footer = null;
|
||||
} else {
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="1"
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={toggleMicrophone}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={toggleCamera}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreenShareEnabled}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<HangupButton
|
||||
key="6"
|
||||
onPress={function (): void {
|
||||
onLeave();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
footer = (
|
||||
<div
|
||||
ref={footerRef}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: windowMode === "flat",
|
||||
[styles.hidden]: !showFooter || (!showControls && hideHeader),
|
||||
})}
|
||||
>
|
||||
{!mobile && !hideHeader && (
|
||||
<div className={styles.logo}>
|
||||
<LogoMark width={24} height={24} aria-hidden />
|
||||
<LogoType
|
||||
width={80}
|
||||
height={11}
|
||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{!mobile && showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
onTouchEnd={onLayoutToggleTouchEnd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
ref={containerRef}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchCancel}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
{showHeader &&
|
||||
(hideHeader ? (
|
||||
// Cosmetic header to fill out space while still affecting the bounds
|
||||
// of the grid
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
) : (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
return (
|
||||
<div className={styles.inRoom} ref={containerRef}>
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
@@ -593,19 +442,24 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
))}
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)}
|
||||
<div className={styles.controlsOverlay}>
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
</div>
|
||||
{!noControls && (
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
)}
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,17 +18,15 @@ import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Room } from "matrix-js-sdk";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import LinkIcon from "@vector-im/compound-design-tokens/icons/link.svg?react";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
|
||||
import useClipboard from "react-use-clipboard";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||
import styles from "./InviteModal.module.css";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { Toast } from "../Toast";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
@@ -38,11 +36,11 @@ interface Props {
|
||||
|
||||
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
||||
const url = useMemo(
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
() =>
|
||||
getAbsoluteRoomUrl(room.roomId, room.name, roomSharedKey ?? undefined),
|
||||
[room, roomSharedKey],
|
||||
);
|
||||
const [, setCopied] = useClipboard(url);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -21,15 +21,22 @@ limitations under the License.
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
box-shadow: 0px 0px 40px 0px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
/* Safari puts a margin on these, which is not removed via appearance: none */
|
||||
/*
|
||||
* Safari puts a margin on these, which is not removed via appearance: none
|
||||
* mobile safari also has them take up space in the DOM, so set width 0
|
||||
*/
|
||||
margin: 0;
|
||||
block-size: var(--cpd-space-11x);
|
||||
inline-size: var(--cpd-space-11x);
|
||||
width: 0;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.toggle label {
|
||||
display: block;
|
||||
padding: calc(2.5 * var(--cpd-space-1x));
|
||||
cursor: pointer;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
color: var(--cpd-color-icon-primary);
|
||||
@@ -37,52 +44,41 @@ limitations under the License.
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
}
|
||||
|
||||
.toggle svg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
padding: calc(2.5 * var(--cpd-space-1x));
|
||||
pointer-events: none;
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.toggle svg:nth-child(2) {
|
||||
inset-inline-start: 2px;
|
||||
}
|
||||
|
||||
.toggle svg:nth-child(4) {
|
||||
inset-inline-end: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.toggle input:hover {
|
||||
.toggle label:hover {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle input:active {
|
||||
.toggle label:active {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toggle input:checked {
|
||||
.toggle input:checked + label {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
background: var(--cpd-color-bg-action-primary-rest);
|
||||
}
|
||||
|
||||
.toggle input:checked + svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.toggle input:checked:hover {
|
||||
.toggle input:checked + label:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle input:checked:active {
|
||||
.toggle input:checked + label:active {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
|
||||
.toggle input:first-child {
|
||||
margin-inline-end: 5px;
|
||||
.toggle label > svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle label:last-child {
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
|
||||
.toggle input:focus-visible + label {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,13 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
|
||||
import { ChangeEvent, FC, useCallback, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import SpotlightIcon from "@vector-im/compound-design-tokens/icons/spotlight.svg?react";
|
||||
import GridIcon from "@vector-im/compound-design-tokens/icons/grid.svg?react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./LayoutToggle.module.css";
|
||||
@@ -31,15 +29,9 @@ interface Props {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
className?: string;
|
||||
onTouchEnd?: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
export const LayoutToggle: FC<Props> = ({
|
||||
layout,
|
||||
setLayout,
|
||||
className,
|
||||
onTouchEnd,
|
||||
}) => {
|
||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange = useCallback(
|
||||
@@ -47,30 +39,37 @@ export const LayoutToggle: FC<Props> = ({
|
||||
[setLayout],
|
||||
);
|
||||
|
||||
const spotlightId = useId();
|
||||
const gridId = useId();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.toggle, className)}>
|
||||
<input
|
||||
id={spotlightId}
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Tooltip label={t("layout_spotlight_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
<label htmlFor={spotlightId}>
|
||||
<SpotlightIcon aria-label={t("layout_spotlight_label")} />
|
||||
</label>
|
||||
</Tooltip>
|
||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||
<input
|
||||
id={gridId}
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Tooltip label={t("layout_grid_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
<label htmlFor={gridId}>
|
||||
<GridIcon aria-label={t("layout_grid_label")} />
|
||||
</label>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,18 +25,6 @@ limitations under the License.
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wait {
|
||||
color: var(--cpd-color-text-primary) !important;
|
||||
background-color: var(--cpd-color-bg-canvas-default) !important;
|
||||
/* relative colors are only supported on chromium based browsers */
|
||||
background-color: rgb(
|
||||
from var(--cpd-color-bg-canvas-default) r g b / 0.5
|
||||
) !important;
|
||||
}
|
||||
.wait > svg {
|
||||
color: var(--cpd-color-theme-primary) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.join {
|
||||
width: 100%;
|
||||
|
||||
@@ -21,8 +21,8 @@ import { Button, Link } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
@@ -36,19 +36,16 @@ import {
|
||||
} from "../button/Button";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
onEnter: () => void;
|
||||
enterLabel?: JSX.Element | string;
|
||||
confineToRoom: boolean;
|
||||
hideHeader: boolean;
|
||||
participantCount: number | null;
|
||||
participantCount: number;
|
||||
onShareClick: (() => void) | null;
|
||||
waitingForInvite?: boolean;
|
||||
}
|
||||
|
||||
export const LobbyView: FC<Props> = ({
|
||||
@@ -56,12 +53,10 @@ export const LobbyView: FC<Props> = ({
|
||||
matrixInfo,
|
||||
muteStates,
|
||||
onEnter,
|
||||
enterLabel,
|
||||
confineToRoom,
|
||||
hideHeader,
|
||||
participantCount,
|
||||
onShareClick,
|
||||
waitingForInvite,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
useLocationNavigation();
|
||||
@@ -109,7 +104,7 @@ export const LobbyView: FC<Props> = ({
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
@@ -121,16 +116,12 @@ export const LobbyView: FC<Props> = ({
|
||||
<div className={styles.content}>
|
||||
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
|
||||
<Button
|
||||
className={classNames(styles.join, {
|
||||
[styles.wait]: waitingForInvite,
|
||||
})}
|
||||
size={waitingForInvite ? "sm" : "lg"}
|
||||
onClick={() => {
|
||||
if (!waitingForInvite) onEnter();
|
||||
}}
|
||||
className={styles.join}
|
||||
size="lg"
|
||||
onClick={onEnter}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
{enterLabel ?? t("lobby.join_button")}
|
||||
{t("lobby.join_button")}
|
||||
</Button>
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
|
||||
@@ -14,24 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { Dispatch, SetStateAction, useMemo } from "react";
|
||||
|
||||
import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
* the user.
|
||||
* If there already is this many participants in the call, we automatically mute
|
||||
* the user
|
||||
*/
|
||||
export const MUTE_PARTICIPANT_COUNT = 8;
|
||||
const MUTE_PARTICIPANT_COUNT = 8;
|
||||
|
||||
interface DeviceAvailable {
|
||||
enabled: boolean;
|
||||
@@ -59,85 +51,27 @@ function useMuteState(
|
||||
device: MediaDevice,
|
||||
enabledByDefault: () => boolean,
|
||||
): MuteState {
|
||||
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
|
||||
(prev) =>
|
||||
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined,
|
||||
const [enabled, setEnabled] = useReactiveState<boolean>(
|
||||
(prev) => device.available.length > 0 && (prev ?? enabledByDefault()),
|
||||
[device],
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
device.available.length === 0
|
||||
? deviceUnavailable
|
||||
: {
|
||||
enabled: enabled ?? false,
|
||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||
},
|
||||
: { enabled, setEnabled },
|
||||
[device, enabled, setEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
export function useMuteStates(): MuteStates {
|
||||
export function useMuteStates(participantCount: number): MuteStates {
|
||||
const devices = useMediaDevices();
|
||||
|
||||
const audio = useMuteState(devices.audioInput, () => true);
|
||||
const video = useMuteState(devices.videoInput, () => true);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(ElementWidgetActions.DeviceMute, {
|
||||
audio_enabled: audio.enabled,
|
||||
video_enabled: video.enabled,
|
||||
});
|
||||
}, [audio, video]);
|
||||
|
||||
const onMuteStateChangeRequest = useCallback(
|
||||
(ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
// First copy the current state into our new state.
|
||||
const newState = {
|
||||
audio_enabled: audio.enabled,
|
||||
video_enabled: video.enabled,
|
||||
};
|
||||
// Update new state if there are any requested changes from the widget action
|
||||
// in `ev.detail.data`.
|
||||
if (
|
||||
ev.detail.data.audio_enabled != null &&
|
||||
typeof ev.detail.data.audio_enabled === "boolean"
|
||||
) {
|
||||
audio.setEnabled?.(ev.detail.data.audio_enabled);
|
||||
newState.audio_enabled = ev.detail.data.audio_enabled;
|
||||
}
|
||||
if (
|
||||
ev.detail.data.video_enabled != null &&
|
||||
typeof ev.detail.data.video_enabled === "boolean"
|
||||
) {
|
||||
video.setEnabled?.(ev.detail.data.video_enabled);
|
||||
newState.video_enabled = ev.detail.data.video_enabled;
|
||||
}
|
||||
// Always reply with the new (now "current") state.
|
||||
// This allows to also use this action to just get the unaltered current state
|
||||
// by using a fromWidget request with: `ev.detail.data = {}`
|
||||
widget!.api.transport.reply(ev.detail, newState);
|
||||
},
|
||||
[audio, video],
|
||||
const audio = useMuteState(
|
||||
devices.audioInput,
|
||||
() => participantCount <= MUTE_PARTICIPANT_COUNT,
|
||||
);
|
||||
useEffect(() => {
|
||||
// We setup a event listener for the widget action ElementWidgetActions.DeviceMute.
|
||||
if (widget) {
|
||||
// only setup the listener in widget mode
|
||||
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.DeviceMute,
|
||||
onMuteStateChangeRequest,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
// return a call to `off` so that we always clean up our listener.
|
||||
widget?.lazyActions.off(
|
||||
ElementWidgetActions.DeviceMute,
|
||||
onMuteStateChangeRequest,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [onMuteStateChangeRequest]);
|
||||
const video = useMuteState(devices.videoInput, () => true);
|
||||
|
||||
return useMemo(() => ({ audio, video }), [audio, video]);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const RoomAuthView: FC = () => {
|
||||
Not registered yet?{" "}
|
||||
<Link
|
||||
color="primary"
|
||||
to={{ pathname: "/register", state: { from: location } }}
|
||||
to={{ pathname: "/login", state: { from: location } }}
|
||||
>
|
||||
Create an account
|
||||
</Link>
|
||||
|
||||
@@ -15,9 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
@@ -26,16 +25,11 @@ import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { HomePage } from "../home/HomePage";
|
||||
import { platform } from "../Platform";
|
||||
import { AppSelectionModal } from "./AppSelectionModal";
|
||||
import { widget } from "../widget";
|
||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -46,7 +40,7 @@ export const RoomPage: FC = () => {
|
||||
displayName,
|
||||
skipLobby,
|
||||
} = useUrlParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||
|
||||
const roomIdOrAlias = roomId ?? roomAlias;
|
||||
@@ -54,14 +48,17 @@ export const RoomPage: FC = () => {
|
||||
logger.error("No room specified");
|
||||
}
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const { loading, authenticated, client, error, passwordlessUser } =
|
||||
useClientLegacy();
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
useEffect(() => {
|
||||
// If we've finished loading, are not already authed and we've been given a display name as
|
||||
@@ -80,87 +77,19 @@ export const RoomPage: FC = () => {
|
||||
registerPasswordlessUser,
|
||||
]);
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
useEffect(() => {
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
passwordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
t,
|
||||
userDisplayName,
|
||||
avatarUrl,
|
||||
],
|
||||
(rtcSession: MatrixRTCSession) => (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
/>
|
||||
),
|
||||
[client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby],
|
||||
);
|
||||
|
||||
let content: ReactNode;
|
||||
@@ -189,9 +118,9 @@ export const RoomPage: FC = () => {
|
||||
<>
|
||||
{content}
|
||||
{/* On Android and iOS, show a prompt to launch the mobile app. */}
|
||||
{appPrompt &&
|
||||
(platform === "android" || platform === "ios") &&
|
||||
roomId && <AppSelectionModal roomId={roomId} />}
|
||||
{appPrompt && (platform === "android" || platform === "ios") && (
|
||||
<AppSelectionModal roomId={roomId} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,12 +18,20 @@ limitations under the License.
|
||||
margin-inline: var(--inline-content-inset);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.preview.content {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview > video {
|
||||
.content video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -61,20 +69,12 @@ limitations under the License.
|
||||
);
|
||||
}
|
||||
|
||||
.preview.content .buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 1 / 1) {
|
||||
.preview > video {
|
||||
.preview video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.preview {
|
||||
margin-inline: 0;
|
||||
border-radius: 0;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 - 2024 New Vector Ltd
|
||||
Copyright 2022 - 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,20 +14,24 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
|
||||
import { useEffect, useMemo, useRef, FC, ReactNode } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import { usePreviewTracks } from "@livekit/components-react";
|
||||
import { LocalVideoTrack, Track } from "livekit-client";
|
||||
import {
|
||||
CreateLocalTracksOptions,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Glass } from "@vector-im/compound-web";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
@@ -37,7 +41,7 @@ export type MatrixInfo = {
|
||||
roomName: string;
|
||||
roomAlias: string | null;
|
||||
roomAvatar: string | null;
|
||||
e2eeSystem: EncryptionSystem;
|
||||
roomEncrypted: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -58,42 +62,30 @@ export const VideoPreview: FC<Props> = ({
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
// we're not doing anything with the audio anyway so we don't need to
|
||||
// re-open the devices when they change (see below).
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
||||
);
|
||||
const initialAudioOptions = useRef<CreateLocalTracksOptions["audio"]>();
|
||||
initialAudioOptions.current ??= muteStates.audio.enabled && {
|
||||
deviceId: devices.audioInput.selectedId,
|
||||
};
|
||||
|
||||
const localTrackOptions = useMemo(
|
||||
() => ({
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
// The only reason we request audio here is to get the audio permission
|
||||
// request over with at the same time. But changing the audio settings
|
||||
// shouldn't cause this hook to recreate the track, which is why we
|
||||
// reference the initial values here.
|
||||
// We also pass in a clone because livekit mutates the object passed in,
|
||||
// which would cause the devices to be re-opened on the next render.
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
audio: Object.assign({}, initialAudioOptions.current),
|
||||
video: muteStates.video.enabled && {
|
||||
deviceId: devices.videoInput.selectedId,
|
||||
},
|
||||
}),
|
||||
[
|
||||
initialAudioOptions,
|
||||
devices.videoInput.selectedId,
|
||||
muteStates.video.enabled,
|
||||
],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
},
|
||||
(error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
muteStates.video.setEnabled?.(false);
|
||||
},
|
||||
[muteStates.audio, muteStates.video],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.find((t) => t.kind === Track.Kind.Video) as
|
||||
@@ -109,13 +101,13 @@ export const VideoPreview: FC<Props> = ({
|
||||
if (videoEl.current) {
|
||||
videoTrack?.attach(videoEl.current);
|
||||
}
|
||||
return (): void => {
|
||||
return () => {
|
||||
videoTrack?.detach();
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
const content = (
|
||||
<>
|
||||
<video
|
||||
ref={videoEl}
|
||||
muted
|
||||
@@ -135,6 +127,21 @@ export const VideoPreview: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.buttonBar}>{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return useMediaQuery("(max-width: 550px)") ? (
|
||||
<div
|
||||
className={classNames(styles.preview, styles.content)}
|
||||
ref={previewRef}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Glass className={styles.preview}>
|
||||
<div className={styles.content} ref={previewRef}>
|
||||
{content}
|
||||
</div>
|
||||
</Glass>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,28 +21,33 @@ import {
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { deepCompare } from "matrix-js-sdk/src/utils";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
LivekitFocus,
|
||||
isLivekitFocus,
|
||||
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
import { LivekitFocus } from "../livekit/LivekitFocus";
|
||||
|
||||
function getActiveFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
const focus = oldestMembership?.getActiveFoci()[0] as LivekitFocus;
|
||||
|
||||
return focus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||
* This logic is specific to livekit foci where the whole call must use one
|
||||
* and the same focus.
|
||||
*/
|
||||
export function useActiveLivekitFocus(
|
||||
export function useActiveFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const [activeFocus, setActiveFocus] = useState(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
});
|
||||
const [activeFocus, setActiveFocus] = useState(() =>
|
||||
getActiveFocus(rtcSession),
|
||||
);
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
const newActiveFocus = rtcSession.getActiveFocus();
|
||||
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||
const newActiveFocus = getActiveFocus(rtcSession);
|
||||
|
||||
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
@@ -59,7 +64,7 @@ export function useActiveLivekitFocus(
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
return () => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
|
||||
114
src/room/useFullscreen.ts
Normal file
114
src/room/useFullscreen.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useEventTarget } from "../useEvents";
|
||||
import { TileDescriptor } from "../state/CallViewModel";
|
||||
|
||||
const isFullscreen = (): boolean =>
|
||||
Boolean(document.fullscreenElement) ||
|
||||
Boolean(document.webkitFullscreenElement);
|
||||
|
||||
function enterFullscreen(): void {
|
||||
if (document.body.requestFullscreen) {
|
||||
document.body.requestFullscreen();
|
||||
} else if (document.body.webkitRequestFullscreen) {
|
||||
document.body.webkitRequestFullscreen();
|
||||
} else {
|
||||
logger.error("No available fullscreen API!");
|
||||
}
|
||||
}
|
||||
|
||||
function exitFullscreen(): void {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else {
|
||||
logger.error("No available fullscreen API!");
|
||||
}
|
||||
}
|
||||
|
||||
function useFullscreenChange(onFullscreenChange: () => void): void {
|
||||
useEventTarget(document.body, "fullscreenchange", onFullscreenChange);
|
||||
useEventTarget(document.body, "webkitfullscreenchange", onFullscreenChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides callbacks for controlling the full-screen view, which can hold one
|
||||
* item at a time.
|
||||
*/
|
||||
export function useFullscreen<T>(items: TileDescriptor<T>[]): {
|
||||
fullscreenItem: TileDescriptor<T> | null;
|
||||
toggleFullscreen: (itemId: string) => void;
|
||||
exitFullscreen: () => void;
|
||||
} {
|
||||
const [fullscreenItem, setFullscreenItem] =
|
||||
useReactiveState<TileDescriptor<T> | null>(
|
||||
(prevItem) =>
|
||||
prevItem == null
|
||||
? null
|
||||
: items.find((i) => i.id === prevItem.id) ?? null,
|
||||
[items],
|
||||
);
|
||||
|
||||
const latestItems = useRef<TileDescriptor<T>[]>(items);
|
||||
latestItems.current = items;
|
||||
|
||||
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
|
||||
latestFullscreenItem.current = fullscreenItem;
|
||||
|
||||
const toggleFullscreen = useCallback(
|
||||
(itemId: string) => {
|
||||
setFullscreenItem(
|
||||
latestFullscreenItem.current === null
|
||||
? latestItems.current.find((i) => i.id === itemId) ?? null
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[setFullscreenItem],
|
||||
);
|
||||
|
||||
const exitFullscreenCallback = useCallback(
|
||||
() => setFullscreenItem(null),
|
||||
[setFullscreenItem],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Determine whether we need to change the fullscreen state
|
||||
if (isFullscreen() !== (fullscreenItem !== null)) {
|
||||
(fullscreenItem === null ? exitFullscreen : enterFullscreen)();
|
||||
}
|
||||
}, [fullscreenItem]);
|
||||
|
||||
// Detect when the user exits fullscreen through an external mechanism like
|
||||
// browser chrome or the escape key
|
||||
useFullscreenChange(
|
||||
useCallback(() => {
|
||||
if (!isFullscreen()) setFullscreenItem(null);
|
||||
}, [setFullscreenItem]),
|
||||
);
|
||||
|
||||
return {
|
||||
fullscreenItem,
|
||||
toggleFullscreen,
|
||||
exitFullscreen: exitFullscreenCallback,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user