Compare commits

..

4 Commits

Author SHA1 Message Date
fkwp
9e3b3e847f Merge branch 'livekit' into fkwp-patch-1 2024-04-11 13:45:13 +02:00
fkwp
2314417397 we dont need prdetails going forward 2024-04-11 11:40:45 +00:00
fkwp
b2aa3adda3 use default for enable-pull-request-comment and enable-commit-comment 2024-04-11 09:36:15 +00:00
fkwp
a287d0671b use pull_request event rather than workflow_run 2024-04-11 09:30:43 +00:00
156 changed files with 10401 additions and 10322 deletions

View File

@@ -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: {

View File

@@ -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

View File

@@ -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
View 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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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>
);
});

View File

@@ -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:

View File

@@ -1,4 +1,5 @@
port: 7880
environment: dev
bind_addresses:
- "0.0.0.0"
rtc:

View File

@@ -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%

View File

@@ -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"
}

View 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"
}
}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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…"
}

View File

@@ -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"
]
}

View File

@@ -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 {

View File

@@ -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
View 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>;
};

View File

@@ -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 {

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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>
)}

View File

@@ -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";

View File

@@ -36,8 +36,3 @@ if (/android/i.test(navigator.userAgent)) {
} else {
platform = "desktop";
}
export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};

View File

@@ -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();
});
});
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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>
`;

View File

@@ -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();
}

View File

@@ -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
View 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,
);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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 (

View File

@@ -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;
};
/**

View File

@@ -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);
},
};

View File

@@ -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();
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}),
});

View File

@@ -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;
}

View File

@@ -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>
);
}),
});

View File

@@ -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;
}

View File

@@ -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>
);
}),
});

View File

@@ -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;
}

View File

@@ -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>
);
}),
});

View File

@@ -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);
}

View File

@@ -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>
);
}),
});

View File

@@ -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>
);

View File

@@ -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,
),
);
}

View File

@@ -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,
),
);
}

View File

@@ -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]);

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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> => {

View File

@@ -59,5 +59,4 @@ export const defaultLiveKitOptions: RoomOptions = {
stopLocalTrackOnUnpublish: true,
reconnectPolicy: new DefaultReconnectPolicy(),
disconnectOnPageLeave: true,
webAudioMix: false,
};

View File

@@ -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);
};

View File

@@ -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(
{

View File

@@ -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(

View File

@@ -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
View 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 }));

View File

@@ -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,
);

View File

@@ -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,
}));

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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} />;
}

View File

@@ -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}
/>

View File

@@ -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;
}

View File

@@ -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>
);
},
);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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%;

View File

@@ -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}

View File

@@ -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]);
}

View File

@@ -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>

View File

@@ -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} />
)}
</>
);
};

View File

@@ -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);
}
}

View File

@@ -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>
);
};

View File

@@ -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
View 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