Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8774ad682 | ||
|
|
30a54f3795 | ||
|
|
66b79f57bb | ||
|
|
61a24262de | ||
|
|
0955d7bcc3 | ||
|
|
36ce21d7ac | ||
|
|
eddc590235 | ||
|
|
61bc4dcc14 | ||
|
|
e2c4eae67b | ||
|
|
1da3fe0731 | ||
|
|
f562cc1e7f | ||
|
|
69b762b9ed | ||
|
|
ff55b1d189 | ||
|
|
d796ebe3fa | ||
|
|
b4bc41ba02 | ||
|
|
a072dfae9c | ||
|
|
0eba3ef75f | ||
|
|
2b9bf1fbe6 | ||
|
|
8769f8966d | ||
|
|
4e7b29e142 | ||
|
|
977ba92dba | ||
|
|
64e7047b12 | ||
|
|
ed99af0be6 | ||
|
|
52058716f6 | ||
|
|
29df87d22c | ||
|
|
6443e911dc | ||
|
|
aa6b7056ae | ||
|
|
c20737ba4c | ||
|
|
6f03653532 | ||
|
|
2ec0aaa0de | ||
|
|
9b4ad24f10 | ||
|
|
5069b008e2 | ||
|
|
6d8e45aea8 | ||
|
|
f0f9b929a1 | ||
|
|
9b5072cc57 | ||
|
|
b13fa85465 | ||
|
|
bf5128cfee | ||
|
|
f928e63c7b | ||
|
|
eef92249f7 | ||
|
|
04ad44f900 | ||
|
|
90072aa2bb | ||
|
|
ab42fe97cb | ||
|
|
f4cf3d8c62 | ||
|
|
1782a0eaf3 | ||
|
|
5bf46eb8f8 | ||
|
|
b4973bbc6b | ||
|
|
eaf3fb13c1 | ||
|
|
b503056673 | ||
|
|
86e3c346a4 | ||
|
|
7449e1f6e4 | ||
|
|
aadf6c05ac | ||
|
|
39ee8d838e | ||
|
|
1f10245adc | ||
|
|
c1de41106f | ||
|
|
e12bad952a | ||
|
|
7abb56e406 | ||
|
|
00d8100dfe | ||
|
|
eb051ab318 | ||
|
|
942e28f3c2 | ||
|
|
0bfec65405 | ||
|
|
f89342713a | ||
|
|
5a0b81b57f | ||
|
|
f9323d8b2c | ||
|
|
c68d536d80 | ||
|
|
fde7dbedaa | ||
|
|
7e240e96b7 | ||
|
|
f84800363f | ||
|
|
f9e12c8ff3 | ||
|
|
6abd1fbca1 | ||
|
|
599a4708cb | ||
|
|
f53ea75c94 | ||
|
|
2b67a9cfbe | ||
|
|
d582a7cc29 | ||
|
|
8757f07982 | ||
|
|
5b8910d265 | ||
|
|
a03ab6c9fa | ||
|
|
a3ce333352 | ||
|
|
d5faa5ea90 | ||
|
|
5becd2e175 | ||
|
|
3b38a5322c | ||
|
|
d062871f41 | ||
|
|
6b64bdfdb5 | ||
|
|
2de4705fa7 | ||
|
|
12e233970c | ||
|
|
10b915c707 | ||
|
|
5544695f21 | ||
|
|
72de8e066c | ||
|
|
63afda05bc | ||
|
|
b05c4234b7 | ||
|
|
80ddb7495d | ||
|
|
380f49fccc | ||
|
|
447bac3280 | ||
|
|
c74cebcc4b | ||
|
|
cd0aa0ced6 | ||
|
|
9cbd146e24 | ||
|
|
509bb4f1b0 | ||
|
|
3be3a32f3d | ||
|
|
17adfc5777 | ||
|
|
4eb1be678d | ||
|
|
b34e7d00e9 | ||
|
|
78f4c2a650 | ||
|
|
a3773c0a9a | ||
|
|
2b92ce8af2 | ||
|
|
5564e2fde6 | ||
|
|
35e2d2c432 | ||
|
|
ea2d98179c | ||
|
|
d83a104dda | ||
|
|
58f274eabf | ||
|
|
632ad07304 | ||
|
|
4173fd113b | ||
|
|
56b5f2845d | ||
|
|
afee9eaa26 | ||
|
|
364b78abda | ||
|
|
507b1fc52d | ||
|
|
6812c35a40 | ||
|
|
377b7ff5de | ||
|
|
4955535374 | ||
|
|
0664f978e3 | ||
|
|
bcc06d86ff | ||
|
|
7526826b0c | ||
|
|
b4e0df75c0 | ||
|
|
d561a41666 | ||
|
|
d53ad9a8f3 | ||
|
|
e04affe93e | ||
|
|
24870deead | ||
|
|
7fcd7125c1 | ||
|
|
1efa594430 | ||
|
|
caea4b250e | ||
|
|
0a8c6c1454 | ||
|
|
d4a2617f7b | ||
|
|
e05c6f1bdf | ||
|
|
2bc56dbff2 | ||
|
|
a59875dab5 | ||
|
|
8c21e8f277 | ||
|
|
d8634eed3d | ||
|
|
be4b70c1e1 | ||
|
|
e79cded57f | ||
|
|
2440037639 | ||
|
|
a16f235277 | ||
|
|
45c89a2298 | ||
|
|
7979493371 | ||
|
|
e0b10d89b5 | ||
|
|
183d2d9050 | ||
|
|
12b719da95 | ||
|
|
dfda7539d6 | ||
|
|
7f40ce8dde | ||
|
|
ec1b020d4e | ||
|
|
54c22f4ab2 | ||
|
|
ffbbc74a96 | ||
|
|
34c45cb5e2 | ||
|
|
af0bd795b5 | ||
|
|
0d485ef97f | ||
|
|
5647619b36 | ||
|
|
8a414012a0 | ||
|
|
e33fbd77d1 | ||
|
|
fdc6d4a1b6 | ||
|
|
a534356dd9 | ||
|
|
f847692953 | ||
|
|
486430d1f0 | ||
|
|
599d6fd007 | ||
|
|
14fc1481f3 | ||
|
|
e6ddf40b1b | ||
|
|
9f521a79f7 | ||
|
|
83784a717a | ||
|
|
0729deee79 | ||
|
|
77c3114cf8 | ||
|
|
82a56c8204 | ||
|
|
b39896d8c6 | ||
|
|
79b3fdb645 | ||
|
|
0f877cd021 | ||
|
|
db2acc75b2 | ||
|
|
a5dbfbf2c1 | ||
|
|
34c7d02de2 | ||
|
|
ca45067158 | ||
|
|
5a6eb7c573 | ||
|
|
41083c0f9e | ||
|
|
20602c122b | ||
|
|
5ad2a27a92 | ||
|
|
68daaa45f9 | ||
|
|
c40ea35937 | ||
|
|
d27f433175 | ||
|
|
8a6101cd14 | ||
|
|
4db7c2bc68 | ||
|
|
18740fc686 | ||
|
|
0c39398493 | ||
|
|
949145f04b | ||
|
|
8578dcadf2 | ||
|
|
959db44eca | ||
|
|
a031c0e128 | ||
|
|
591833505f | ||
|
|
f7ad5074d8 | ||
|
|
e0aef74bf5 | ||
|
|
b2378bf899 | ||
|
|
255f6b1814 | ||
|
|
4c491b5363 | ||
|
|
61c808d4cf | ||
|
|
13ef3183e2 | ||
|
|
afd4fdcea2 | ||
|
|
982181ccd4 | ||
|
|
30629ebba2 | ||
|
|
7f6a32d21a | ||
|
|
320ade0a50 | ||
|
|
8c6fee3150 | ||
|
|
5c6acaf915 | ||
|
|
c46549b2b6 | ||
|
|
97a58f6db7 | ||
|
|
b6288579c9 | ||
|
|
44bf987cdc | ||
|
|
a7d55824bb | ||
|
|
8fa038c61f | ||
|
|
869d9b43cb |
@@ -38,15 +38,6 @@ 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: {
|
||||
|
||||
12
.github/workflows/docker.yaml
vendored
12
.github/workflows/docker.yaml
vendored
@@ -23,10 +23,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -42,16 +42,16 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@f7b4ed12385588c3f9bc252f0a2b520d83b52d48
|
||||
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@5138f76647652447004da686b2411557eaf65f33
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f6010ea70151369b06f0194be1051fbbdff851b2
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out test private repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
with:
|
||||
repository: element-hq/static-call-participant
|
||||
ref: refs/heads/main
|
||||
|
||||
7
.github/workflows/element-call.yaml
vendored
7
.github/workflows/element-call.yaml
vendored
@@ -21,11 +21,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Build
|
||||
@@ -38,7 +39,7 @@ jobs:
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist
|
||||
|
||||
5
.github/workflows/lint.yaml
vendored
5
.github/workflows/lint.yaml
vendored
@@ -7,11 +7,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Prettier
|
||||
|
||||
10
.github/workflows/netlify.yaml
vendored
10
.github/workflows/netlify.yaml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
environment: Netlify
|
||||
steps:
|
||||
- name: 📝 Create Deployment
|
||||
uses: bobheadxi/deployments@v1
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
id: deployment
|
||||
with:
|
||||
step: start
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
Exercise caution. Use test accounts.
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
@@ -58,11 +58,11 @@ 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/element_io_preview.json" > webapp/config.json
|
||||
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@v3.0
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: 🚦 Update deployment status
|
||||
uses: bobheadxi/deployments@v1
|
||||
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||
if: always()
|
||||
with:
|
||||
step: finish
|
||||
|
||||
2
.github/workflows/pr-deploy.yaml
vendored
2
.github/workflows/pr-deploy.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
steps:
|
||||
- id: prdetails
|
||||
uses: matrix-org/pr-details-action@v1.3
|
||||
uses: matrix-org/pr-details-action@15bde5285d7850ba276cc3bd8a03733e3f24622a # v1.3
|
||||
continue-on-error: true
|
||||
with:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
id: current-time
|
||||
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
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@552bf3722c16e81001aea7db72d8cedf64eb5f68
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
|
||||
7
.github/workflows/test.yaml
vendored
7
.github/workflows/test.yaml
vendored
@@ -9,16 +9,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
- name: Vitest
|
||||
run: "yarn run test"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
|
||||
with:
|
||||
flags: unittests
|
||||
|
||||
9
.github/workflows/translations-download.yaml
vendored
9
.github/workflows/translations-download.yaml
vendored
@@ -13,11 +13,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
@@ -26,7 +27,7 @@ jobs:
|
||||
run: "rm -R public/locales"
|
||||
|
||||
- name: Download translation files
|
||||
uses: localazy/download@v1.1.0
|
||||
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
|
||||
with:
|
||||
groups: "-p includeSourceLang:true"
|
||||
|
||||
@@ -38,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v6.0.5
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
4
.github/workflows/translations-upload.yaml
vendored
4
.github/workflows/translations-upload.yaml
vendored
@@ -14,9 +14,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@v1
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
with:
|
||||
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
|
||||
|
||||
26
README.md
26
README.md
@@ -22,7 +22,7 @@ yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server of your choice.
|
||||
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).
|
||||
|
||||
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:
|
||||
@@ -131,7 +131,7 @@ advertises one in the client well-known, this will not be used.)
|
||||
|
||||
```json
|
||||
"livekit": {
|
||||
"livekit_service_url": "http://localhost:8881"
|
||||
"livekit_service_url": "http://localhost:7881"
|
||||
},
|
||||
```
|
||||
|
||||
@@ -141,6 +141,28 @@ 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:
|
||||
|
||||
@@ -5,5 +5,11 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"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"
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
41
package.json
41
package.json
@@ -20,15 +20,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@livekit/components-core": "^0.10.0",
|
||||
"@livekit/components-core": "^0.11.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/context-zone": "^1.9.1",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.38.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.38.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.39.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.39.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
@@ -41,45 +40,45 @@
|
||||
"@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/select": "^3.1.3",
|
||||
"@react-stately/tooltip": "^3.0.5",
|
||||
"@react-stately/tree": "^3.2.0",
|
||||
"@sentry/react": "^7.0.0",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/tracing": "^7.0.0",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^1.0.0",
|
||||
"@vector-im/compound-web": "^3.0.0",
|
||||
"@vector-im/compound-web": "^6.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"events": "^3.3.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^7.0.0",
|
||||
"i18next-browser-languagedetector": "^8.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#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
|
||||
"matrix-widget-api": "^1.3.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-preset-env": "^9.0.0",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"posthog-js": "^1.29.0",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react-i18next": "^14.0.0",
|
||||
"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",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"tinyqueue": "^2.0.3",
|
||||
"tinyqueue": "^3.0.0",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "9",
|
||||
"uuid": "10",
|
||||
"vaul": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -90,17 +89,19 @@
|
||||
"@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/dom": "^10.1.0",
|
||||
"@testing-library/react": "^16.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-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/request": "^2.48.8",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "9",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"babel-loader": "^9.0.0",
|
||||
@@ -114,8 +115,8 @@
|
||||
"eslint-plugin-matrix-org": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"i18next-parser": "^8.0.0",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"i18next-parser": "^9.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"sass": "^1.42.1",
|
||||
@@ -124,6 +125,6 @@
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"analytics": "Analytics",
|
||||
"audio": "Audio",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"camera": "Camera",
|
||||
"copied": "Copied!",
|
||||
"display_name": "Display name",
|
||||
@@ -49,6 +50,7 @@
|
||||
"home": "Home",
|
||||
"loading": "Loading…",
|
||||
"microphone": "Microphone",
|
||||
"next": "Next",
|
||||
"options": "Options",
|
||||
"password": "Password",
|
||||
"profile": "Profile",
|
||||
@@ -57,6 +59,8 @@
|
||||
"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>",
|
||||
@@ -97,6 +101,7 @@
|
||||
"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",
|
||||
@@ -130,6 +135,7 @@
|
||||
"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",
|
||||
@@ -138,7 +144,6 @@
|
||||
"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",
|
||||
@@ -152,14 +157,13 @@
|
||||
"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": "Version: {{version}}",
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"exit_full_screen": "Exit full screen",
|
||||
"full_screen": "Full screen",
|
||||
"mute_for_me": "Mute for me",
|
||||
"sfu_participant_local": "You",
|
||||
"volume": "Volume"
|
||||
},
|
||||
"waiting_for_participants": "Waiting for other participants…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,45 @@
|
||||
"extends": ["config:base"],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Disable renoavte for packages we want to monitor ourselves",
|
||||
"matchPackagePatterns": ["matrix-js-sdk"],
|
||||
"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"],
|
||||
"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",
|
||||
|
||||
11
src/@types/global.d.ts
vendored
11
src/@types/global.d.ts
vendored
@@ -23,17 +23,6 @@ declare global {
|
||||
webkitFullscreenElement: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
// 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 {
|
||||
// Safari only supports this prefixed, so tell the type system about it
|
||||
webkitRequestFullscreen: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-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.
|
||||
@@ -90,6 +90,7 @@ limitations under the License.
|
||||
.nameLine {
|
||||
grid-area: name;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-1x);
|
||||
@@ -97,8 +98,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-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.
|
||||
@@ -15,11 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { FC, HTMLAttributes, ReactNode, forwardRef } 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/icons/user-profile.svg?react";
|
||||
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./Header.module.css";
|
||||
import Logo from "./icons/Logo.svg?react";
|
||||
@@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
|
||||
return (
|
||||
<header className={classNames(styles.header, className)} {...rest}>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
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";
|
||||
|
||||
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -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/icons/close.svg?react";
|
||||
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { Heading, Glass } from "@vector-im/compound-web";
|
||||
|
||||
|
||||
@@ -36,3 +36,8 @@ if (/android/i.test(navigator.userAgent)) {
|
||||
} else {
|
||||
platform = "desktop";
|
||||
}
|
||||
|
||||
export const isFirefox = (): boolean => {
|
||||
const { userAgent } = navigator;
|
||||
return userAgent.includes("Firefox");
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -35,7 +34,7 @@ import {
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { localStorageBus } from "../useLocalStorage";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
@@ -131,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
|
||||
setSetting("opt-in-analytics", Boolean(analyticsID));
|
||||
optInAnalytics.setValue(Boolean(analyticsID));
|
||||
}
|
||||
|
||||
this.posthog.init(posthogConfig.project_api_key, {
|
||||
@@ -151,9 +150,7 @@ export class PosthogAnalytics {
|
||||
);
|
||||
this.enabled = false;
|
||||
}
|
||||
this.startListeningToSettingsChanges();
|
||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
|
||||
}
|
||||
|
||||
private sanitizeProperties = (
|
||||
@@ -336,8 +333,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
public onLoginStatusChanged(): void {
|
||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
this.maybeIdentifyUser();
|
||||
}
|
||||
|
||||
private updateSuperProperties(): void {
|
||||
@@ -360,20 +356,12 @@ export class PosthogAnalytics {
|
||||
return this.eventSignup.getSignupEndTime() > new Date(0);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
private async maybeIdentifyUser(): Promise<void> {
|
||||
// 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 (anonymity === Anonymity.Pseudonymous) {
|
||||
if (this.anonymity === Anonymity.Pseudonymous) {
|
||||
this.setRegistrationType(
|
||||
window.matrixclient.isGuest() || window.passwordlessUser
|
||||
? RegistrationType.Guest
|
||||
@@ -389,7 +377,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
if (anonymity !== Anonymity.Disabled) {
|
||||
if (this.anonymity !== Anonymity.Disabled) {
|
||||
this.updateSuperProperties();
|
||||
}
|
||||
}
|
||||
@@ -419,8 +407,9 @@ 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)
|
||||
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
optInAnalytics.value.subscribe((optIn) => {
|
||||
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
|
||||
this.maybeIdentifyUser();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,16 @@ 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 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 {
|
||||
MicOnSolidIcon,
|
||||
MicOffSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
VideoCallOffSolidIcon,
|
||||
EndCallIcon,
|
||||
ShareScreenSolidIcon,
|
||||
SettingsSolidIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
@@ -149,7 +151,7 @@ export const MicButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={muted}>
|
||||
<Icon aria-label={label} />
|
||||
<Icon aria-hidden width={24} height={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -169,7 +171,7 @@ export const VideoButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={muted}>
|
||||
<Icon aria-label={label} />
|
||||
<Icon aria-hidden width={24} height={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -189,7 +191,7 @@ export const ScreenshareButton: FC<{
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Button variant="toolbar" {...rest} on={enabled}>
|
||||
<ShareScreenSolidIcon aria-label={label} />
|
||||
<ShareScreenSolidIcon aria-hidden width={24} height={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -209,7 +211,7 @@ export const HangupButton: FC<{
|
||||
className={classNames(styles.hangupButton, className)}
|
||||
{...rest}
|
||||
>
|
||||
<EndCallIcon aria-label={t("hangup_button_label")} />
|
||||
<EndCallIcon aria-hidden width={24} height={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -225,7 +227,7 @@ export const SettingsButton: FC<{
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<Button variant="toolbar" {...rest}>
|
||||
<SettingsSolidIcon aria-label={t("common.settings")} />
|
||||
<SettingsSolidIcon aria-hidden width={24} height={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { ComponentPropsWithoutRef, FC } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/icons/user-add.svg?react";
|
||||
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
export const InviteButton: FC<
|
||||
Omit<ComponentPropsWithoutRef<"button">, "children">
|
||||
|
||||
@@ -65,11 +65,21 @@ export interface ConfigOptions {
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow to join a group calls without audio and video.
|
||||
* TEMPORARY: Is a feature that's not proved and experimental
|
||||
* TEMPORARY experimental features.
|
||||
*/
|
||||
features?: {
|
||||
feature_group_calls_without_video_and_audio: boolean;
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
150
src/grid/CallLayout.ts
Normal file
150
src/grid/CallLayout.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
|
||||
import { LayoutProps } from "./Grid";
|
||||
|
||||
export interface Bounds {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export const defaultSpotlightAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
};
|
||||
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
|
||||
|
||||
export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
minBounds: Observable<Bounds>;
|
||||
/**
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
*/
|
||||
spotlightAlignment: BehaviorSubject<Alignment>;
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present.
|
||||
*/
|
||||
pipAlignment: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface GridTileModel {
|
||||
type: "grid";
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface SpotlightTileModel {
|
||||
type: "spotlight";
|
||||
vms: MediaViewModel[];
|
||||
maximised: boolean;
|
||||
}
|
||||
|
||||
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
/**
|
||||
* Whether the scrolling layer of the layout should appear on top.
|
||||
*/
|
||||
scrollingOnTop: boolean;
|
||||
/**
|
||||
* The visually fixed (non-scrolling) layer of the layout.
|
||||
*/
|
||||
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||
/**
|
||||
* The layer of the layout that can overflow and be scrolled.
|
||||
*/
|
||||
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout system for media tiles.
|
||||
*/
|
||||
export type CallLayout<Model> = (
|
||||
inputs: CallLayoutInputs,
|
||||
) => CallLayoutOutputs<Model>;
|
||||
|
||||
export interface GridArrangement {
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
gap: number;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const tileMaxAspectRatio = 17 / 9;
|
||||
const tileMinAspectRatio = 4 / 3;
|
||||
|
||||
/**
|
||||
* Determine the ideal arrangement of tiles into a grid of a particular size.
|
||||
*/
|
||||
export function arrangeTiles(
|
||||
width: number,
|
||||
minHeight: number,
|
||||
tileCount: number,
|
||||
): GridArrangement {
|
||||
// The goal here is to determine the grid size and padding that maximizes
|
||||
// use of screen space for n tiles without making those tiles too small or
|
||||
// too cropped (having an extreme aspect ratio)
|
||||
const gap = width < 800 ? 16 : 20;
|
||||
const area = width * minHeight;
|
||||
// Magic numbers that make tiles scale up nicely as the window gets larger
|
||||
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
|
||||
const tilesPerPage = Math.min(tileCount, area / tileArea);
|
||||
|
||||
let columns = Math.min(
|
||||
// Don't create more columns than we have items for
|
||||
tilesPerPage,
|
||||
// The ideal number of columns is given by a packing of equally-sized
|
||||
// squares into a grid.
|
||||
// width / column = height / row.
|
||||
// columns * rows = number of squares.
|
||||
// ∴ columns = sqrt(width / height * number of squares).
|
||||
// Except we actually want 16:9-ish tiles rather than squares, so we
|
||||
// divide the width-to-height ratio by the target aspect ratio.
|
||||
Math.round(
|
||||
Math.sqrt((width / minHeight / tileMinAspectRatio) * tilesPerPage),
|
||||
),
|
||||
);
|
||||
let rows = tilesPerPage / columns;
|
||||
// If all the tiles could fit on one page, we want to ensure that they do by
|
||||
// not leaving fractional rows hanging off the bottom
|
||||
if (tilesPerPage === tileCount) {
|
||||
rows = Math.ceil(rows);
|
||||
// We may now be able to fit the tiles into fewer columns
|
||||
columns = Math.ceil(tileCount / rows);
|
||||
}
|
||||
|
||||
let tileWidth = (width - (columns + 1) * gap) / columns;
|
||||
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||
|
||||
// Impose a minimum and maximum aspect ratio on the tiles
|
||||
const tileAspectRatio = tileWidth / tileHeight;
|
||||
if (tileAspectRatio > tileMaxAspectRatio)
|
||||
tileWidth = tileHeight * tileMaxAspectRatio;
|
||||
else if (tileAspectRatio < tileMinAspectRatio)
|
||||
tileHeight = tileWidth / tileMinAspectRatio;
|
||||
|
||||
return { tileWidth, tileHeight, gap, columns };
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
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
|
||||
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,
|
||||
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.videoGrid {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
touch-action: none;
|
||||
.grid {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.slot {
|
||||
contain: strict;
|
||||
}
|
||||
514
src/grid/Grid.tsx
Normal file
514
src/grid/Grid.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
src/grid/GridLayout.module.css
Normal file
61
src/grid/GridLayout.module.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
129
src/grid/GridLayout.tsx
Normal file
129
src/grid/GridLayout.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}),
|
||||
});
|
||||
54
src/grid/OneOnOneLayout.module.css
Normal file
54
src/grid/OneOnOneLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
88
src/grid/OneOnOneLayout.tsx
Normal file
88
src/grid/OneOnOneLayout.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}),
|
||||
});
|
||||
54
src/grid/SpotlightExpandedLayout.module.css
Normal file
54
src/grid/SpotlightExpandedLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
91
src/grid/SpotlightExpandedLayout.tsx
Normal file
91
src/grid/SpotlightExpandedLayout.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}),
|
||||
});
|
||||
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
92
src/grid/SpotlightLandscapeLayout.tsx
Normal file
92
src/grid/SpotlightLandscapeLayout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}),
|
||||
});
|
||||
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
118
src/grid/SpotlightPortraitLayout.tsx
Normal file
118
src/grid/SpotlightPortraitLayout.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -14,15 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.bigGrid {
|
||||
display: grid;
|
||||
grid-auto-rows: 130px;
|
||||
gap: var(--cpd-space-2x);
|
||||
.tile.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.bigGrid {
|
||||
grid-auto-rows: 135px;
|
||||
gap: var(--cpd-space-5x);
|
||||
}
|
||||
.tile.draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -14,83 +14,76 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { memo, ReactNode, RefObject, useRef } from "react";
|
||||
import { ComponentType, memo, RefObject, useRef } from "react";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
import { SpringValue, to } from "@react-spring/web";
|
||||
import { SpringValue } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { ChildrenProperties } from "./VideoGrid";
|
||||
import { TileProps } from "./Grid";
|
||||
import styles from "./TileWrapper.module.css";
|
||||
|
||||
interface Props<T> {
|
||||
interface Props<M, R extends HTMLElement> {
|
||||
id: string;
|
||||
onDragRef: RefObject<
|
||||
onDrag: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||
) => void
|
||||
>;
|
||||
> | null;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
data: T;
|
||||
model: M;
|
||||
Tile: ComponentType<TileProps<M, R>>;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
shadowSpread: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
children: (props: ChildrenProperties<T>) => ReactNode;
|
||||
}
|
||||
|
||||
const TileWrapper_ = memo(
|
||||
<T,>({
|
||||
<M, R extends HTMLElement>({
|
||||
id,
|
||||
onDragRef,
|
||||
onDrag,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
data,
|
||||
model,
|
||||
Tile,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}: Props<T>) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
}: Props<M, R>) => {
|
||||
const ref = useRef<R | null>(null);
|
||||
|
||||
useDrag((state) => onDragRef?.current!(id, state), {
|
||||
useDrag((state) => onDrag?.current!(id, state), {
|
||||
target: ref,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
ref,
|
||||
style: {
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
boxShadow: to(
|
||||
[shadow, shadowSpread],
|
||||
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`,
|
||||
),
|
||||
},
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
data,
|
||||
})}
|
||||
</>
|
||||
<Tile
|
||||
ref={ref}
|
||||
className={classNames(styles.tile, { [styles.draggable]: onDrag })}
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
model={model}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -104,4 +97,6 @@ TileWrapper_.displayName = "TileWrapper";
|
||||
// We pretend this component is a simple function rather than a
|
||||
// NamedExoticComponent, because that's the only way we can fit in a type
|
||||
// parameter
|
||||
export const TileWrapper = TileWrapper_ as <T>(props: Props<T>) => JSX.Element;
|
||||
export const TileWrapper = TileWrapper_ as <M, R extends HTMLElement>(
|
||||
props: Props<M, R>,
|
||||
) => JSX.Element;
|
||||
@@ -38,9 +38,12 @@ 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 {
|
||||
useSetting,
|
||||
optInAnalytics as optInAnalyticsSetting,
|
||||
} from "../settings/settings";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -49,7 +52,7 @@ interface Props {
|
||||
export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||
|
||||
@@ -41,15 +41,18 @@ 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 {
|
||||
useSetting,
|
||||
optInAnalytics as optInAnalyticsSetting,
|
||||
} from "../settings/settings";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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";
|
||||
@@ -33,8 +32,6 @@ 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;
|
||||
@@ -55,7 +52,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 avioded if possible
|
||||
// but this should be avoided if possible
|
||||
|
||||
//i18n
|
||||
const languageDetector = new LanguageDetector();
|
||||
@@ -119,7 +116,7 @@ export class Initializer {
|
||||
}
|
||||
Initializer.internalInstance = new Initializer();
|
||||
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||
// initStep calls itself recursivly until everything is initialized in the correct order.
|
||||
// initStep calls itself recursively until everything is initialized in the correct order.
|
||||
// Then the promise gets resolved.
|
||||
Initializer.internalInstance.initStep(resolve);
|
||||
});
|
||||
@@ -129,18 +126,6 @@ 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;
|
||||
@@ -160,10 +145,7 @@ export class Initializer {
|
||||
dsn: Config.get().sentry?.DSN,
|
||||
environment: Config.get().sentry?.environment,
|
||||
integrations: [
|
||||
new BrowserTracing({
|
||||
routingInstrumentation:
|
||||
Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
Sentry.reactRouterV5BrowserTracingIntegration({ history }),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
@@ -29,11 +29,12 @@ import { Observable } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import {
|
||||
isFirefox,
|
||||
useAudioInput,
|
||||
useAudioOutput,
|
||||
useVideoInput,
|
||||
} from "../settings/useSetting";
|
||||
useSetting,
|
||||
audioInput as audioInputSetting,
|
||||
audioOutput as audioOutputSetting,
|
||||
videoInput as videoInputSetting,
|
||||
} from "../settings/settings";
|
||||
import { isFirefox } from "../Platform";
|
||||
|
||||
export interface MediaDevice {
|
||||
available: MediaDeviceInfo[];
|
||||
@@ -145,43 +146,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
// for ouput devices because the selector wont be shown on FF.
|
||||
const useOutputNames = usingNames && !isFirefox();
|
||||
|
||||
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
|
||||
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
|
||||
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
|
||||
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
|
||||
const [storedAudioOutput, setStoredAudioOutput] =
|
||||
useSetting(audioOutputSetting);
|
||||
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
|
||||
|
||||
const audioInput = useMediaDevice(
|
||||
"audioinput",
|
||||
audioInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
|
||||
const audioOutput = useMediaDevice(
|
||||
"audiooutput",
|
||||
audioOutputSetting,
|
||||
storedAudioOutput,
|
||||
useOutputNames,
|
||||
alwaysUseDefaultAudio,
|
||||
);
|
||||
const videoInput = useMediaDevice(
|
||||
"videoinput",
|
||||
videoInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioInput.selectedId !== undefined)
|
||||
setAudioInputSetting(audioInput.selectedId);
|
||||
}, [setAudioInputSetting, audioInput.selectedId]);
|
||||
setStoredAudioInput(audioInput.selectedId);
|
||||
}, [setStoredAudioInput, 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())
|
||||
setAudioOutputSetting(audioOutput.selectedId);
|
||||
}, [setAudioOutputSetting, audioOutput.selectedId]);
|
||||
setStoredAudioOutput(audioOutput.selectedId);
|
||||
}, [setStoredAudioOutput, audioOutput.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoInput.selectedId !== undefined)
|
||||
setVideoInputSetting(videoInput.selectedId);
|
||||
}, [setVideoInputSetting, videoInput.selectedId]);
|
||||
setStoredVideoInput(videoInput.selectedId);
|
||||
}, [setStoredVideoInput, videoInput.selectedId]);
|
||||
|
||||
const startUsingDeviceNames = useCallback(
|
||||
() => setNumCallersUsingNames((n) => n + 1),
|
||||
|
||||
@@ -34,7 +34,6 @@ 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 { loadOlm } from "./olm";
|
||||
import { Config } from "./config/Config";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { EncryptionSystem, saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
||||
@@ -97,8 +96,6 @@ export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
await loadOlm();
|
||||
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
@@ -198,7 +195,7 @@ export async function initClient(
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
await client.initCrypto();
|
||||
await client.initRustCrypto();
|
||||
client.setGlobalErrorOnUnknownDevices(false);
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable, defer, finalize, tap } from "rxjs";
|
||||
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
@@ -35,3 +35,15 @@ 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));
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ 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";
|
||||
@@ -446,7 +445,7 @@ export class OTelGroupCallMembership {
|
||||
const type = OTelStatsReportType.SummaryReport;
|
||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = setSpan(
|
||||
const ctx = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan,
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
||||
displayName,
|
||||
avatarUrl: removeAvatar
|
||||
? undefined
|
||||
: mxcAvatarUrl ?? prev.avatarUrl,
|
||||
: (mxcAvatarUrl ?? prev.avatarUrl),
|
||||
loading: false,
|
||||
success: true,
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ 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/icons/pop-out.svg?react";
|
||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
@@ -51,11 +51,11 @@ export const CallEndedView: FC<Props> = ({
|
||||
const history = useHistory();
|
||||
|
||||
const { displayName } = useProfile(client);
|
||||
const [surveySubmitted, setSurverySubmitted] = useState(false);
|
||||
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
||||
const [starRating, setStarRating] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitDone, setSubmitDone] = useState(false);
|
||||
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
const submitSurvey: 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
|
||||
setSurverySubmitted(true);
|
||||
setSurveySubmitted(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={submitSurvery}>
|
||||
<form onSubmit={submitSurvey}>
|
||||
<FieldRow>
|
||||
<StarRatingInput starCount={5} onChange={setStarRating} required />
|
||||
</FieldRow>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -17,8 +17,10 @@ limitations under the License.
|
||||
import { FC } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
LockSolidIcon,
|
||||
LockOffIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./EncryptionLock.module.css";
|
||||
|
||||
@@ -32,13 +34,13 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
||||
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
|
||||
|
||||
return (
|
||||
<Tooltip label={label} side="right" isTriggerInteractive={false}>
|
||||
<Tooltip label={label} placement="right" isTriggerInteractive={false}>
|
||||
<Icon
|
||||
width={16}
|
||||
height={16}
|
||||
className={styles.lock}
|
||||
data-encrypted={encrypted}
|
||||
aria-label={label}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -80,17 +80,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||
|
||||
// The mute state reactively gets updated once the participant count reaches the threshold.
|
||||
// The user then still is able to unmute again.
|
||||
// The more common case is that the user is muted from the start (participant count is already over the threshold).
|
||||
const autoMuteHappened = useRef(false);
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (autoMuteHappened.current) return;
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT) {
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
autoMuteHappened.current = true;
|
||||
}
|
||||
}, [autoMuteHappened, memberships, muteStates.audio]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
@@ -195,7 +190,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
const onJoin = async (
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): Promise<void> => {
|
||||
defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
@@ -204,9 +199,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
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();
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
Copyright 2021-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.
|
||||
@@ -19,36 +19,31 @@ limitations under the License.
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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%
|
||||
);
|
||||
}
|
||||
|
||||
.centerMessage {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centerMessage p {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
.header.filler {
|
||||
block-size: var(--cpd-space-6x);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: "logo buttons layout";
|
||||
@@ -63,6 +58,24 @@ limitations under the License.
|
||||
);
|
||||
}
|
||||
|
||||
.footer.overlay {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.footer.overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.logo {
|
||||
grid-area: logo;
|
||||
justify-self: start;
|
||||
@@ -101,11 +114,37 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
|
||||
.footerThin {
|
||||
padding-top: var(--cpd-space-3x);
|
||||
padding-bottom: var(--cpd-space-5x);
|
||||
.fixedGrid {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.footerHidden {
|
||||
display: none;
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 - 2023 New Vector Ltd
|
||||
Copyright 2022 - 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.
|
||||
@@ -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, Track } from "livekit-client";
|
||||
import { ConnectionState, Room } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
FC,
|
||||
ReactNode,
|
||||
Ref,
|
||||
PointerEvent,
|
||||
PropsWithoutRef,
|
||||
TouchEvent,
|
||||
forwardRef,
|
||||
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 { useStateObservable } from "@react-rxjs/core";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -51,21 +51,16 @@ 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 { VideoTile } from "../video-grid/VideoTile";
|
||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
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 { useFullscreen } from "./useFullscreen";
|
||||
import { useLayoutStates } from "../video-grid/Layout";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
@@ -74,13 +69,28 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import { useCallViewModel } from "../state/CallViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
import { GridMode, Layout, useCallViewModel } 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";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
@@ -126,309 +136,427 @@ export interface InCallViewProps {
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
({
|
||||
client,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
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]);
|
||||
|
||||
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 { 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 vm = useCallViewModel(
|
||||
rtcSession.room,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
);
|
||||
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);
|
||||
|
||||
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]);
|
||||
// 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), []);
|
||||
|
||||
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);
|
||||
// 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 screenSharingTracks = useTracks(
|
||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||
{
|
||||
room: livekitRoom,
|
||||
},
|
||||
);
|
||||
const { layout, setLayout } = useVideoGridLayout(
|
||||
screenSharingTracks.length > 0,
|
||||
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,
|
||||
{},
|
||||
);
|
||||
}, [gridMode]);
|
||||
|
||||
const { hideScreensharing, showControls } = useUrlParams();
|
||||
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 { 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,
|
||||
{},
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
}, [layout]);
|
||||
|
||||
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(
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.TileLayout,
|
||||
onTileLayout,
|
||||
);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setLayout]);
|
||||
|
||||
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.e2eeSystem.kind !== E2eeType.NONE,
|
||||
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>
|
||||
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,
|
||||
);
|
||||
}
|
||||
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}
|
||||
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 model.type === "grid" ? (
|
||||
<GridTile
|
||||
ref={ref}
|
||||
vm={model.vm}
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<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 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 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 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.inRoom} ref={containerRef}>
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
<Header>
|
||||
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}>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
@@ -444,24 +572,19 @@ export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
},
|
||||
);
|
||||
))}
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,8 +18,10 @@ 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 from "@vector-im/compound-design-tokens/icons/link.svg?react";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
|
||||
import {
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import useClipboard from "react-use-clipboard";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -21,22 +21,15 @@ 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
|
||||
* mobile safari also has them take up space in the DOM, so set width 0
|
||||
*/
|
||||
/* Safari puts a margin on these, which is not removed via appearance: none */
|
||||
margin: 0;
|
||||
width: 0;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.toggle label {
|
||||
display: block;
|
||||
padding: calc(2.5 * var(--cpd-space-1x));
|
||||
block-size: var(--cpd-space-11x);
|
||||
inline-size: var(--cpd-space-11x);
|
||||
cursor: pointer;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
color: var(--cpd-color-icon-primary);
|
||||
@@ -44,41 +37,52 @@ 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 label:hover {
|
||||
.toggle input:hover {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle label:active {
|
||||
.toggle input:active {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toggle input:checked + label {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
.toggle input:checked {
|
||||
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 + label:hover {
|
||||
.toggle input:checked:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle input:checked + label:active {
|
||||
.toggle input:checked:active {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
|
||||
.toggle label > svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle label:last-child {
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
|
||||
.toggle input:focus-visible + label {
|
||||
outline: auto;
|
||||
.toggle input:first-child {
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ChangeEvent, FC, useCallback, useId } from "react";
|
||||
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
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 {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./LayoutToggle.module.css";
|
||||
@@ -29,9 +31,15 @@ interface Props {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
className?: string;
|
||||
onTouchEnd?: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
export const LayoutToggle: FC<Props> = ({
|
||||
layout,
|
||||
setLayout,
|
||||
className,
|
||||
onTouchEnd,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange = useCallback(
|
||||
@@ -39,37 +47,30 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
[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")}>
|
||||
<label htmlFor={spotlightId}>
|
||||
<SpotlightIcon aria-label={t("layout_spotlight_label")} />
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<input
|
||||
id={gridId}
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||
<Tooltip label={t("layout_grid_label")}>
|
||||
<label htmlFor={gridId}>
|
||||
<GridIcon aria-label={t("layout_grid_label")} />
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,10 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Dispatch, SetStateAction, useMemo } from "react";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||
|
||||
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
|
||||
@@ -53,7 +61,7 @@ function useMuteState(
|
||||
): MuteState {
|
||||
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
|
||||
(prev) =>
|
||||
device.available.length > 0 ? prev ?? enabledByDefault() : undefined,
|
||||
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined,
|
||||
[device],
|
||||
);
|
||||
return useMemo(
|
||||
@@ -74,5 +82,62 @@ export function useMuteStates(): MuteStates {
|
||||
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],
|
||||
);
|
||||
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]);
|
||||
|
||||
return useMemo(() => ({ audio, video }), [audio, video]);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
|
||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
@@ -26,7 +26,6 @@ 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";
|
||||
@@ -36,6 +35,10 @@ import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import {
|
||||
useSetting,
|
||||
optInAnalytics as optInAnalyticsSetting,
|
||||
} from "../settings/settings";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -80,7 +83,7 @@ export const RoomPage: FC = () => {
|
||||
registerPasswordlessUser,
|
||||
]);
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||
useEffect(() => {
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
|
||||
@@ -18,20 +18,12 @@ limitations under the License.
|
||||
margin-inline: var(--inline-content-inset);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
}
|
||||
|
||||
.preview.content {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-radius: var(--cpd-space-4x);
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content video {
|
||||
.preview > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -69,12 +61,20 @@ limitations under the License.
|
||||
);
|
||||
}
|
||||
|
||||
.preview.content .buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 1 / 1) {
|
||||
.preview video {
|
||||
.preview > video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.preview {
|
||||
margin-inline: 0;
|
||||
border-radius: 0;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 - 2023 New Vector Ltd
|
||||
Copyright 2022 - 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.
|
||||
@@ -18,20 +18,15 @@ import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import { usePreviewTracks } from "@livekit/components-react";
|
||||
import {
|
||||
CreateLocalTracksOptions,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { 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 { useMediaQuery } from "../useMediaQuery";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type MatrixInfo = {
|
||||
@@ -63,10 +58,10 @@ 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 = useRef<CreateLocalTracksOptions["audio"]>();
|
||||
initialAudioOptions.current ??= muteStates.audio.enabled && {
|
||||
deviceId: devices.audioInput.selectedId,
|
||||
};
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
||||
);
|
||||
|
||||
const localTrackOptions = useMemo(
|
||||
() => ({
|
||||
@@ -76,12 +71,16 @@ export const VideoPreview: FC<Props> = ({
|
||||
// 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.current),
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
video: muteStates.video.enabled && {
|
||||
deviceId: devices.videoInput.selectedId,
|
||||
},
|
||||
}),
|
||||
[devices.videoInput.selectedId, muteStates.video.enabled],
|
||||
[
|
||||
initialAudioOptions,
|
||||
devices.videoInput.selectedId,
|
||||
muteStates.video.enabled,
|
||||
],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
@@ -115,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
ref={videoEl}
|
||||
muted
|
||||
@@ -136,21 +135,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,114 +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 { 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,
|
||||
};
|
||||
}
|
||||
@@ -225,16 +225,38 @@ export const useLoadGroupCall = (
|
||||
});
|
||||
} else {
|
||||
// If the room does not exist we first search for it with viaServers
|
||||
const roomSummary = await client.getRoomSummary(roomId, viaServers);
|
||||
if (roomSummary.join_rule === JoinRule.Public) {
|
||||
room = await client.joinRoom(roomSummary.room_id, {
|
||||
let roomSummary: RoomSummary | undefined = undefined;
|
||||
try {
|
||||
roomSummary = await client.getRoomSummary(roomId, viaServers);
|
||||
} catch (error) {
|
||||
// If the room summary endpoint is not supported we let it be undefined and treat this case like
|
||||
// `JoinRule.Public`.
|
||||
// This is how the logic was done before: "we expect any room id passed to EC
|
||||
// to be for a public call" Which is definitely not ideal but worth a try if fetching
|
||||
// the summary crashes.
|
||||
logger.warn(
|
||||
`Could not load room summary to decide whether we want to join or knock.
|
||||
EC will fallback to join as if this would be a public room.
|
||||
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
roomSummary === undefined ||
|
||||
roomSummary.join_rule === JoinRule.Public
|
||||
) {
|
||||
room = await client.joinRoom(roomId, {
|
||||
viaServers,
|
||||
});
|
||||
} else if (roomSummary.join_rule === JoinRule.Knock) {
|
||||
// bind room summary in this scope so we have it stored in a binding of type `RoomSummary`
|
||||
// instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept
|
||||
// the type check from the if condition above.
|
||||
const _roomSummary = roomSummary;
|
||||
let knock: () => void = () => {};
|
||||
const userPressedAskToJoinPromise: Promise<void> = new Promise(
|
||||
(resolve) => {
|
||||
if (roomSummary.membership !== KnownMembership.Knock) {
|
||||
if (_roomSummary.membership !== KnownMembership.Knock) {
|
||||
knock = resolve;
|
||||
} else {
|
||||
// resolve immediately if the user already knocked
|
||||
@@ -242,12 +264,13 @@ export const useLoadGroupCall = (
|
||||
}
|
||||
},
|
||||
);
|
||||
setState({ kind: "canKnock", roomSummary, knock });
|
||||
setState({ kind: "canKnock", roomSummary: _roomSummary, knock });
|
||||
await userPressedAskToJoinPromise;
|
||||
room = await getRoomByKnocking(
|
||||
roomSummary.room_id,
|
||||
viaServers,
|
||||
() => setState({ kind: "waitForInvite", roomSummary }),
|
||||
() =>
|
||||
setState({ kind: "waitForInvite", roomSummary: _roomSummary }),
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
|
||||
@@ -108,10 +108,17 @@ export async function enterRTCSession(
|
||||
|
||||
// right now we assume everything is a room-scoped call
|
||||
const livekitAlias = rtcSession.room.roomId;
|
||||
const useDeviceSessionMemberEvents =
|
||||
Config.get().features?.feature_use_device_session_member_events;
|
||||
rtcSession.joinRoomSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
{ manageMediaKeys: encryptMedia },
|
||||
{
|
||||
manageMediaKeys: encryptMedia,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
|
||||
const avatar = data.get("avatar");
|
||||
|
||||
const avatarSize =
|
||||
typeof avatar == "string" ? avatar.length : avatar?.size ?? 0;
|
||||
typeof avatar == "string" ? avatar.length : (avatar?.size ?? 0);
|
||||
const displayName =
|
||||
typeof displayNameDataEntry == "string"
|
||||
? displayNameDataEntry
|
||||
: displayNameDataEntry?.name ?? null;
|
||||
: (displayNameDataEntry?.name ?? null);
|
||||
|
||||
if (!displayName) {
|
||||
return;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ChangeEvent, FC, Key, ReactNode } from "react";
|
||||
import { ChangeEvent, FC, Key, ReactNode, useCallback } from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { MatrixClient } from "matrix-js-sdk";
|
||||
@@ -29,14 +29,8 @@ import OverflowIcon from "../icons/Overflow.svg?react";
|
||||
import UserIcon from "../icons/User.svg?react";
|
||||
import FeedbackIcon from "../icons/Feedback.svg?react";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import {
|
||||
useOptInAnalytics,
|
||||
useDeveloperSettingsTab,
|
||||
useShowConnectionStats,
|
||||
isFirefox,
|
||||
} from "./useSetting";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Body, Caption } from "../typography/Typography";
|
||||
import { Caption } from "../typography/Typography";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
@@ -46,6 +40,13 @@ import {
|
||||
useMediaDeviceNames,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
useSetting,
|
||||
optInAnalytics as optInAnalyticsSetting,
|
||||
developerSettingsTab as developerSettingsTabSetting,
|
||||
duplicateTiles as duplicateTilesSetting,
|
||||
} from "./settings";
|
||||
import { isFirefox } from "../Platform";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -76,11 +77,11 @@ export const SettingsModal: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
||||
useDeveloperSettingsTab();
|
||||
const [showConnectionStats, setShowConnectionStats] =
|
||||
useShowConnectionStats();
|
||||
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
|
||||
developerSettingsTabSetting,
|
||||
);
|
||||
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
|
||||
|
||||
// Generate a `SelectInput` with a list of devices for a given device kind.
|
||||
const generateDeviceSelection = (
|
||||
@@ -238,23 +239,40 @@ export const SettingsModal: FC<Props> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldRow>
|
||||
<Body className={styles.fieldRowText}>
|
||||
{t("version", {
|
||||
version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||
})}
|
||||
</Body>
|
||||
</FieldRow>
|
||||
<p>
|
||||
{t("version", {
|
||||
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("crypto_version", {
|
||||
version: client.getCrypto()?.getVersion() || "unknown",
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("matrix_id", {
|
||||
id: client.getUserId() || "unknown",
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("device_id", {
|
||||
id: client.getDeviceId() || "unknown",
|
||||
})}
|
||||
</p>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showConnectionStats"
|
||||
name="connection-stats"
|
||||
label={t("settings.show_connection_stats_label")}
|
||||
type="checkbox"
|
||||
checked={showConnectionStats}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setShowConnectionStats(e.target.checked)
|
||||
}
|
||||
id="duplicateTiles"
|
||||
type="number"
|
||||
label={t("settings.duplicate_tiles_label")}
|
||||
value={duplicateTiles.toString()}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = event.target.valueAsNumber;
|
||||
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
|
||||
},
|
||||
[setDuplicateTiles],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</TabItem>
|
||||
|
||||
94
src/settings/settings.ts
Normal file
94
src/settings/settings.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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 { logger } from "matrix-js-sdk/src/logger";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
export class Setting<T> {
|
||||
public constructor(key: string, defaultValue: T) {
|
||||
this.key = `matrix-setting-${key}`;
|
||||
|
||||
const storedValue = localStorage.getItem(this.key);
|
||||
let initialValue = defaultValue;
|
||||
if (storedValue !== null) {
|
||||
try {
|
||||
initialValue = JSON.parse(storedValue);
|
||||
} catch (e) {
|
||||
logger.warn(`Invalid value stored for setting ${key}: ${storedValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
this._value = new BehaviorSubject(initialValue);
|
||||
this.value = this._value;
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value: BehaviorSubject<T>;
|
||||
public readonly value: Observable<T>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
this._value.next(value);
|
||||
localStorage.setItem(this.key, JSON.stringify(value));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that returns a settings's current value and a setter.
|
||||
*/
|
||||
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
|
||||
return [useObservableEagerState(setting.value), setting.setValue];
|
||||
}
|
||||
|
||||
// null = undecided
|
||||
export const optInAnalytics = new Setting<boolean | null>(
|
||||
"opt-in-analytics",
|
||||
null,
|
||||
);
|
||||
// TODO: This setting can be disabled. Work out an approach to disableable
|
||||
// settings thats works for Observables in addition to React.
|
||||
export const useOptInAnalytics = (): [
|
||||
boolean | null,
|
||||
((value: boolean | null) => void) | null,
|
||||
] => {
|
||||
const setting = useSetting(optInAnalytics);
|
||||
return PosthogAnalytics.instance.isEnabled() ? setting : [false, null];
|
||||
};
|
||||
|
||||
export const developerSettingsTab = new Setting(
|
||||
"developer-settings-tab",
|
||||
false,
|
||||
);
|
||||
|
||||
export const duplicateTiles = new Setting("duplicate-tiles", 0);
|
||||
|
||||
export const audioInput = new Setting<string | undefined>(
|
||||
"audio-input",
|
||||
undefined,
|
||||
);
|
||||
export const audioOutput = new Setting<string | undefined>(
|
||||
"audio-output",
|
||||
undefined,
|
||||
);
|
||||
export const videoInput = new Setting<string | undefined>(
|
||||
"video-input",
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
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 { useCallback, useMemo } from "react";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
|
||||
type Setting<T> = [T, (value: T) => void];
|
||||
type DisableableSetting<T> = [T, ((value: T) => void) | null];
|
||||
|
||||
export const getSettingKey = (name: string): string => {
|
||||
return `matrix-setting-${name}`;
|
||||
};
|
||||
// Like useState, but reads from and persists the value to localStorage
|
||||
export const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
|
||||
const key = useMemo(() => getSettingKey(name), [name]);
|
||||
|
||||
const [item, setItem] = useLocalStorage(key);
|
||||
|
||||
const value = useMemo(
|
||||
() => (item == null ? defaultValue : JSON.parse(item)),
|
||||
[item, defaultValue],
|
||||
);
|
||||
const setValue = useCallback(
|
||||
(value: T) => {
|
||||
setItem(JSON.stringify(value));
|
||||
},
|
||||
[setItem],
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
};
|
||||
|
||||
export const getSetting = <T>(name: string, defaultValue: T): T => {
|
||||
const item = getLocalStorageItem(getSettingKey(name));
|
||||
return item === null ? defaultValue : JSON.parse(item);
|
||||
};
|
||||
|
||||
export const setSetting = <T>(name: string, newValue: T): void =>
|
||||
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
|
||||
|
||||
export const isFirefox = (): boolean => {
|
||||
const { userAgent } = navigator;
|
||||
return userAgent.includes("Firefox");
|
||||
};
|
||||
|
||||
const canEnableSpatialAudio = (): boolean => {
|
||||
// Spatial audio means routing audio through audio contexts. On Chrome,
|
||||
// this bypasses the AEC processor and so breaks echo cancellation.
|
||||
// We only allow spatial audio to be enabled on Firefox which we know
|
||||
// passes audio context audio through the AEC algorithm.
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574 is the
|
||||
// chrome bug for this: once this is fixed and the updated version is deployed
|
||||
// widely enough, we can allow spatial audio everywhere. It's currently in a
|
||||
// chrome flag, so we could enable this in Electron if we enabled the chrome flag
|
||||
// in the Electron wrapper.
|
||||
return isFirefox();
|
||||
};
|
||||
|
||||
export const useSpatialAudio = (): DisableableSetting<boolean> => {
|
||||
const settingVal = useSetting("spatial-audio", false);
|
||||
if (canEnableSpatialAudio()) return settingVal;
|
||||
|
||||
return [false, null];
|
||||
};
|
||||
|
||||
// null = undecided
|
||||
export const useOptInAnalytics = (): DisableableSetting<boolean | null> => {
|
||||
const settingVal = useSetting<boolean | null>("opt-in-analytics", null);
|
||||
if (PosthogAnalytics.instance.isEnabled()) return settingVal;
|
||||
|
||||
return [false, null];
|
||||
};
|
||||
|
||||
export const useDeveloperSettingsTab = (): Setting<boolean> =>
|
||||
useSetting("developer-settings-tab", false);
|
||||
|
||||
export const useShowConnectionStats = (): Setting<boolean> =>
|
||||
useSetting("show-connection-stats", false);
|
||||
|
||||
export const useAudioInput = (): Setting<string | undefined> =>
|
||||
useSetting<string | undefined>("audio-input", undefined);
|
||||
export const useAudioOutput = (): Setting<string | undefined> =>
|
||||
useSetting<string | undefined>("audio-output", undefined);
|
||||
export const useVideoInput = (): Setting<string | undefined> =>
|
||||
useSetting<string | undefined>("video-input", undefined);
|
||||
@@ -28,29 +28,33 @@ import {
|
||||
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
Observable,
|
||||
Subject,
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeAll,
|
||||
of,
|
||||
race,
|
||||
sample,
|
||||
scan,
|
||||
shareReplay,
|
||||
skip,
|
||||
startWith,
|
||||
switchAll,
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
throttleTime,
|
||||
timer,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
import { StateObservable, state } from "@react-rxjs/core";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -61,32 +65,24 @@ import {
|
||||
} from "../livekit/useECConnectionState";
|
||||
import { usePrevious } from "../usePrevious";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
MediaViewModel,
|
||||
UserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
UserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { finalizeValue } from "../observable-utils";
|
||||
import { accumulate, finalizeValue } from "../observable-utils";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { duplicateTiles } from "../settings/settings";
|
||||
import { isFirefox } from "../Platform";
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
// Represents something that should get a tile on the layout,
|
||||
// ie. a user's video feed or a screen share feed.
|
||||
// TODO: This exposes too much information to the view layer, let's keep this
|
||||
// information internal to the view model and switch to using Tile<T> instead
|
||||
export interface TileDescriptor<T> {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
isPresenter: boolean;
|
||||
isSpeaker: boolean;
|
||||
hasVideo: boolean;
|
||||
local: boolean;
|
||||
largeBaseSize: boolean;
|
||||
placeNear?: string;
|
||||
data: T;
|
||||
}
|
||||
// This is the number of participants that we think constitutes a "small" call
|
||||
// on mobile. No spotlight tile should be shown below this threshold.
|
||||
const smallMobileCallThreshold = 3;
|
||||
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
@@ -94,18 +90,30 @@ export interface GridLayout {
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLayout {
|
||||
type: "spotlight";
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface FullScreenLayout {
|
||||
type: "full screen";
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: MediaViewModel[];
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayout {
|
||||
type: "one-on-one";
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
type: "pip";
|
||||
spotlight: MediaViewModel[];
|
||||
@@ -117,26 +125,44 @@ export interface PipLayout {
|
||||
*/
|
||||
export type Layout =
|
||||
| GridLayout
|
||||
| SpotlightLayout
|
||||
| FullScreenLayout
|
||||
| SpotlightLandscapeLayout
|
||||
| SpotlightPortraitLayout
|
||||
| SpotlightExpandedLayout
|
||||
| OneOnOneLayout
|
||||
| PipLayout;
|
||||
|
||||
export type GridMode = "grid" | "spotlight";
|
||||
|
||||
export type WindowMode = "normal" | "full screen" | "pip";
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
/**
|
||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||
*/
|
||||
enum SortingBin {
|
||||
SelfStart,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is on.
|
||||
*/
|
||||
SelfAlwaysShown,
|
||||
/**
|
||||
* Participants that are sharing their screen.
|
||||
*/
|
||||
Presenters,
|
||||
/**
|
||||
* Participants that have been speaking recently.
|
||||
*/
|
||||
Speakers,
|
||||
VideoAndAudio,
|
||||
/**
|
||||
* Participants with video.
|
||||
*/
|
||||
Video,
|
||||
Audio,
|
||||
NoMedia,
|
||||
SelfEnd,
|
||||
/**
|
||||
* Participants not sharing any video.
|
||||
*/
|
||||
NoVideo,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is off.
|
||||
*/
|
||||
SelfNotAlwaysShown,
|
||||
}
|
||||
|
||||
class UserMedia {
|
||||
@@ -151,14 +177,17 @@ class UserMedia {
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
|
||||
this.vm =
|
||||
participant instanceof LocalParticipant
|
||||
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
||||
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
||||
|
||||
this.speaker = this.vm.speaking.pipeState(
|
||||
// Require 1 s of continuous speaking to become a speaker, and 10 s of
|
||||
this.speaker = this.vm.speaking.pipe(
|
||||
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||
// continuous silence to stop being considered a speaker
|
||||
audit((s) =>
|
||||
merge(
|
||||
timer(s ? 1000 : 10000),
|
||||
timer(s ? 1000 : 60000),
|
||||
// If the speaking flag resets to its original value during this time,
|
||||
// end the silencing window to stick with that original value
|
||||
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
|
||||
@@ -210,7 +239,8 @@ function findMatrixMember(
|
||||
room: MatrixRoom,
|
||||
id: string,
|
||||
): RoomMember | undefined {
|
||||
if (!id) return undefined;
|
||||
if (id === "local")
|
||||
return room.getMember(room.client.getUserId()!) ?? undefined;
|
||||
|
||||
const parts = id.split(":");
|
||||
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
||||
@@ -229,9 +259,9 @@ function findMatrixMember(
|
||||
|
||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||
export class CallViewModel extends ViewModel {
|
||||
private readonly rawRemoteParticipants = state(
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
||||
this.livekitRoom,
|
||||
).pipe(shareReplay(1));
|
||||
|
||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||
// they've left
|
||||
@@ -271,16 +301,13 @@ export class CallViewModel extends ViewModel {
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
// Aggregate the hold instructions into a single list showing which
|
||||
// Accumulate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
scan(
|
||||
(holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
[] as RemoteParticipant[][],
|
||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||
@@ -304,33 +331,30 @@ export class CallViewModel extends ViewModel {
|
||||
},
|
||||
);
|
||||
|
||||
private readonly mediaItems: StateObservable<MediaItem[]> = state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan(
|
||||
(
|
||||
prevItems,
|
||||
[remoteParticipants, { participant: localParticipant }],
|
||||
) => {
|
||||
let allGhosts = true;
|
||||
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
duplicateTiles.value,
|
||||
]).pipe(
|
||||
scan(
|
||||
(
|
||||
prevItems,
|
||||
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
|
||||
) => {
|
||||
const newItems = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||
const userMediaId = p === localParticipant ? "local" : p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
if (member === undefined)
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||
);
|
||||
|
||||
const newItems = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||
const member = findMatrixMember(this.matrixRoom, p.identity);
|
||||
allGhosts &&= member === undefined;
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (p.identity !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaId = p.identity;
|
||||
// Create as many tiles for this participant as called for by
|
||||
// the duplicateTiles option
|
||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||
const userMediaId = `${p.identity}:${i}`;
|
||||
yield [
|
||||
userMediaId,
|
||||
prevItems.get(userMediaId) ??
|
||||
@@ -346,74 +370,107 @@ export class CallViewModel extends ViewModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
}.bind(this)(),
|
||||
);
|
||||
}
|
||||
}.bind(this)(),
|
||||
);
|
||||
|
||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? new Map() : newItems;
|
||||
},
|
||||
new Map<string, MediaItem>(),
|
||||
),
|
||||
map((ms) => [...ms.values()]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.destroy();
|
||||
}),
|
||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
||||
return newItems;
|
||||
},
|
||||
new Map<string, MediaItem>(),
|
||||
),
|
||||
map((mediaItems) => [...mediaItems.values()]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.destroy();
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
||||
map((mediaItems) =>
|
||||
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
||||
),
|
||||
);
|
||||
|
||||
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
|
||||
this.mediaItems.pipe(
|
||||
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
|
||||
);
|
||||
|
||||
private readonly screenShares: Observable<ScreenShare[]> =
|
||||
this.mediaItems.pipe(
|
||||
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
||||
map((mediaItems) =>
|
||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||
),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
||||
private readonly hasRemoteScreenShares: Observable<boolean> =
|
||||
this.screenShares.pipe(
|
||||
map((ms) => ms.some((m) => !m.vm.local)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
|
||||
this.userMedia.pipe(
|
||||
switchMap((ms) =>
|
||||
ms.length === 0
|
||||
switchMap((mediaItems) =>
|
||||
mediaItems.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
||||
mediaItems.map((m) =>
|
||||
m.vm.speaking.pipe(map((s) => [m, s] as const)),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
||||
(prev, ms) =>
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
|
||||
(prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
ms.find(([m, s]) => m === prev && s)?.[0] ??
|
||||
// Otherwise, select anyone who is speaking
|
||||
ms.find(([, s]) => s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
prev ??
|
||||
// Otherwise, spotlight the local user
|
||||
ms.find(([m]) => m.vm.local)?.[0] ??
|
||||
null,
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.vm.local)![0]);
|
||||
},
|
||||
null,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(800, undefined, { leading: true, trailing: true }),
|
||||
map((speaker) => speaker.vm),
|
||||
shareReplay(1),
|
||||
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
||||
);
|
||||
|
||||
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
||||
switchMap((ms) => {
|
||||
const bins = ms.map((m) =>
|
||||
switchMap((mediaItems) => {
|
||||
const bins = mediaItems.map((m) =>
|
||||
combineLatest(
|
||||
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
|
||||
(speaker, presenter, audio, video) => {
|
||||
[
|
||||
m.speaker,
|
||||
m.presenter,
|
||||
m.vm.videoEnabled,
|
||||
m.vm instanceof LocalUserMediaViewModel
|
||||
? m.vm.alwaysShow
|
||||
: of(false),
|
||||
],
|
||||
(speaker, presenter, video, alwaysShow) => {
|
||||
let bin: SortingBin;
|
||||
if (m.vm.local) bin = SortingBin.SelfStart;
|
||||
if (m.vm.local)
|
||||
bin = alwaysShow
|
||||
? SortingBin.SelfAlwaysShown
|
||||
: SortingBin.SelfNotAlwaysShown;
|
||||
else if (presenter) bin = SortingBin.Presenters;
|
||||
else if (speaker) bin = SortingBin.Speakers;
|
||||
else if (video)
|
||||
bin = audio ? SortingBin.VideoAndAudio : SortingBin.Video;
|
||||
else bin = audio ? SortingBin.Audio : SortingBin.NoMedia;
|
||||
else if (video) bin = SortingBin.Video;
|
||||
else bin = SortingBin.NoVideo;
|
||||
|
||||
return [m, bin] as const;
|
||||
},
|
||||
@@ -428,153 +485,339 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
||||
[this.screenShares, this.spotlightSpeaker],
|
||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
||||
private readonly spotlightAndPip: Observable<
|
||||
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
|
||||
> = this.screenShares.pipe(
|
||||
map((screenShares) =>
|
||||
screenShares.length > 0
|
||||
? screenShares.map((m) => m.vm)
|
||||
: spotlightSpeaker === null
|
||||
? []
|
||||
: [spotlightSpeaker.vm],
|
||||
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
|
||||
: ([
|
||||
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
|
||||
this.spotlightSpeaker.pipe(
|
||||
switchMap((speaker) =>
|
||||
speaker.local
|
||||
? of(null)
|
||||
: this.localUserMedia.pipe(
|
||||
switchMap((vm) =>
|
||||
vm.alwaysShow.pipe(
|
||||
map((alwaysShow) => (alwaysShow ? vm : null)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] as const),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Make this react to changes in window dimensions and screen
|
||||
// orientation
|
||||
private readonly windowMode = of<WindowMode>("normal");
|
||||
private readonly spotlight: Observable<MediaViewModel[]> =
|
||||
this.spotlightAndPip.pipe(
|
||||
switchMap(([spotlight]) => spotlight),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
|
||||
private readonly pip: Observable<UserMediaViewModel | null> =
|
||||
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
|
||||
|
||||
/**
|
||||
* The general shape of the window.
|
||||
*/
|
||||
public readonly windowMode: Observable<WindowMode> = fromEvent(
|
||||
window,
|
||||
"resize",
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => {
|
||||
const height = window.innerHeight;
|
||||
const width = window.innerWidth;
|
||||
if (height <= 400 && width <= 340) return "pip";
|
||||
// Our layouts for flat windows are better at adapting to a small width
|
||||
// than our layouts for narrow windows are at adapting to a small height,
|
||||
// so we give "flat" precedence here
|
||||
if (height <= 600) return "flat";
|
||||
if (width <= 600) return "narrow";
|
||||
return "normal";
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly spotlightExpandedToggle = new Subject<void>();
|
||||
public readonly spotlightExpanded: Observable<boolean> =
|
||||
this.spotlightExpandedToggle.pipe(
|
||||
accumulate(false, (expanded) => !expanded),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly gridModeUserSelection = new Subject<GridMode>();
|
||||
/**
|
||||
* The layout mode of the media tile grid.
|
||||
*/
|
||||
public readonly gridMode = state(this._gridMode);
|
||||
public readonly gridMode: Observable<GridMode> =
|
||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||
this.gridModeUserSelection.pipe(
|
||||
startWith(null),
|
||||
switchMap((userSelection) =>
|
||||
(userSelection === "spotlight"
|
||||
? EMPTY
|
||||
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
|
||||
skip(userSelection === null ? 0 : 1),
|
||||
map(
|
||||
([hasScreenShares, windowMode]): GridMode =>
|
||||
hasScreenShares || windowMode === "flat"
|
||||
? "spotlight"
|
||||
: "grid",
|
||||
),
|
||||
)
|
||||
).pipe(startWith(userSelection ?? "grid")),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public setGridMode(value: GridMode): void {
|
||||
this._gridMode.next(value);
|
||||
this.gridModeUserSelection.next(value);
|
||||
}
|
||||
|
||||
public readonly layout: StateObservable<Layout> = state(
|
||||
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
||||
private readonly oneOnOne: Observable<boolean> = combineLatest(
|
||||
[this.grid, this.screenShares],
|
||||
(grid, screenShares) =>
|
||||
grid.length == 2 &&
|
||||
// There might not be a remote tile if only the local user is in the call
|
||||
// and they're using the duplicate tiles option
|
||||
grid.some((vm) => !vm.local) &&
|
||||
screenShares.length === 0,
|
||||
);
|
||||
|
||||
private readonly gridLayout: Observable<Layout> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
? spotlight
|
||||
: undefined,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly spotlightLandscapeLayout: Observable<Layout> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid }),
|
||||
);
|
||||
|
||||
private readonly spotlightPortraitLayout: Observable<Layout> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid }),
|
||||
);
|
||||
|
||||
private readonly spotlightExpandedLayout: Observable<Layout> = combineLatest(
|
||||
[this.spotlight, this.pip],
|
||||
(spotlight, pip) => ({
|
||||
type: "spotlight-expanded",
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly oneOnOneLayout: Observable<Layout> = this.grid.pipe(
|
||||
map((grid) => ({
|
||||
type: "one-on-one",
|
||||
local: grid.find((vm) => vm.local) as LocalUserMediaViewModel,
|
||||
remote: grid.find((vm) => !vm.local) as RemoteUserMediaViewModel,
|
||||
})),
|
||||
);
|
||||
|
||||
private readonly pipLayout: Observable<Layout> = this.spotlight.pipe(
|
||||
map((spotlight): Layout => ({ type: "pip", spotlight })),
|
||||
);
|
||||
|
||||
public readonly layout: Observable<Layout> = this.windowMode.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "full screen":
|
||||
throw new Error("unimplemented");
|
||||
case "normal":
|
||||
return this.gridMode.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return this.oneOnOne.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne ? this.oneOnOneLayout : this.gridLayout,
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return this.spotlightExpanded.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? this.spotlightExpandedLayout
|
||||
: this.spotlightLandscapeLayout,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return this.oneOnOne.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne
|
||||
? // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout
|
||||
: combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
? this.spotlightPortraitLayout
|
||||
: this.gridLayout,
|
||||
).pipe(switchAll()),
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return this.gridMode.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return this.spotlightLandscapeLayout;
|
||||
case "spotlight":
|
||||
return this.spotlightExpandedLayout;
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
throw new Error("unimplemented");
|
||||
case "normal": {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight, this.screenShares],
|
||||
(grid, spotlight, screenShares): Layout => ({
|
||||
type: "grid",
|
||||
spotlight: screenShares.length > 0 ? spotlight : undefined,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
case "spotlight":
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight): Layout => ({
|
||||
type: "spotlight",
|
||||
spotlight,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.pipLayout;
|
||||
}
|
||||
}).pipe(switchAll()),
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
|
||||
map((l) => l.type !== "grid"),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* The media tiles to be displayed in the call view.
|
||||
* Determines whether video should be shown for a certain piece of media
|
||||
* appearing in the grid.
|
||||
*/
|
||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
||||
// which keeps more details of the layout order internal to the view model
|
||||
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
|
||||
state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||
const ps = [localParticipant, ...remoteParticipants];
|
||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
let allGhosts = true;
|
||||
|
||||
const newTiles = ps.flatMap((p) => {
|
||||
const userMediaId = p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
allGhosts &&= member === undefined;
|
||||
const spokeRecently =
|
||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (userMediaId !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaVm =
|
||||
tilesById.get(userMediaId)?.data ??
|
||||
new UserMediaViewModel(userMediaId, member, p, this.encrypted);
|
||||
tilesById.delete(userMediaId);
|
||||
|
||||
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
||||
id: userMediaId,
|
||||
focused: false,
|
||||
isPresenter: p.isScreenShareEnabled,
|
||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||
hasVideo: p.isCameraEnabled,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: false,
|
||||
data: userMediaVm,
|
||||
};
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${userMediaId}:screen-share`;
|
||||
const screenShareVm =
|
||||
tilesById.get(screenShareId)?.data ??
|
||||
new ScreenShareViewModel(
|
||||
screenShareId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
);
|
||||
tilesById.delete(screenShareId);
|
||||
|
||||
const screenShareTile: TileDescriptor<MediaViewModel> = {
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: true,
|
||||
placeNear: userMediaId,
|
||||
data: screenShareVm,
|
||||
};
|
||||
return [userMediaTile, screenShareTile];
|
||||
} else {
|
||||
return [userMediaTile];
|
||||
}
|
||||
});
|
||||
|
||||
// Any tiles left in the map are unused and should be destroyed
|
||||
for (const t of tilesById.values()) t.data.destroy();
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : newTiles;
|
||||
}, [] as TileDescriptor<MediaViewModel>[]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.data.destroy();
|
||||
}),
|
||||
public showGridVideo(vm: MediaViewModel): Observable<boolean> {
|
||||
return this.layout.pipe(
|
||||
map(
|
||||
(l) =>
|
||||
!(
|
||||
(l.type === "spotlight-landscape" ||
|
||||
l.type === "spotlight-portrait") &&
|
||||
// This media is already visible in the spotlight; avoid duplication
|
||||
l.spotlight.some((spotlightVm) => spotlightVm === vm)
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
|
||||
map((l) => l.type !== "one-on-one" && !l.type.startsWith("spotlight-")),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
|
||||
this.windowMode.pipe(
|
||||
switchMap((mode) =>
|
||||
mode === "normal"
|
||||
? this.layout.pipe(
|
||||
map(
|
||||
(l) =>
|
||||
l.type === "spotlight-landscape" ||
|
||||
l.type === "spotlight-expanded",
|
||||
),
|
||||
)
|
||||
: of(false),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map((enabled) =>
|
||||
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
|
||||
),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly screenTap = new Subject<void>();
|
||||
private readonly screenHover = new Subject<void>();
|
||||
private readonly screenUnhover = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Callback for when the user taps the call view.
|
||||
*/
|
||||
public tapScreen(): void {
|
||||
this.screenTap.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when the user hovers over the call view.
|
||||
*/
|
||||
public hoverScreen(): void {
|
||||
this.screenHover.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when the user stops hovering over the call view.
|
||||
*/
|
||||
public unhoverScreen(): void {
|
||||
this.screenUnhover.next();
|
||||
}
|
||||
|
||||
public readonly showHeader: Observable<boolean> = this.windowMode.pipe(
|
||||
map((mode) => mode !== "pip" && mode !== "flat"),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public readonly showFooter = this.windowMode.pipe(
|
||||
switchMap((mode) => {
|
||||
switch (mode) {
|
||||
case "pip":
|
||||
return of(false);
|
||||
case "normal":
|
||||
case "narrow":
|
||||
return of(true);
|
||||
case "flat":
|
||||
// Sadly Firefox has some layering glitches that prevent the footer
|
||||
// from appearing properly. They happen less often if we never hide
|
||||
// the footer.
|
||||
if (isFirefox()) return of(true);
|
||||
// Show/hide the footer in response to interactions
|
||||
return merge(
|
||||
this.screenTap.pipe(map(() => "tap" as const)),
|
||||
this.screenHover.pipe(map(() => "hover" as const)),
|
||||
).pipe(
|
||||
switchScan(
|
||||
(state, interaction) =>
|
||||
interaction === "tap"
|
||||
? state
|
||||
? // Toggle visibility on tap
|
||||
of(false)
|
||||
: // Hide after a timeout
|
||||
timer(6000).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
)
|
||||
: // Show on hover and hide after a timeout
|
||||
race(timer(3000), this.screenUnhover.pipe(take(1))).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
),
|
||||
false,
|
||||
),
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { StateObservable, state } from "@react-rxjs/core";
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrack,
|
||||
@@ -32,34 +31,60 @@ import {
|
||||
TrackEvent,
|
||||
facingModeFromLocalTrack,
|
||||
} from "livekit-client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
fromEvent,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { alwaysShowSelf } from "../settings/settings";
|
||||
|
||||
// TODO: Move this naming logic into the view model
|
||||
export function useDisplayName(vm: MediaViewModel): string {
|
||||
const [displayName, setDisplayName] = useReactiveState(
|
||||
() => vm.member?.rawDisplayName ?? "[👻]",
|
||||
[vm.member],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (vm.member) {
|
||||
const updateName = (): void => {
|
||||
setDisplayName(vm.member!.rawDisplayName);
|
||||
};
|
||||
|
||||
vm.member!.on(RoomMemberEvent.Name, updateName);
|
||||
return (): void => {
|
||||
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
|
||||
};
|
||||
}
|
||||
}, [vm.member, setDisplayName]);
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
function observeTrackReference(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): StateObservable<TrackReferenceOrPlaceholder> {
|
||||
return state(
|
||||
observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
),
|
||||
): Observable<TrackReferenceOrPlaceholder> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,15 +96,16 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
|
||||
public readonly video: Observable<TrackReferenceOrPlaceholder>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning: StateObservable<boolean>;
|
||||
public readonly unencryptedWarning: Observable<boolean>;
|
||||
|
||||
public constructor(
|
||||
// TODO: This is only needed for full screen toggling and can be removed as
|
||||
// soon as that code is moved into the view models
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
public readonly id: string,
|
||||
/**
|
||||
* The Matrix room member to which this media belongs.
|
||||
@@ -95,15 +121,13 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
super();
|
||||
const audio = observeTrackReference(participant, audioSource);
|
||||
this.video = observeTrackReference(participant, videoSource);
|
||||
this.unencryptedWarning = state(
|
||||
combineLatest(
|
||||
[audio, this.video],
|
||||
(a, v) =>
|
||||
callEncrypted &&
|
||||
(a.publication?.isEncrypted === false ||
|
||||
v.publication?.isEncrypted === false),
|
||||
).pipe(distinctUntilChanged()),
|
||||
);
|
||||
this.unencryptedWarning = combineLatest(
|
||||
[audio, this.video],
|
||||
(a, v) =>
|
||||
callEncrypted &&
|
||||
(a.publication?.isEncrypted === false ||
|
||||
v.publication?.isEncrypted === false),
|
||||
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,66 +135,39 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
* Some participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
export type UserMediaViewModel =
|
||||
| LocalUserMediaViewModel
|
||||
| RemoteUserMediaViewModel;
|
||||
|
||||
/**
|
||||
* Some participant's user media.
|
||||
*/
|
||||
export class UserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror = state(
|
||||
this.video.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking = state(
|
||||
observeParticipantEvents(
|
||||
this.participant,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking)),
|
||||
public readonly speaking = observeParticipantEvents(
|
||||
this.participant,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(
|
||||
map((p) => p.isSpeaking),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
||||
/**
|
||||
* Whether we've disabled this participant's audio.
|
||||
*/
|
||||
public readonly locallyMuted = state(this._locallyMuted);
|
||||
|
||||
private readonly _localVolume = new BehaviorSubject(1);
|
||||
/**
|
||||
* The volume to which we've set this participant's audio, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume = state(this._localVolume);
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled: StateObservable<boolean>;
|
||||
public readonly audioEnabled: Observable<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
public readonly videoEnabled: StateObservable<boolean>;
|
||||
public readonly videoEnabled: Observable<boolean>;
|
||||
|
||||
private readonly _cropVideo = new BehaviorSubject(true);
|
||||
/**
|
||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||
*/
|
||||
public readonly cropVideo = state(this._cropVideo);
|
||||
public readonly cropVideo: Observable<boolean> = this._cropVideo;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
@@ -187,32 +184,96 @@ export class UserMediaViewModel extends BaseMediaViewModel {
|
||||
Track.Source.Camera,
|
||||
);
|
||||
|
||||
const media = observeParticipantMedia(participant);
|
||||
this.audioEnabled = state(
|
||||
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)),
|
||||
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
|
||||
this.audioEnabled = media.pipe(
|
||||
map((m) => m.microphoneTrack?.isMuted === false),
|
||||
);
|
||||
this.videoEnabled = state(
|
||||
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
|
||||
this.videoEnabled = media.pipe(
|
||||
map((m) => m.cameraTrack?.isMuted === false),
|
||||
);
|
||||
|
||||
// Sync the local mute state and volume with LiveKit
|
||||
if (!this.local)
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
muted ? 0 : volume,
|
||||
)
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((volume) => {
|
||||
(this.participant as RemoteParticipant).setVolume(volume);
|
||||
});
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this._locallyMuted.next(!this._locallyMuted.value);
|
||||
}
|
||||
|
||||
public toggleFitContain(): void {
|
||||
this._cropVideo.next(!this._cropVideo.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local participant's user media.
|
||||
*/
|
||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror = this.video.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether to show this tile in a highly visible location near the start of
|
||||
* the grid.
|
||||
*/
|
||||
public readonly alwaysShow = alwaysShowSelf.value;
|
||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
super(id, member, participant, callEncrypted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
||||
/**
|
||||
* Whether we've disabled this participant's audio.
|
||||
*/
|
||||
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
|
||||
|
||||
private readonly _localVolume = new BehaviorSubject(1);
|
||||
/**
|
||||
* The volume to which we've set this participant's audio, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume: Observable<number> = this._localVolume;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant: RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
super(id, member, participant, callEncrypted);
|
||||
|
||||
// Sync the local mute state and volume with LiveKit
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
muted ? 0 : volume,
|
||||
)
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((volume) => {
|
||||
(this.participant as RemoteParticipant).setVolume(volume);
|
||||
});
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this._locallyMuted.next(!this._locallyMuted.value);
|
||||
}
|
||||
|
||||
public setLocalVolume(value: number): void {
|
||||
this._localVolume.next(value);
|
||||
|
||||
@@ -1,49 +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 {
|
||||
ForwardRefExoticComponent,
|
||||
ForwardRefRenderFunction,
|
||||
PropsWithoutRef,
|
||||
RefAttributes,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
|
||||
|
||||
/**
|
||||
* Wraps a React component that consumes Observables, resulting in a component
|
||||
* that safely subscribes to its Observables before rendering. The component
|
||||
* will return null until the subscriptions are created.
|
||||
*/
|
||||
export function subscribe<P, R>(
|
||||
render: ForwardRefRenderFunction<R, P>,
|
||||
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>> {
|
||||
const Subscriber = forwardRef<R, { p: P }>(({ p }, ref) => (
|
||||
<RemoveSubscribe>{render(p, ref)}</RemoveSubscribe>
|
||||
));
|
||||
Subscriber.displayName = "Subscriber";
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const OuterComponent = forwardRef<R, P>((p, ref) => (
|
||||
<Subscribe>
|
||||
<Subscriber ref={ref} p={p} />
|
||||
</Subscribe>
|
||||
));
|
||||
// Copy over the component's display name, default props, etc.
|
||||
Object.assign(OuterComponent, render);
|
||||
return OuterComponent;
|
||||
}
|
||||
@@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Ref, useCallback, useRef } from "react";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
/**
|
||||
* React hook that creates an Observable from a changing value. The Observable
|
||||
* replays its current value upon subscription and emits whenever the value
|
||||
@@ -28,3 +30,14 @@ export function useObservable<T>(value: T): Observable<T> {
|
||||
if (value !== subject.current.value) subject.current.next(value);
|
||||
return subject.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that creates a ref and an Observable that emits any values
|
||||
* stored in the ref. The Observable replays the value currently stored in the
|
||||
* ref upon subscription.
|
||||
*/
|
||||
export function useObservableRef<T>(initialValue: T): [Observable<T>, Ref<T>] {
|
||||
const subject = useInitial(() => new BehaviorSubject(initialValue));
|
||||
const ref = useCallback((value: T) => subject.next(value), [subject]);
|
||||
return [subject, ref];
|
||||
}
|
||||
|
||||
72
src/tile/GridTile.module.css
Normal file
72
src/tile/GridTile.module.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2022-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.
|
||||
*/
|
||||
|
||||
.tile {
|
||||
--media-view-border-radius: var(--cpd-space-4x);
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
}
|
||||
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
borders don't support gradients */
|
||||
.tile::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1; /* Put it below the outline */
|
||||
opacity: 0; /* Hidden unless speaking */
|
||||
transition: opacity ease 0.15s;
|
||||
inset: calc(-1 * var(--cpd-border-width-4));
|
||||
border-radius: var(--cpd-space-5x);
|
||||
background: linear-gradient(
|
||||
119deg,
|
||||
rgba(13, 92, 189, 0.7) 0%,
|
||||
rgba(13, 189, 168, 0.7) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(13, 92, 189, 0.9) 0%,
|
||||
rgba(13, 189, 168, 0.9) 100%
|
||||
);
|
||||
background-blend-mode: overlay, normal;
|
||||
}
|
||||
|
||||
.tile.speaking {
|
||||
/* !important because speaking border should take priority over hover */
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
||||
}
|
||||
|
||||
.tile.speaking::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tile:hover {
|
||||
outline: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="true"] {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="false"] {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
}
|
||||
315
src/tile/GridTile.tsx
Normal file
315
src/tile/GridTile.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
Copyright 2022-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 {
|
||||
ComponentProps,
|
||||
ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MicOnSolidIcon,
|
||||
MicOffSolidIcon,
|
||||
MicOffIcon,
|
||||
OverflowHorizontalIcon,
|
||||
VolumeOnIcon,
|
||||
VolumeOffIcon,
|
||||
VisibilityOnIcon,
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
Menu,
|
||||
} from "@vector-im/compound-web";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
UserMediaViewModel,
|
||||
useDisplayName,
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { Slider } from "../Slider";
|
||||
import { MediaView } from "./MediaView";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
interface TileProps {
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
displayName: string;
|
||||
showVideo: boolean;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
|
||||
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
showVideo,
|
||||
showSpeakingIndicators,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
displayName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const speaking = useObservableEagerState(vm.speaking);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
videoEnabled={videoEnabled && showVideo}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeakingIndicators && speaking,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<MicIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
||||
data-muted={!audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserMediaTile.displayName = "UserMediaTile";
|
||||
|
||||
interface LocalUserMediaTileProps extends TileProps {
|
||||
vm: LocalUserMediaViewModel;
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.setAlwaysShow(!latestAlwaysShow.current);
|
||||
},
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
||||
|
||||
interface RemoteUserMediaTileProps extends TileProps {
|
||||
vm: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
const RemoteUserMediaTile = forwardRef<
|
||||
HTMLDivElement,
|
||||
RemoteUserMediaTileProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const locallyMuted = useObservableEagerState(vm.locallyMuted);
|
||||
const localVolume = useObservableEagerState(vm.localVolume);
|
||||
const onSelectMute = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleLocallyMuted();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const onChangeLocalVolume = useCallback(
|
||||
(v: number) => vm.setLocalVolume(v),
|
||||
[vm],
|
||||
);
|
||||
|
||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
<>
|
||||
<ToggleMenuItem
|
||||
Icon={MicOffIcon}
|
||||
label={t("video_tile.mute_for_me")}
|
||||
checked={locallyMuted}
|
||||
onSelect={onSelectMute}
|
||||
/>
|
||||
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
||||
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={t("video_tile.volume")}
|
||||
value={localVolume}
|
||||
onValueChange={onChangeLocalVolume}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={locallyMuted}
|
||||
/>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface GridTileProps {
|
||||
vm: UserMediaViewModel;
|
||||
onOpenProfile: () => void;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
showVideo: boolean;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const displayName = useDisplayName(vm);
|
||||
|
||||
if (vm instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GridTile.displayName = "GridTile";
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022-2023 New Vector Ltd
|
||||
Copyright 2022-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.
|
||||
@@ -14,63 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.videoTile {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
container-name: videoTile;
|
||||
.media {
|
||||
container-name: mediaView;
|
||||
container-type: size;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
border-radius: var(--media-view-border-radius);
|
||||
}
|
||||
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
borders don't support gradients */
|
||||
.videoTile::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1; /* Put it below the outline */
|
||||
opacity: 0; /* Hidden unless speaking */
|
||||
transition: opacity ease 0.15s;
|
||||
inset: calc(-1 * var(--cpd-border-width-4));
|
||||
border-radius: var(--cpd-space-5x);
|
||||
background: linear-gradient(
|
||||
119deg,
|
||||
rgba(13, 92, 189, 0.7) 0%,
|
||||
rgba(13, 189, 168, 0.7) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(13, 92, 189, 0.9) 0%,
|
||||
rgba(13, 189, 168, 0.9) 100%
|
||||
);
|
||||
background-blend-mode: overlay, normal;
|
||||
}
|
||||
|
||||
.videoTile.speaking {
|
||||
/* !important because speaking border should take priority over hover */
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
||||
}
|
||||
|
||||
.videoTile.speaking::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.videoTile:hover {
|
||||
outline: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.videoTile.maximised {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.videoTile video {
|
||||
.media video {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: contain;
|
||||
@@ -81,19 +31,19 @@ borders don't support gradients */
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.videoTile.mirror video {
|
||||
.media.mirror video {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.videoTile.screenshare video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.videoTile.cropVideo video {
|
||||
.media[data-video-fit="cover"] video {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.videoTile.videoMuted video {
|
||||
.media[data-video-fit="contain"] video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.media.videoMuted video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -114,13 +64,13 @@ borders don't support gradients */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.videoTile.videoMuted .avatar {
|
||||
.media.videoMuted .avatar {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
/* CSS makes us put a condition here, even though all we want to do is
|
||||
unconditionally select the container so we can use cqmin units */
|
||||
@container videoTile (width > 0) {
|
||||
@container mediaView (width > 0) {
|
||||
.avatar {
|
||||
/* Half of the smallest dimension of the tile */
|
||||
inline-size: 50cqmin;
|
||||
@@ -137,11 +87,14 @@ unconditionally select the container so we can use cqmin units */
|
||||
|
||||
.fg {
|
||||
position: absolute;
|
||||
inset: var(--cpd-space-1x);
|
||||
inset: var(
|
||||
--media-view-fg-inset,
|
||||
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
||||
);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: ". button2" "nameTag button1";
|
||||
grid-template-areas: ". ." "nameTag button";
|
||||
gap: var(--cpd-space-1x);
|
||||
place-items: start;
|
||||
}
|
||||
@@ -167,14 +120,6 @@ unconditionally select the container so we can use cqmin units */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="true"] {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="false"] {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.nameTag > .name {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@@ -200,8 +145,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
transition: opacity ease 0.15s;
|
||||
}
|
||||
|
||||
.fg > button:focus-visible,
|
||||
.fg > :focus-visible ~ button,
|
||||
.fg:has(:focus-visible) > button,
|
||||
.fg > button[data-enabled="true"],
|
||||
.fg > button[data-state="open"] {
|
||||
opacity: 1;
|
||||
@@ -231,13 +175,5 @@ unconditionally select the container so we can use cqmin units */
|
||||
}
|
||||
|
||||
.fg > button:first-of-type {
|
||||
grid-area: button1;
|
||||
}
|
||||
|
||||
.fg > button:nth-of-type(2) {
|
||||
grid-area: button2;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
grid-area: button;
|
||||
}
|
||||
125
src/tile/MediaView.tsx
Normal file
125
src/tile/MediaView.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
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 { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { ComponentProps, ReactNode, forwardRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
import { Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./MediaView.module.css";
|
||||
import { Avatar } from "../Avatar";
|
||||
|
||||
interface Props extends ComponentProps<typeof animated.div> {
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder;
|
||||
videoFit: "cover" | "contain";
|
||||
mirror: boolean;
|
||||
member: RoomMember | undefined;
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
primaryButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
member,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
[styles.videoMuted]: !videoEnabled,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
{video.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
<ErrorIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{primaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MediaView.displayName = "MediaView";
|
||||
169
src/tile/SpotlightTile.module.css
Normal file
169
src/tile/SpotlightTile.module.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.contents {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
display: flex;
|
||||
border-radius: var(--cpd-space-6x);
|
||||
contain: strict;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
scroll-snap-type: inline mandatory;
|
||||
scroll-snap-stop: always;
|
||||
/* It would be nice to use smooth scrolling here, but Firefox has a bug where
|
||||
it will not re-snap if the snapping point changes while it's smoothly
|
||||
animating to another snapping point.
|
||||
scroll-behavior: smooth; */
|
||||
}
|
||||
|
||||
.tile.maximised .contents {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 100%;
|
||||
flex-basis: 100%;
|
||||
flex-shrink: 0;
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.item.snap {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.advance {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
padding: calc(var(--cpd-space-3x) - var(--cpd-border-width-1));
|
||||
border: var(--cpd-border-width-1) solid
|
||||
var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-alpha-gray-1400);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
transition-duration: 0.1s;
|
||||
transition-property: opacity, background-color, border-color;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
/* Center the button vertically on the tile */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.advance > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.advance:hover {
|
||||
border-color: var(--cpd-color-bg-action-primary-hovered);
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.advance:active {
|
||||
border-color: var(--cpd-color-bg-action-primary-pressed);
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
.back {
|
||||
inset-inline-start: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.next {
|
||||
inset-inline-end: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.expand {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
padding: var(--cpd-space-2x);
|
||||
border: none;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-alpha-gray-1400);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
transition-duration: 0.1s;
|
||||
transition-property: opacity, background-color;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
--inset: 6px;
|
||||
inset-block-end: var(--inset);
|
||||
inset-inline-end: var(--inset);
|
||||
}
|
||||
|
||||
.expand > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.expand:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.expand:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.tile:hover > button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tile:has(:focus-visible) > button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indicators {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-2x);
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-end: calc(-1 * var(--cpd-space-6x));
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
transition: opacity ease 0.15s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.indicators.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maximised .indicators {
|
||||
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.indicators > .item {
|
||||
flex-basis: 32px;
|
||||
block-size: 2px;
|
||||
transition: background-color ease 0.15s;
|
||||
}
|
||||
|
||||
.indicators > .item[data-visible="false"] {
|
||||
background: var(--cpd-color-alpha-gray-600);
|
||||
}
|
||||
|
||||
.indicators > .item[data-visible="true"] {
|
||||
background: var(--cpd-color-gray-1400);
|
||||
}
|
||||
329
src/tile/SpotlightTile.tsx
Normal file
329
src/tile/SpotlightTile.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
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 {
|
||||
ComponentProps,
|
||||
RefAttributes,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ExpandIcon,
|
||||
CollapseIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { Observable, map } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { RoomMember } from "matrix-js-sdk";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
import styles from "./SpotlightTile.module.css";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
MediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
UserMediaViewModel,
|
||||
useDisplayName,
|
||||
} from "../state/MediaViewModel";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useObservableRef } from "../state/useObservable";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
className?: string;
|
||||
"data-id": string;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
videoEnabled: boolean;
|
||||
videoFit: "contain" | "cover";
|
||||
}
|
||||
|
||||
interface SpotlightLocalUserMediaItemProps
|
||||
extends SpotlightUserMediaItemBaseProps {
|
||||
vm: LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightLocalUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightLocalUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const mirror = useObservableEagerState(vm.mirror);
|
||||
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
||||
});
|
||||
|
||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||
|
||||
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps = {
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
};
|
||||
|
||||
return vm instanceof LocalUserMediaViewModel ? (
|
||||
<SpotlightLocalUserMediaItem ref={ref} vm={vm} {...baseProps} />
|
||||
) : (
|
||||
<MediaView mirror={false} {...baseProps} />
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||
|
||||
interface SpotlightItemProps {
|
||||
vm: MediaViewModel;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
intersectionObserver: Observable<IntersectionObserver>;
|
||||
/**
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
}
|
||||
|
||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useDisplayName(vm);
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ourRef.current!;
|
||||
let prevIo: IntersectionObserver | null = null;
|
||||
const subscription = intersectionObserver.subscribe((io) => {
|
||||
prevIo?.unobserve(element);
|
||||
io.observe(element);
|
||||
prevIo = io;
|
||||
});
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
prevIo?.unobserve(element);
|
||||
};
|
||||
}, [intersectionObserver]);
|
||||
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView
|
||||
videoEnabled
|
||||
videoFit="contain"
|
||||
mirror={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SpotlightItem.displayName = "SpotlightItem";
|
||||
|
||||
interface Props {
|
||||
vms: MediaViewModel[];
|
||||
maximised: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (() => void) | null;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
showIndicators: boolean;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
vms,
|
||||
maximised,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
className,
|
||||
style,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [root, ourRef] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const [visibleId, setVisibleId] = useState(vms[0].id);
|
||||
const latestVms = useLatest(vms);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = vms.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1;
|
||||
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
// Observable to actually give them the intersection observer.
|
||||
const intersectionObserver = useInitial<Observable<IntersectionObserver>>(
|
||||
() =>
|
||||
root.pipe(
|
||||
map(
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const vms = latestVms.current;
|
||||
const visibleIndex = vms.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||
|
||||
const onNextClick = useCallback(() => {
|
||||
const vms = latestVms.current;
|
||||
const visibleIndex = vms.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== vms.length - 1)
|
||||
setScrollToId(vms[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.back)}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
{vms.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver={intersectionObserver}
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
aria-label={t("common.next")}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && vms.length > 1,
|
||||
})}
|
||||
>
|
||||
{vms.map((vm) => (
|
||||
<div
|
||||
key={vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SpotlightTile.displayName = "SpotlightTile";
|
||||
@@ -14,20 +14,17 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { RefObject, useCallback, useRef } from "react";
|
||||
import { RefObject, useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import { useEventTarget } from "./useEvents";
|
||||
|
||||
/**
|
||||
* Determines whether focus is in the same part of the tree as the given
|
||||
* element (specifically, if an ancestor or descendant of it is focused).
|
||||
* element (specifically, if the element or an ancestor of it is focused).
|
||||
*/
|
||||
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
||||
const focusedElement = document.activeElement;
|
||||
return (
|
||||
focusedElement !== null &&
|
||||
(focusedElement.contains(e) || e.contains(focusedElement))
|
||||
);
|
||||
return focusedElement !== null && focusedElement.contains(e);
|
||||
};
|
||||
|
||||
export function useCallViewKeyboardShortcuts(
|
||||
@@ -50,12 +47,17 @@ export function useCallViewKeyboardShortcuts(
|
||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
||||
|
||||
if (event.key === "m") {
|
||||
event.preventDefault();
|
||||
toggleMicrophoneMuted();
|
||||
} else if (event.key == "v") {
|
||||
event.preventDefault();
|
||||
toggleLocalVideoMuted();
|
||||
} else if (event.key === " " && !spacebarHeld.current) {
|
||||
spacebarHeld.current = true;
|
||||
setMicrophoneMuted(false);
|
||||
} else if (event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!spacebarHeld.current) {
|
||||
spacebarHeld.current = true;
|
||||
setMicrophoneMuted(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -65,6 +67,10 @@ export function useCallViewKeyboardShortcuts(
|
||||
setMicrophoneMuted,
|
||||
],
|
||||
),
|
||||
// Because this is set on the window, to prevent shortcuts from activating
|
||||
// another event callback at the same time, we need to preventDefault
|
||||
// *before* child elements receive the event by using capture mode
|
||||
useMemo(() => ({ capture: true }), []),
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
|
||||
26
src/useInitial.ts
Normal file
26
src/useInitial.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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 { useRef } from "react";
|
||||
|
||||
/**
|
||||
* React hook that returns the value given on the initial render.
|
||||
*/
|
||||
export function useInitial<T>(getValue: () => T): T {
|
||||
const ref = useRef<{ value: T }>();
|
||||
ref.current ??= { value: getValue() };
|
||||
return ref.current.value;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
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.
|
||||
@@ -14,16 +14,18 @@ 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";
|
||||
import { RefObject, useRef } from "react";
|
||||
|
||||
// https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
window.OLM_OPTIONS = {};
|
||||
|
||||
let olmLoaded: Promise<void> | null = null;
|
||||
export interface LatestRef<T> extends RefObject<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Olm, if not already loaded.
|
||||
* React hook that returns a ref containing the value given on the latest
|
||||
* render.
|
||||
*/
|
||||
export const loadOlm = (): Promise<void> =>
|
||||
(olmLoaded ??= Olm.init({ locateFile: () => olmWasmPath }));
|
||||
export function useLatest<T>(value: T): LatestRef<T> {
|
||||
const ref = useRef<T>(value);
|
||||
ref.current = value;
|
||||
return ref;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
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.
|
||||
@@ -44,7 +44,8 @@ export const useReactiveState = <T>(
|
||||
if (
|
||||
prevDeps.current === undefined ||
|
||||
deps.length !== prevDeps.current.length ||
|
||||
deps.some((d, i) => d !== prevDeps.current![i])
|
||||
// Deps might be NaN, so we compare with Object.is rather than ===
|
||||
deps.some((d, i) => !Object.is(d, prevDeps.current![i]))
|
||||
) {
|
||||
state.current = updateFn(state.current);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,195 +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 { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import type { RectReadOnly } from "react-use-measure";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { TileDescriptor } from "../state/CallViewModel";
|
||||
|
||||
/**
|
||||
* A video grid layout system with concrete states of type State.
|
||||
*/
|
||||
// Ideally State would be parameterized by the tile data type, but then that
|
||||
// makes Layout a higher-kinded type, which isn't achievable in TypeScript
|
||||
// (unless you invoke some dark type-level computation magic… 😏)
|
||||
// So we're stuck with these types being a little too strong.
|
||||
export interface Layout<State> {
|
||||
/**
|
||||
* The layout state for zero tiles.
|
||||
*/
|
||||
readonly emptyState: State;
|
||||
/**
|
||||
* Updates/adds/removes tiles in a way that looks natural in the context of
|
||||
* the given initial state.
|
||||
*/
|
||||
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
|
||||
/**
|
||||
* Adapts the layout to a new container size.
|
||||
*/
|
||||
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
|
||||
/**
|
||||
* Gets tiles in the order created by the layout.
|
||||
*/
|
||||
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
|
||||
/**
|
||||
* Determines whether a tile is draggable.
|
||||
*/
|
||||
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
|
||||
/**
|
||||
* Drags the tile 'from' to the location of the tile 'to' (if possible).
|
||||
* The position parameters are numbers in the range [0, 1) describing the
|
||||
* specific positions on 'from' and 'to' that the drag gesture is targeting.
|
||||
*/
|
||||
readonly dragTile: <T>(
|
||||
s: State,
|
||||
from: TileDescriptor<T>,
|
||||
to: TileDescriptor<T>,
|
||||
xPositionOnFrom: number,
|
||||
yPositionOnFrom: number,
|
||||
xPositionOnTo: number,
|
||||
yPositionOnTo: number,
|
||||
) => State;
|
||||
/**
|
||||
* Toggles the focus of the given tile (if this layout has the concept of
|
||||
* focus).
|
||||
*/
|
||||
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
|
||||
/**
|
||||
* A React component generating the slot elements for a given layout state.
|
||||
*/
|
||||
readonly Slots: ComponentType<{ s: State }>;
|
||||
/**
|
||||
* Whether the state of this layout should be remembered even while a
|
||||
* different layout is active.
|
||||
*/
|
||||
readonly rememberState: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A version of Map with stronger types that allow us to save layout states in a
|
||||
* type-safe way.
|
||||
*/
|
||||
export interface LayoutStatesMap {
|
||||
get<State>(layout: Layout<State>): State | undefined;
|
||||
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
|
||||
delete<State>(layout: Layout<State>): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook creating a Map to store layout states in.
|
||||
*/
|
||||
export const useLayoutStates = (): LayoutStatesMap => {
|
||||
const layoutStates = useRef<Map<unknown, unknown>>();
|
||||
if (layoutStates.current === undefined) layoutStates.current = new Map();
|
||||
return layoutStates.current as LayoutStatesMap;
|
||||
};
|
||||
|
||||
interface UseLayout<State, T> {
|
||||
state: State;
|
||||
orderedItems: TileDescriptor<T>[];
|
||||
generation: number;
|
||||
canDragTile: (tile: TileDescriptor<T>) => boolean;
|
||||
dragTile: (
|
||||
from: TileDescriptor<T>,
|
||||
to: TileDescriptor<T>,
|
||||
xPositionOnFrom: number,
|
||||
yPositionOnFrom: number,
|
||||
xPositionOnTo: number,
|
||||
yPositionOnTo: number,
|
||||
) => void;
|
||||
toggleFocus: ((tile: TileDescriptor<T>) => void) | undefined;
|
||||
slots: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook which uses the provided layout system to arrange a set of items into a
|
||||
* concrete layout state, and provides callbacks for user interaction.
|
||||
*/
|
||||
export function useLayout<State, T>(
|
||||
layout: Layout<State>,
|
||||
items: TileDescriptor<T>[],
|
||||
bounds: RectReadOnly,
|
||||
layoutStates: LayoutStatesMap,
|
||||
): UseLayout<State, T> {
|
||||
const prevLayout = useRef<Layout<unknown>>();
|
||||
const prevState = layoutStates.get(layout);
|
||||
|
||||
const [state, setState] = useReactiveState<State>(() => {
|
||||
// If the bounds aren't known yet, don't add anything to the layout
|
||||
if (bounds.width === 0) {
|
||||
return layout.emptyState;
|
||||
} else {
|
||||
if (
|
||||
prevLayout.current !== undefined &&
|
||||
layout !== prevLayout.current &&
|
||||
!prevLayout.current.rememberState
|
||||
)
|
||||
layoutStates.delete(prevLayout.current);
|
||||
|
||||
const baseState = layoutStates.get(layout) ?? layout.emptyState;
|
||||
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
|
||||
}
|
||||
}, [layout, items, bounds]);
|
||||
|
||||
const generation = useRef<number>(0);
|
||||
if (state !== prevState) generation.current++;
|
||||
|
||||
prevLayout.current = layout as Layout<unknown>;
|
||||
// No point in remembering an empty state, plus it would end up clobbering the
|
||||
// real saved state while restoring a layout
|
||||
if (state !== layout.emptyState) layoutStates.set(layout, state);
|
||||
|
||||
return {
|
||||
state,
|
||||
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
|
||||
generation: generation.current,
|
||||
canDragTile: useCallback(
|
||||
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
|
||||
[layout, state],
|
||||
),
|
||||
dragTile: useCallback(
|
||||
(
|
||||
from: TileDescriptor<T>,
|
||||
to: TileDescriptor<T>,
|
||||
xPositionOnFrom: number,
|
||||
yPositionOnFrom: number,
|
||||
xPositionOnTo: number,
|
||||
yPositionOnTo: number,
|
||||
) =>
|
||||
setState((s) =>
|
||||
layout.dragTile(
|
||||
s,
|
||||
from,
|
||||
to,
|
||||
xPositionOnFrom,
|
||||
yPositionOnFrom,
|
||||
xPositionOnTo,
|
||||
yPositionOnTo,
|
||||
),
|
||||
),
|
||||
[layout, setState],
|
||||
),
|
||||
toggleFocus: useMemo(
|
||||
() =>
|
||||
layout.toggleFocus &&
|
||||
((tile: TileDescriptor<T>): void =>
|
||||
setState((s) => layout.toggleFocus!(s, tile))),
|
||||
[layout, setState],
|
||||
),
|
||||
slots: <layout.Slots s={state} />,
|
||||
};
|
||||
}
|
||||
@@ -1,389 +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 { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||
import {
|
||||
CSSProperties,
|
||||
FC,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { zip } from "lodash";
|
||||
|
||||
import styles from "./NewVideoGrid.module.css";
|
||||
import {
|
||||
VideoGridProps as Props,
|
||||
TileSpring,
|
||||
ChildrenProperties,
|
||||
TileSpringUpdate,
|
||||
} from "./VideoGrid";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { BigGrid } from "./BigGrid";
|
||||
import { useLayout } from "./Layout";
|
||||
import { TileDescriptor } from "../state/CallViewModel";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Tile<T> extends Rect {
|
||||
item: TileDescriptor<T>;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
tileId: string;
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
interface TapData {
|
||||
tileId: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Slot: FC<SlotProps> = ({ style }) => (
|
||||
<div className={styles.slot} style={style} />
|
||||
);
|
||||
|
||||
/**
|
||||
* An interactive, animated grid of video tiles.
|
||||
*/
|
||||
export function NewVideoGrid<T>({
|
||||
items,
|
||||
disableAnimations,
|
||||
layoutStates,
|
||||
children,
|
||||
}: Props<T>): ReactNode {
|
||||
// Overview: This component lays out tiles by rendering an invisible template
|
||||
// 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 template.
|
||||
|
||||
// To know when the rendered grid becomes consistent with the layout we've
|
||||
// requested, we give it a data-generation attribute which holds the ID of the
|
||||
// most recently rendered generation of the grid, and watch it with a
|
||||
// MutationObserver.
|
||||
|
||||
const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
|
||||
const [renderedGeneration, setRenderedGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (slotsRoot !== null) {
|
||||
setRenderedGeneration(
|
||||
parseInt(slotsRoot.getAttribute("data-generation")!),
|
||||
);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((m) => m.type === "attributes")) {
|
||||
setRenderedGeneration(
|
||||
parseInt(slotsRoot.getAttribute("data-generation")!),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(slotsRoot, { attributes: true });
|
||||
return (): void => observer.disconnect();
|
||||
}
|
||||
}, [slotsRoot, setRenderedGeneration]);
|
||||
|
||||
const [gridRef1, gridBounds] = useMeasure();
|
||||
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
||||
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
||||
|
||||
const slotRects = useMemo(() => {
|
||||
if (slotsRoot === null) return [];
|
||||
|
||||
const slots = slotsRoot.getElementsByClassName(styles.slot);
|
||||
const rects = new Array<Rect>(slots.length);
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i] as HTMLElement;
|
||||
rects[i] = {
|
||||
x: slot.offsetLeft,
|
||||
y: slot.offsetTop,
|
||||
width: slot.offsetWidth,
|
||||
height: slot.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return rects;
|
||||
// The rects may change due to the grid being resized or rerendered, but
|
||||
// eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotsRoot, renderedGeneration, gridBounds]);
|
||||
|
||||
// TODO: Implement more layouts and select the right one here
|
||||
const layout = BigGrid;
|
||||
const {
|
||||
state: grid,
|
||||
orderedItems,
|
||||
generation,
|
||||
canDragTile,
|
||||
dragTile,
|
||||
toggleFocus,
|
||||
slots,
|
||||
} = useLayout(layout, items, gridBounds, layoutStates);
|
||||
|
||||
const [tiles] = useReactiveState<Tile<T>[]>(
|
||||
(prevTiles) => {
|
||||
// If React hasn't yet rendered the current generation of the grid, skip
|
||||
// the update, because grid and slotRects will be out of sync
|
||||
if (renderedGeneration !== generation) return prevTiles ?? [];
|
||||
|
||||
const tileRects = new Map(
|
||||
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][],
|
||||
);
|
||||
// In order to not break drag gestures, it's critical that we render tiles
|
||||
// in a stable order (that of 'items')
|
||||
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
||||
},
|
||||
[slotRects, grid, renderedGeneration],
|
||||
);
|
||||
|
||||
// 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(
|
||||
tiles,
|
||||
() => ({
|
||||
key: ({ item }: Tile<T>): string => item.id,
|
||||
from: ({ x, y, width, height }: Tile<T>): TileSpringUpdate => ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
shadow: 0,
|
||||
shadowSpread: 0,
|
||||
zIndex: 1,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: disableAnimations,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
|
||||
update: ({
|
||||
item,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}: Tile<T>): TileSpringUpdate | null =>
|
||||
item.id === dragState.current?.tileId
|
||||
? null
|
||||
: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: disableAnimations,
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
|
||||
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<Tile<T>, 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();
|
||||
}, [tiles, springRef]);
|
||||
|
||||
const animateDraggedTile = (endOfGesture: boolean): void => {
|
||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
||||
|
||||
springRef.current
|
||||
.find((c) => (c.item as Tile<T>).item.id === tileId)
|
||||
?.start(
|
||||
endOfGesture
|
||||
? {
|
||||
scale: 1,
|
||||
zIndex: 1,
|
||||
shadow: 0,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate:
|
||||
disableAnimations || ((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,
|
||||
shadow: 15,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
disableAnimations ||
|
||||
((key): boolean =>
|
||||
key === "zIndex" || key === "x" || key === "y"),
|
||||
},
|
||||
);
|
||||
|
||||
const overTile = tiles.find(
|
||||
(t) =>
|
||||
cursorX >= t.x &&
|
||||
cursorX < t.x + t.width &&
|
||||
cursorY >= t.y &&
|
||||
cursorY < t.y + t.height,
|
||||
);
|
||||
|
||||
if (overTile !== undefined)
|
||||
dragTile(
|
||||
tile.item,
|
||||
overTile.item,
|
||||
(cursorX - tileX) / tile.width,
|
||||
(cursorY - tileY) / tile.height,
|
||||
(cursorX - overTile.x) / overTile.width,
|
||||
(cursorY - overTile.y) / overTile.height,
|
||||
);
|
||||
};
|
||||
|
||||
const lastTap = useRef<TapData | null>(null);
|
||||
|
||||
// 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 now = Date.now();
|
||||
|
||||
if (
|
||||
tileId === lastTap.current?.tileId &&
|
||||
now - lastTap.current.ts < 500
|
||||
) {
|
||||
toggleFocus?.(items.find((i) => i.id === tileId)!);
|
||||
lastTap.current = null;
|
||||
} else {
|
||||
lastTap.current = { tileId, ts: now };
|
||||
}
|
||||
} else {
|
||||
const tileController = springRef.current.find(
|
||||
(c) => (c.item as Tile<T>).item.id === tileId,
|
||||
)!;
|
||||
|
||||
if (canDragTile((tileController.item as Tile<T>).item)) {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
{ target: gridRef2 },
|
||||
);
|
||||
|
||||
// Render nothing if the grid has yet to be generated
|
||||
if (grid === null) {
|
||||
return <div ref={gridRef} className={styles.grid} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={gridRef} className={styles.grid}>
|
||||
<div
|
||||
ref={setSlotsRoot}
|
||||
className={styles.slots}
|
||||
data-generation={generation}
|
||||
>
|
||||
{slots}
|
||||
</div>
|
||||
{tileTransitions((spring, tile) => (
|
||||
<TileWrapper
|
||||
key={tile.item.id}
|
||||
id={tile.item.id}
|
||||
onDragRef={onTileDragRef}
|
||||
targetWidth={tile.width}
|
||||
targetHeight={tile.height}
|
||||
data={tile.item.data}
|
||||
{...spring}
|
||||
>
|
||||
{children as (props: ChildrenProperties<T>) => ReactNode}
|
||||
</TileWrapper>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,494 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
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 {
|
||||
ComponentProps,
|
||||
ForwardedRef,
|
||||
ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TrackReferenceOrPlaceholder,
|
||||
VideoTrack,
|
||||
} from "@livekit/components-react";
|
||||
import {
|
||||
RoomMember,
|
||||
RoomMemberEvent,
|
||||
} from "matrix-js-sdk/src/models/room-member";
|
||||
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 ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
|
||||
import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?react";
|
||||
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
|
||||
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
|
||||
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
|
||||
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
|
||||
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
|
||||
import {
|
||||
Text,
|
||||
Tooltip,
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
Menu,
|
||||
} from "@vector-im/compound-web";
|
||||
import { useStateObservable } from "@react-rxjs/core";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import {
|
||||
ScreenShareViewModel,
|
||||
MediaViewModel,
|
||||
UserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { Slider } from "../Slider";
|
||||
|
||||
interface TileProps {
|
||||
tileRef?: ForwardedRef<HTMLDivElement>;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder;
|
||||
member: RoomMember | undefined;
|
||||
videoEnabled: boolean;
|
||||
maximised: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
nameTag: string;
|
||||
displayName: string;
|
||||
primaryButton: ReactNode;
|
||||
secondaryButton?: ReactNode;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
const Tile = forwardRef<HTMLDivElement, TileProps>(
|
||||
(
|
||||
{
|
||||
tileRef = null,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member,
|
||||
videoEnabled,
|
||||
maximised,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
nameTag,
|
||||
displayName,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const mergedRef = useMergedRefs(tileRef, ref);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.videoTile, className, {
|
||||
[styles.maximised]: maximised,
|
||||
[styles.videoMuted]: !videoEnabled,
|
||||
})}
|
||||
style={style}
|
||||
ref={mergedRef}
|
||||
data-testid="videoTile"
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
{video.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{nameTag}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
side="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
<ErrorIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={t("common.unencrypted")}
|
||||
className={styles.errorIcon}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{primaryButton}
|
||||
{secondaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Tile.displayName = "Tile";
|
||||
|
||||
interface UserMediaTileProps {
|
||||
vm: UserMediaViewModel;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
nameTag: string;
|
||||
displayName: string;
|
||||
maximised: boolean;
|
||||
onOpenProfile: () => void;
|
||||
showSpeakingIndicator: boolean;
|
||||
}
|
||||
|
||||
const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
nameTag,
|
||||
displayName,
|
||||
maximised,
|
||||
onOpenProfile,
|
||||
showSpeakingIndicator,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const video = useStateObservable(vm.video);
|
||||
const audioEnabled = useStateObservable(vm.audioEnabled);
|
||||
const videoEnabled = useStateObservable(vm.videoEnabled);
|
||||
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
|
||||
const mirror = useStateObservable(vm.mirror);
|
||||
const speaking = useStateObservable(vm.speaking);
|
||||
const locallyMuted = useStateObservable(vm.locallyMuted);
|
||||
const cropVideo = useStateObservable(vm.cropVideo);
|
||||
const localVolume = useStateObservable(vm.localVolume);
|
||||
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
|
||||
const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]);
|
||||
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[],
|
||||
);
|
||||
|
||||
const onChangeLocalVolume = useCallback(
|
||||
(v: number) => vm.setLocalVolume(v),
|
||||
[vm],
|
||||
);
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = vm.local ? (
|
||||
<>
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onChange={onChangeFitContain}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleMenuItem
|
||||
Icon={MicOffIcon}
|
||||
label={t("video_tile.mute_for_me")}
|
||||
checked={locallyMuted}
|
||||
onChange={onChangeMute}
|
||||
onSelect={onSelectMute}
|
||||
/>
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onChange={onChangeFitContain}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
||||
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={t("video_tile.volume")}
|
||||
value={localVolume}
|
||||
onValueChange={onChangeLocalVolume}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={locallyMuted}
|
||||
/>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const tile = (
|
||||
<Tile
|
||||
tileRef={ref}
|
||||
className={classNames(className, {
|
||||
[styles.mirror]: mirror,
|
||||
[styles.speaking]: showSpeakingIndicator && speaking,
|
||||
[styles.cropVideo]: cropVideo,
|
||||
})}
|
||||
style={style}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
videoEnabled={videoEnabled}
|
||||
maximised={maximised}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
nameTagLeadingIcon={
|
||||
<MicIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
||||
data-muted={!audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
nameTag={nameTag}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={nameTag}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={nameTag} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserMediaTile.displayName = "UserMediaTile";
|
||||
|
||||
interface ScreenShareTileProps {
|
||||
vm: ScreenShareViewModel;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
nameTag: string;
|
||||
displayName: string;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
nameTag,
|
||||
displayName,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const video = useStateObservable(vm.video);
|
||||
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
|
||||
const onClickFullScreen = useCallback(
|
||||
() => onToggleFullscreen(vm.id),
|
||||
[onToggleFullscreen, vm],
|
||||
);
|
||||
|
||||
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
<Tile
|
||||
ref={ref}
|
||||
className={classNames(className, styles.screenshare)}
|
||||
style={style}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
videoEnabled={true}
|
||||
maximised={maximised}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
nameTag={nameTag}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
!vm.local && (
|
||||
<button
|
||||
aria-label={
|
||||
fullscreen
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
}
|
||||
onClick={onClickFullScreen}
|
||||
>
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScreenShareTile.displayName = "ScreenShareTile";
|
||||
|
||||
interface Props {
|
||||
vm: MediaViewModel;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
onOpenProfile: () => void;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
showSpeakingIndicator: boolean;
|
||||
}
|
||||
|
||||
export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
onOpenProfile,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showSpeakingIndicator,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle display name changes.
|
||||
// TODO: Move this into the view model
|
||||
const [displayName, setDisplayName] = useReactiveState(
|
||||
() => vm.member?.rawDisplayName ?? "[👻]",
|
||||
[vm.member],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (vm.member) {
|
||||
const updateName = (): void => {
|
||||
setDisplayName(vm.member!.rawDisplayName);
|
||||
};
|
||||
|
||||
vm.member!.on(RoomMemberEvent.Name, updateName);
|
||||
return (): void => {
|
||||
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
|
||||
};
|
||||
}
|
||||
}, [vm.member, setDisplayName]);
|
||||
const nameTag = vm.local
|
||||
? t("video_tile.sfu_participant_local")
|
||||
: displayName;
|
||||
|
||||
if (vm instanceof UserMediaViewModel) {
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={style}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
nameTag={nameTag}
|
||||
displayName={displayName}
|
||||
maximised={maximised}
|
||||
onOpenProfile={onOpenProfile}
|
||||
showSpeakingIndicator={showSpeakingIndicator}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ScreenShareTile
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={style}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
nameTag={nameTag}
|
||||
displayName={displayName}
|
||||
maximised={maximised}
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
VideoTile.displayName = "VideoTile";
|
||||
@@ -46,6 +46,21 @@ export enum ElementWidgetActions {
|
||||
// host -> Element Call telling EC to stop screen sharing, or that
|
||||
// the user cancelled when selecting a source after a ScreenshareRequest
|
||||
ScreenshareStop = "io.element.screenshare_stop",
|
||||
// This can be sent as from or to widget
|
||||
// fromWidget: updates the client about the current device mute state
|
||||
// toWidget: the client requests a specific device mute configuration
|
||||
// The reply will always be the resulting configuration
|
||||
// It is possible to sent an empty configuration to retrieve the current values or
|
||||
// just one of the fields to update that particular value
|
||||
// An undefined field means that EC will keep the mute state as is.
|
||||
// -> this will allow the client to only get the current state
|
||||
//
|
||||
// The data of the widget action request and the response are:
|
||||
// {
|
||||
// audio_enabled?: boolean,
|
||||
// video_enabled?: boolean
|
||||
// }
|
||||
DeviceMute = "io.element.device_mute",
|
||||
}
|
||||
|
||||
export interface JoinCallData {
|
||||
@@ -88,6 +103,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
ElementWidgetActions.ScreenshareStart,
|
||||
ElementWidgetActions.ScreenshareStop,
|
||||
ElementWidgetActions.DeviceMute,
|
||||
].forEach((action) => {
|
||||
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
ev.preventDefault();
|
||||
@@ -118,13 +134,17 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
"org.matrix.rageshake_request",
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
];
|
||||
|
||||
const sendState = [
|
||||
{
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey: userId, // TODO: based on if we use the new format we want the key to be: `_${userId}_${deviceId}`
|
||||
},
|
||||
];
|
||||
userId, // Legacy call membership events
|
||||
`_${userId}_${deviceId}`, // Session membership events
|
||||
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
}));
|
||||
const receiveState = [
|
||||
{ eventType: EventType.RoomCreate },
|
||||
{ eventType: EventType.RoomMember },
|
||||
{ eventType: EventType.RoomEncryption },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
@@ -152,6 +172,8 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
sendDelayedEvents: true,
|
||||
updateDelayedEvents: true,
|
||||
},
|
||||
roomId,
|
||||
{
|
||||
@@ -168,7 +190,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
|
||||
const clientPromise = new Promise<MatrixClient>((resolve) => {
|
||||
(async (): Promise<void> => {
|
||||
// wait for the config file to be ready (we load very early on so it might not
|
||||
// Wait for the config file to be ready (we load very early on so it might not
|
||||
// be otherwise)
|
||||
await Config.init();
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
|
||||
92
test/useCallViewKeyboardShortcuts-test.tsx
Normal file
92
test/useCallViewKeyboardShortcuts-test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { render } from "@testing-library/react";
|
||||
import { FC, useRef } from "react";
|
||||
import { test } from "vitest";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
||||
|
||||
interface TestComponentProps {
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
onButtonClick?: () => void;
|
||||
}
|
||||
|
||||
const TestComponent: FC<TestComponentProps> = ({
|
||||
setMicrophoneMuted,
|
||||
onButtonClick = (): void => {},
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useCallViewKeyboardShortcuts(
|
||||
ref,
|
||||
() => {},
|
||||
() => {},
|
||||
setMicrophoneMuted,
|
||||
);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<Button onClick={onButtonClick}>I'm a button</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
test("spacebar unmutes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let muted = true;
|
||||
render(<TestComponent setMicrophoneMuted={(m) => (muted = m)} />);
|
||||
|
||||
await user.keyboard("[Space>]");
|
||||
expect(muted).toBe(false);
|
||||
await user.keyboard("[/Space]");
|
||||
expect(muted).toBe(true);
|
||||
});
|
||||
|
||||
test("spacebar prioritizes pressing a button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setMuted = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TestComponent setMicrophoneMuted={setMuted} onButtonClick={onClick} />,
|
||||
);
|
||||
|
||||
await user.tab(); // Focus the button
|
||||
await user.keyboard("[Space]");
|
||||
expect(setMuted).not.toBeCalled();
|
||||
expect(onClick).toBeCalled();
|
||||
});
|
||||
|
||||
test("unmuting happens in place of the default action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const defaultPrevented = vi.fn();
|
||||
// In the real application, we mostly just want the spacebar shortcut to avoid
|
||||
// scrolling the page. But to test that here in JSDOM, we need some kind of
|
||||
// container element that can be interactive and receive focus / keydown
|
||||
// events. <video> is kind of a weird choice, but it'll do the job.
|
||||
render(
|
||||
<video
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
|
||||
>
|
||||
<TestComponent setMicrophoneMuted={() => {}} />
|
||||
</video>,
|
||||
);
|
||||
|
||||
await user.tab(); // Focus the <video>
|
||||
await user.keyboard("[Space]");
|
||||
expect(defaultPrevented).toBeCalledWith(true);
|
||||
});
|
||||
@@ -1,493 +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 { TileDescriptor } from "../../src/state/CallViewModel";
|
||||
import {
|
||||
addItems,
|
||||
column,
|
||||
cycleTileSize,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
Grid,
|
||||
SparseGrid,
|
||||
resize,
|
||||
row,
|
||||
moveTile,
|
||||
} from "../../src/video-grid/BigGrid";
|
||||
|
||||
/**
|
||||
* Builds a grid from a string specifying the contents of each cell as a letter.
|
||||
*/
|
||||
function mkGrid(spec: string): Grid {
|
||||
const secondNewline = spec.indexOf("\n", 1);
|
||||
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
||||
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
||||
const areas = new Set(cells);
|
||||
areas.delete(" "); // Space represents an empty cell, not an area
|
||||
const grid: Grid = { columns, cells: new Array(cells.length) };
|
||||
|
||||
for (const area of areas) {
|
||||
const start = cells.indexOf(area);
|
||||
const end = cells.lastIndexOf(area);
|
||||
const rows = row(end, grid) - row(start, grid) + 1;
|
||||
const columns = column(end, grid) - column(start, grid) + 1;
|
||||
|
||||
forEachCellInArea(start, end, grid, (_c, i) => {
|
||||
grid.cells[i] = {
|
||||
item: { id: area } as unknown as TileDescriptor<unknown>,
|
||||
origin: i === start,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a grid into a string showing the contents of each cell as a letter.
|
||||
*/
|
||||
function showGrid(g: Grid): string {
|
||||
let result = "\n";
|
||||
for (let i = 0; i < g.cells.length; i++) {
|
||||
if (i > 0 && i % g.columns == 0) result += "\n";
|
||||
result += g.cells[i]?.item.id ?? " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function testFillGaps(title: string, input: string, output: string): void {
|
||||
test(`fillGaps ${title}`, () => {
|
||||
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testFillGaps(
|
||||
"does nothing on an empty grid",
|
||||
`
|
||||
`,
|
||||
`
|
||||
`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"does nothing if there are no gaps",
|
||||
`
|
||||
ab
|
||||
cd
|
||||
ef`,
|
||||
`
|
||||
ab
|
||||
cd
|
||||
ef`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills a gap",
|
||||
`
|
||||
a b
|
||||
cde
|
||||
f`,
|
||||
`
|
||||
cab
|
||||
fde`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills multiple gaps",
|
||||
`
|
||||
a bc
|
||||
defgh
|
||||
ijkl
|
||||
mno`,
|
||||
`
|
||||
aebch
|
||||
difgl
|
||||
mjnok`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills a big gap with 1×1 tiles",
|
||||
`
|
||||
abcd
|
||||
e f
|
||||
g h
|
||||
ijkl`,
|
||||
`
|
||||
abcd
|
||||
ehkf
|
||||
glji`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills a big gap with a large tile",
|
||||
`
|
||||
|
||||
aa
|
||||
bc`,
|
||||
`
|
||||
aa
|
||||
cb`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"prefers moving around large tiles",
|
||||
`
|
||||
a bc
|
||||
ddde
|
||||
dddf
|
||||
ghij
|
||||
k`,
|
||||
`
|
||||
abce
|
||||
dddf
|
||||
dddj
|
||||
kghi`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"moves through large tiles if necessary",
|
||||
`
|
||||
a bc
|
||||
dddd
|
||||
efgh
|
||||
i`,
|
||||
`
|
||||
afbc
|
||||
dddd
|
||||
iegh`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"keeps a large tile from hanging off the bottom",
|
||||
`
|
||||
abcd
|
||||
efgh
|
||||
|
||||
ii
|
||||
ii`,
|
||||
`
|
||||
abcd
|
||||
iigh
|
||||
iief`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"collapses large tiles trapped at the bottom",
|
||||
`
|
||||
abcd
|
||||
e fg
|
||||
hh
|
||||
hh
|
||||
ii
|
||||
ii`,
|
||||
`
|
||||
abcd
|
||||
hhfg
|
||||
hhie`,
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"gives up on pushing large tiles upwards when not possible",
|
||||
`
|
||||
aa
|
||||
aa
|
||||
bccd
|
||||
eccf
|
||||
ghij`,
|
||||
`
|
||||
aadf
|
||||
aaji
|
||||
bcch
|
||||
eccg`,
|
||||
);
|
||||
|
||||
function testCycleTileSize(
|
||||
title: string,
|
||||
tileId: string,
|
||||
input: string,
|
||||
output: string,
|
||||
): void {
|
||||
test(`cycleTileSize ${title}`, () => {
|
||||
const grid = mkGrid(input);
|
||||
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
|
||||
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile to 2×2 in a 3 column layout",
|
||||
"c",
|
||||
`
|
||||
abc
|
||||
def
|
||||
ghi`,
|
||||
`
|
||||
acc
|
||||
dcc
|
||||
gbe
|
||||
ifh`,
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile to 3×3 in a 4 column layout",
|
||||
"g",
|
||||
`
|
||||
abcd
|
||||
efgh`,
|
||||
`
|
||||
acdh
|
||||
bggg
|
||||
fggg
|
||||
e`,
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"restores a tile to 1×1",
|
||||
"b",
|
||||
`
|
||||
abbc
|
||||
dbbe
|
||||
fghi
|
||||
jk`,
|
||||
`
|
||||
abhc
|
||||
djge
|
||||
fik`,
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile even in a crowded grid",
|
||||
"c",
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf
|
||||
ghi
|
||||
klm`,
|
||||
`
|
||||
abb
|
||||
gbb
|
||||
dde
|
||||
ddf
|
||||
ccm
|
||||
cch
|
||||
lik`,
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"does nothing if the tile has no room to expand",
|
||||
"c",
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf`,
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf`,
|
||||
);
|
||||
|
||||
test("cycleTileSize is its own inverse", () => {
|
||||
const input = `
|
||||
abc
|
||||
def
|
||||
ghi
|
||||
jk`;
|
||||
|
||||
const grid = mkGrid(input);
|
||||
let gridAfter = grid;
|
||||
|
||||
const toggle = (tileId: string): void => {
|
||||
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
|
||||
gridAfter = cycleTileSize(gridAfter, tile);
|
||||
};
|
||||
|
||||
// Toggle a series of tiles
|
||||
toggle("j");
|
||||
toggle("h");
|
||||
toggle("a");
|
||||
// Now do the same thing in reverse
|
||||
toggle("a");
|
||||
toggle("h");
|
||||
toggle("j");
|
||||
|
||||
// The grid should be back to its original state
|
||||
expect(showGrid(gridAfter)).toBe(input);
|
||||
});
|
||||
|
||||
function testAddItems(
|
||||
title: string,
|
||||
items: TileDescriptor<unknown>[],
|
||||
input: string,
|
||||
output: string,
|
||||
): void {
|
||||
test(`addItems ${title}`, () => {
|
||||
expect(showGrid(addItems(items, mkGrid(input) as SparseGrid) as Grid)).toBe(
|
||||
output,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testAddItems(
|
||||
"appends 1×1 tiles",
|
||||
["e", "f"].map((i) => ({ id: i }) as unknown as TileDescriptor<unknown>),
|
||||
`
|
||||
aab
|
||||
aac
|
||||
d`,
|
||||
`
|
||||
aab
|
||||
aac
|
||||
def`,
|
||||
);
|
||||
|
||||
testAddItems(
|
||||
"places one tile near another on request",
|
||||
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor<unknown>],
|
||||
`
|
||||
abc
|
||||
def`,
|
||||
`
|
||||
abc
|
||||
g
|
||||
def`,
|
||||
);
|
||||
|
||||
testAddItems(
|
||||
"places items with a large base size",
|
||||
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor<unknown>],
|
||||
`
|
||||
abc
|
||||
def`,
|
||||
`
|
||||
abc
|
||||
ggf
|
||||
gge
|
||||
d`,
|
||||
);
|
||||
|
||||
function testMoveTile(
|
||||
title: string,
|
||||
from: number,
|
||||
to: number,
|
||||
input: string,
|
||||
output: string,
|
||||
): void {
|
||||
test(`moveTile ${title}`, () => {
|
||||
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testMoveTile(
|
||||
"refuses to move a tile too far to the left",
|
||||
1,
|
||||
-1,
|
||||
`
|
||||
abc`,
|
||||
`
|
||||
abc`,
|
||||
);
|
||||
|
||||
testMoveTile(
|
||||
"refuses to move a tile too far to the right",
|
||||
1,
|
||||
3,
|
||||
`
|
||||
abc`,
|
||||
`
|
||||
abc`,
|
||||
);
|
||||
|
||||
testMoveTile(
|
||||
"moves a large tile to an unoccupied space",
|
||||
3,
|
||||
1,
|
||||
`
|
||||
a b
|
||||
ccd
|
||||
cce`,
|
||||
`
|
||||
acc
|
||||
bcc
|
||||
d e`,
|
||||
);
|
||||
|
||||
testMoveTile(
|
||||
"refuses to move a large tile to an occupied space",
|
||||
3,
|
||||
1,
|
||||
`
|
||||
abb
|
||||
ccd
|
||||
cce`,
|
||||
`
|
||||
abb
|
||||
ccd
|
||||
cce`,
|
||||
);
|
||||
|
||||
function testResize(
|
||||
title: string,
|
||||
columns: number,
|
||||
input: string,
|
||||
output: string,
|
||||
): void {
|
||||
test(`resize ${title}`, () => {
|
||||
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testResize(
|
||||
"contracts the grid",
|
||||
2,
|
||||
`
|
||||
abbb
|
||||
cbbb
|
||||
ddde
|
||||
dddf
|
||||
gh`,
|
||||
`
|
||||
af
|
||||
bb
|
||||
bb
|
||||
dd
|
||||
dd
|
||||
ch
|
||||
eg`,
|
||||
);
|
||||
|
||||
testResize(
|
||||
"expands the grid",
|
||||
4,
|
||||
`
|
||||
af
|
||||
bb
|
||||
bb
|
||||
ch
|
||||
dd
|
||||
dd
|
||||
eg`,
|
||||
`
|
||||
afcd
|
||||
bbbg
|
||||
bbbe
|
||||
h`,
|
||||
);
|
||||
@@ -1,69 +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 { TileDescriptor } from "../../src/state/CallViewModel";
|
||||
import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid";
|
||||
|
||||
const alice: Tile<unknown> = {
|
||||
key: "alice",
|
||||
order: 0,
|
||||
item: { local: false } as unknown as TileDescriptor<unknown>,
|
||||
remove: false,
|
||||
focused: false,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
};
|
||||
const bob: Tile<unknown> = {
|
||||
key: "bob",
|
||||
order: 1,
|
||||
item: { local: false } as unknown as TileDescriptor<unknown>,
|
||||
remove: false,
|
||||
focused: false,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: false,
|
||||
};
|
||||
|
||||
test("reorderTiles does not promote a non-speaker", () => {
|
||||
const tiles = [{ ...alice }, { ...bob }];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 0 }),
|
||||
expect.objectContaining({ key: "bob", order: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reorderTiles promotes a speaker into the visible area", () => {
|
||||
const tiles = [{ ...alice }, { ...bob, isSpeaker: true }];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 1 }),
|
||||
expect.objectContaining({ key: "bob", order: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reorderTiles keeps a promoted speaker in the visible area", () => {
|
||||
const tiles = [
|
||||
{ ...alice, order: 1 },
|
||||
{ ...bob, isSpeaker: true, order: 0 },
|
||||
];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 1 }),
|
||||
expect.objectContaining({ key: "bob", order: 0 }),
|
||||
]);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"target": "es2022",
|
||||
"module": "es2020",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["es2020", "dom", "dom.iterable"],
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
|
||||
// From Matrix-JS-SDK
|
||||
"strict": true,
|
||||
@@ -15,6 +15,8 @@
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true,
|
||||
// Workaround for https://github.com/microsoft/TypeScript/issues/55132
|
||||
"useDefineForClassFields": false,
|
||||
"paths": {
|
||||
// These imports within @livekit/components-core and
|
||||
// @livekit/components-react are broken under the "bundler" module
|
||||
|
||||
Reference in New Issue
Block a user