Compare commits

...

112 Commits

Author SHA1 Message Date
Robin
169ccd9de5 Start refactoring some business logic into view models
As Element Call grows in complexity, it has become a pain point that our business logic remains so tightly coupled to the UI code. In particular, this has made testing difficult, and the complex semantics of React hooks are not a great match for arbitrary business logic. Here, I show the beginnings of what it would look like for us to adopt the MVVM pattern. I've created a CallViewModel and TileViewModel that expose their state to the UI as rxjs Observables, as well as a couple of helper functions for consuming view models in React code.

This should contain no user-visible changes, but we need to watch out for regressions particularly around focus switching and promotion of speakers, because this was the logic I chose to refactor first.
2023-12-01 17:09:21 -05:00
Robin
445c7c4e0c Merge pull request #1964 from vector-im/renovate/sentry-javascript-monorepo
Update sentry-javascript monorepo to v7.84.0
2023-12-01 17:06:00 -05:00
Robin
b9f946a71a Merge pull request #1974 from vector-im/renovate/eslint-8.x-lockfile
Update dependency eslint to v8.55.0
2023-12-01 17:00:02 -05:00
Robin
74a8495750 Merge pull request #1965 from vector-im/renovate/typescript-eslint-monorepo
Update typescript-eslint monorepo to v6.13.1
2023-12-01 16:58:05 -05:00
Robin
be56ade561 Merge pull request #1966 from vector-im/renovate/actions-setup-node-4.x
Update actions/setup-node action to v4
2023-12-01 16:57:30 -05:00
renovate[bot]
607c44ba5e Update sentry-javascript monorepo to v7.84.0 2023-12-01 21:56:31 +00:00
renovate[bot]
dec92a479d Update dependency eslint to v8.55.0 2023-12-01 21:56:16 +00:00
Robin
195ef859ee Merge pull request #1973 from vector-im/renovate/node-20.x-lockfile
Update dependency @types/node to v20.10.2
2023-12-01 16:56:16 -05:00
Robin
63d905ed63 Merge pull request #1967 from vector-im/renovate/vite-5.x
Update dependency vite to v5
2023-12-01 16:55:47 -05:00
Robin
9226b87c88 Merge pull request #1970 from robintown/firefox-visual-glitches
Fix a couple of visual glitches on Firefox
2023-12-01 16:52:00 -05:00
fkwp
f3daeeffc2 Merge pull request #1971 from vector-im/renovate/docker-build-push-action-digest
Update docker/build-push-action digest to 9f6f8c9
2023-12-01 22:22:06 +01:00
renovate[bot]
c7d582983f Update dependency @types/node to v20.10.2 2023-12-01 21:21:48 +00:00
fkwp
c661c916ed Merge pull request #1972 from vector-im/renovate/livekit-client-1.x-lockfile
Update dependency livekit-client to v1.15.3
2023-12-01 22:21:22 +01:00
renovate[bot]
14d9802db8 Update dependency livekit-client to v1.15.3 2023-12-01 18:44:54 +00:00
renovate[bot]
80a7f81760 Update docker/build-push-action digest to 9f6f8c9 2023-12-01 18:44:40 +00:00
Robin
a2e932b73f Fix a couple of visual glitches on Firefox 2023-12-01 12:37:46 -05:00
renovate[bot]
48c4df9cd6 Update docker/setup-buildx-action digest to edfb0fe (#1968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 15:03:29 +01:00
Robin
1d6eaa0e53 Merge pull request #1932 from robintown/backend-corrections
Make some corrections to the development guide
2023-12-01 08:16:22 -05:00
renovate[bot]
d77ad87c45 Update dependency vite to v5 2023-12-01 09:52:51 +00:00
renovate[bot]
ddcec5497d Update actions/setup-node action to v4 2023-12-01 09:52:38 +00:00
renovate[bot]
74ea1c88bb Update typescript-eslint monorepo to v6.13.1 2023-12-01 06:19:44 +00:00
Robin
5c3deeb063 Merge pull request #1953 from vector-im/renovate/i18next-23.x-lockfile
Update dependency i18next to v23.7.7
2023-11-30 23:51:06 -05:00
Robin
9c89f85634 Merge pull request #1956 from vector-im/renovate/docker-metadata-action-digest
Update docker/metadata-action digest to e6428a5
2023-11-30 23:49:34 -05:00
Robin
27cb39d3c5 Merge pull request #1958 from vector-im/renovate/testing-library-jest-dom-6.x-lockfile
Update dependency @testing-library/jest-dom to v6.1.5
2023-11-30 23:48:59 -05:00
Robin
7b4e3ddbc0 Merge pull request #1962 from vector-im/renovate/typescript-5.x-lockfile
Update dependency typescript to v5.3.2
2023-11-30 23:48:39 -05:00
Robin
8c8430c67d Merge pull request #1963 from vector-im/renovate/vite-plugin-svgr-4.x-lockfile
Update dependency vite-plugin-svgr to v4.2.0
2023-11-30 23:48:20 -05:00
Robin
97488a4787 Fix types 2023-11-30 23:47:26 -05:00
Robin
b05b57e744 Merge pull request #1959 from vector-im/renovate/prettier-3.x-lockfile
Update dependency prettier to v3.1.0
2023-11-30 23:44:18 -05:00
renovate[bot]
e813df8dcd Update dependency vite-plugin-svgr to v4.2.0 2023-12-01 04:41:56 +00:00
Robin
2795bc3a94 Merge pull request #1957 from vector-im/renovate/posthog-js-1.x-lockfile
Update dependency posthog-js to v1.93.3
2023-11-30 23:41:27 -05:00
Robin
c433069f90 Format code 2023-11-30 23:40:33 -05:00
renovate[bot]
ab07d657c6 Update dependency typescript to v5.3.2 2023-12-01 04:40:00 +00:00
renovate[bot]
87a215102a Update dependency i18next to v23.7.7 2023-12-01 04:39:47 +00:00
renovate[bot]
1324ff5a69 Update dependency @testing-library/jest-dom to v6.1.5 2023-12-01 04:39:23 +00:00
Robin
64f2bd3315 Merge pull request #1960 from vector-im/renovate/react-i18next-13.x-lockfile
Update dependency react-i18next to v13.5.0
2023-11-30 23:38:55 -05:00
renovate[bot]
cfdb9b4638 Update dependency react-i18next to v13.5.0 2023-12-01 01:03:58 +00:00
renovate[bot]
7730b9aebc Update dependency prettier to v3.1.0 2023-11-30 22:00:31 +00:00
renovate[bot]
eb45149f16 Update dependency posthog-js to v1.93.3 2023-11-30 19:43:24 +00:00
renovate[bot]
a6373c23f3 Update docker/metadata-action digest to e6428a5 2023-11-30 19:43:10 +00:00
Timo
3083fff480 Fix submit label (#1951)
Signed-off-by: Timo K <toger5@hotmail.de>
2023-11-30 17:31:12 +01:00
Robin
53732b99bf Merge pull request #1955 from vector-im/renovate/i18next-http-backend-2.x-lockfile
Update dependency i18next-http-backend to v2.4.2
2023-11-30 11:14:32 -05:00
Robin
3a2dba7a13 Merge pull request #1954 from vector-im/renovate/i18next-browser-languagedetector-7.x-lockfile
Update dependency i18next-browser-languagedetector to v7.2.0
2023-11-30 11:14:13 -05:00
Robin
d7dbc3d096 Merge pull request #1952 from vector-im/renovate/docker-metadata-action-digest
Update docker/metadata-action digest to 26b4721
2023-11-30 11:12:19 -05:00
renovate[bot]
0ce408899c Update dependency i18next-http-backend to v2.4.2 2023-11-30 16:10:42 +00:00
Robin
36056b17e1 Merge pull request #1950 from vector-im/renovate/eslint-8.x-lockfile
Update dependency eslint to v8.54.0
2023-11-30 11:10:39 -05:00
renovate[bot]
7697a14a65 Update dependency i18next-browser-languagedetector to v7.2.0 2023-11-30 16:10:29 +00:00
Robin
1f4c599a36 Merge pull request #1949 from vector-im/renovate/sentry-vite-plugin-2.x-lockfile
Update dependency @sentry/vite-plugin to v2.10.2
2023-11-30 11:10:00 -05:00
renovate[bot]
d232eece5a Update docker/metadata-action digest to 26b4721 2023-11-30 15:31:07 +00:00
renovate[bot]
72a23c5d1a Update dependency eslint to v8.54.0 2023-11-30 08:16:34 +00:00
renovate[bot]
2db2aa9947 Update dependency @sentry/vite-plugin to v2.10.2 2023-11-30 03:59:13 +00:00
Robin
54302ca6c6 Merge pull request #1930 from vector-im/renovate/opentelemetry-instrumentation-user-interaction-0.x
Update dependency @opentelemetry/instrumentation-user-interaction to ^0.34.0
2023-11-29 18:12:20 -05:00
Robin
37ee555e0a Merge pull request #1939 from vector-im/renovate/node-20.x-lockfile
Update dependency @types/node to v20.10.1
2023-11-29 16:43:00 -05:00
renovate[bot]
1790818f3a Update dependency @opentelemetry/instrumentation-user-interaction to ^0.34.0 2023-11-29 21:42:20 +00:00
Robin
c821cadac2 Merge pull request #1929 from vector-im/renovate/opentelemetry-instrumentation-document-load-0.x
Update dependency @opentelemetry/instrumentation-document-load to ^0.34.0
2023-11-29 16:41:55 -05:00
renovate[bot]
d0ec052641 Update dependency @types/node to v20.10.1 2023-11-29 21:39:25 +00:00
Robin
bbe1043289 Fix the settings label (#1936)
We were displaying the raw translation key rather than the translated text.
2023-11-29 19:01:45 +01:00
Robin
22ac156085 Merge pull request #1887 from vector-im/renovate/babel-monorepo
Update babel monorepo
2023-11-29 12:55:54 -05:00
Robin
544c50743e Merge pull request #1934 from vector-im/renovate/livekit-components-react-1.x-lockfile
Update dependency @livekit/components-react to v1.4.2
2023-11-29 12:54:15 -05:00
renovate[bot]
8ab623b9e2 Update babel monorepo 2023-11-29 17:51:59 +00:00
renovate[bot]
468dffcf98 Update dependency @livekit/components-react to v1.4.2 2023-11-29 17:50:39 +00:00
Robin
bd5b547483 Merge pull request #1926 from vector-im/renovate/jest-monorepo
Update dependency @types/jest to v29.5.10
2023-11-29 12:50:09 -05:00
Robin
3b2e0acb99 Merge pull request #1883 from vector-im/renovate/testing-library-react-14.x-lockfile
Update dependency @testing-library/react to v14.1.2
2023-11-29 12:50:02 -05:00
Robin
a631f83a7f Merge pull request #1875 from vector-im/renovate/sdp-transform-2.x-lockfile
Update dependency @types/sdp-transform to v2.4.9
2023-11-29 12:49:55 -05:00
Robin
c40ee6b8cc Merge pull request #1933 from robintown/invite-modal-overflow
Fix the invite modal overflowing
2023-11-29 11:47:44 -05:00
Robin
e017468f4d Fix the invite modal overflowing
If the URL was the wrong shape, it could cause the modal to overflow, so it needs an extra nudge to line break in the right places.
2023-11-29 11:27:44 -05:00
Robin
57ed732ba8 Fix type errors 2023-11-29 10:49:17 -05:00
Robin
50d792c6c9 Make some corrections to the development guide 2023-11-29 10:42:54 -05:00
ElementRobot
55acbee9dc Merge pull request #1931 from vector-im/actions/localazy-download
Localazy Download
2023-11-29 15:37:26 +00:00
robintown
6af52a32fd Translations updates 2023-11-29 15:36:15 +00:00
renovate[bot]
a0cdafcd7c Update dependency @types/jest to v29.5.10 2023-11-29 13:06:10 +00:00
renovate[bot]
00487324b1 Update dependency @vitejs/plugin-basic-ssl to v1.0.2 (#1928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 14:05:26 +01:00
renovate[bot]
2d8aaf0a3b Update dependency @opentelemetry/instrumentation-document-load to ^0.34.0 2023-11-29 12:06:25 +00:00
renovate[bot]
48900f3921 Update dependency @types/lodash to v4.14.202 (#1927)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 13:05:17 +01:00
fkwp
e35cae9430 Merge pull request #1916 from vector-im/renovate/docker-build-push-action-digest
Update docker/build-push-action digest to 4a13e50
2023-11-29 09:14:18 +01:00
fkwp
dbfe7f36c5 Merge pull request #1917 from vector-im/renovate/docker-login-action-digest
Update docker/login-action digest to 3d58c27
2023-11-29 09:13:55 +01:00
fkwp
16498e4548 Merge pull request #1919 from vector-im/renovate/docker-metadata-action-digest
Update docker/metadata-action digest to 2a4836a
2023-11-29 09:13:29 +01:00
renovate[bot]
8bb114d7d4 Update dependency @livekit/components-react to v1.4.1 (#1923)
* Update dependency @livekit/components-react to v1.4.1

* patch to match new lk api

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
2023-11-28 20:01:12 +01:00
Timo
db859d43e9 Refactor livekit disconnect to use an effect hook. (#1925)
---------

Signed-off-by: Timo K <toger5@hotmail.de>
2023-11-28 19:07:08 +01:00
fkwp
712f320d28 Merge pull request #1924 from vector-im/renovate/livekit-client-1.x-lockfile
Update dependency livekit-client to v1.15.2
2023-11-27 22:52:06 +01:00
renovate[bot]
0e42099088 Update dependency livekit-client to v1.15.2 2023-11-27 21:47:08 +00:00
Timo
b17a76ac1a Hotfix for hangup disconnect screen (#1921)
-  we cannot disconnect livekit before ending the rtcsession.

Signed-off-by: Timo K <toger5@hotmail.de>
2023-11-27 14:08:39 +01:00
renovate[bot]
c24d20f540 Update docker/metadata-action digest to 2a4836a 2023-11-27 10:16:40 +00:00
Timo
005b0bfa5e Fix not disconnecting from livekit session. (#1920)
Signed-off-by: Timo K <toger5@hotmail.de>
2023-11-24 17:53:15 +01:00
Robin
115dfea521 Show unencrypted media warnings in widget mode (#1915)
They weren't showing up because the video tiles didn't think that the call was encrypted in the per-participant keys case.
2023-11-23 17:50:55 +01:00
Michael Telatynski
d328fc2625 Merge pull request #1918 from vector-im/t3chguy/missing-i18n 2023-11-23 08:21:27 +00:00
Michael Telatynski
7051c11411 I18n'ise hardcoded strings 2023-11-22 20:07:30 +00:00
Robin
2b39fa930e Merge pull request #1884 from vector-im/renovate/opentelemetry-js-monorepo
Update opentelemetry-js monorepo
2023-11-22 10:37:18 -05:00
renovate[bot]
326d2b128e Update docker/login-action digest to 3d58c27 2023-11-22 15:37:02 +00:00
renovate[bot]
f3220ab597 Update docker/build-push-action digest to 4a13e50 2023-11-22 15:36:57 +00:00
Robin
7e6c69ce65 Merge pull request #1877 from vector-im/renovate/grecaptcha-3.x-lockfile
Update dependency @types/grecaptcha to v3.0.7
2023-11-22 10:36:41 -05:00
Robin
8c45b5963b Merge pull request #1914 from vector-im/renovate/livekit-client-1.x-lockfile
Update dependency livekit-client to v1.15.0
2023-11-22 10:34:35 -05:00
renovate[bot]
9cf5b7c868 Update dependency livekit-client to v1.15.0 2023-11-21 20:08:18 +00:00
Robin
de3a3b6c22 Merge pull request #1913 from robintown/disable-pip
Properly disable picture-in-picture controls
2023-11-21 12:53:47 -05:00
Robin
0c2a410033 Merge pull request #1911 from robintown/lock-accessibility
Make the encryption lock more accessible
2023-11-21 12:53:37 -05:00
David Baker
95a44f5462 Merge pull request #1902 from vector-im/dbkr/hide_rooms_no_key
Hide rooms we don't have the key for in recents list
2023-11-21 15:26:45 +00:00
fkwp
59387e5b96 Merge pull request #1912 from robintown/unencrypted-indicator
Show a warning on unencrypted media
2023-11-21 16:22:12 +01:00
David Baker
b0dac561d5 Update comment 2023-11-21 09:59:07 +00:00
David Baker
0ba550f56b Merge pull request #1910 from vector-im/dbkr/fix_no_media_perms
Fix joining calls with no media permission
2023-11-21 09:18:00 +00:00
Robin
6ebef28ad8 Properly disable picture-in-picture controls
There were some outdated comments regarding Firefox's support of the disablePictureInPicture flag which I decided to clean up.
2023-11-20 20:42:27 -05:00
Robin
49a50514fd Show a warning on unencrypted media 2023-11-20 19:04:53 -05:00
Robin
09481d1e44 Give the lock icon an accessible label 2023-11-20 19:02:19 -05:00
Robin
fcfb818a27 Make the encryption lock tooltip accessible via the keyboard 2023-11-20 18:59:56 -05:00
Robin
aa8ef57ee0 Merge pull request #1908 from vector-im/t3chguy/localazy/2
Switch from Weblate to Localazy
2023-11-20 16:55:58 -05:00
David Baker
861d8ec016 Fix joining calls with no media permission
You can still try to unmute your media in the preview screen and
the button will show unmuted, but then mute again once you actually
join, so not 100% perfect but better.

Fixes https://github.com/vector-im/element-call/issues/1907
Fixes https://github.com/vector-im/element-call/issues/1661
2023-11-20 18:49:08 +00:00
Robin
a31d1a5ff0 Merge pull request #1906 from robintown/speaker-jumpiness
Fix jumpy speaker tiles in spotlight mode
2023-11-20 13:44:23 -05:00
Robin
0144aafee5 Count people as speakers even if there is a presenter 2023-11-20 13:33:45 -05:00
Robin
42c8677be1 Fix jumpy speaker tiles in spotlight mode
reorderTiles was programmed to only place a tile in the speaker section if that tile's previous position was off-screen. But for speakers that started off-screen, this would cause them to oscillate in and out of the speaker section on each render, because the speaker section is, of course, on-screen. The solution I've gone with here is to avoid referencing the previous position, and instead go by the computed natural ordering, which ought to be more stable.
2023-11-20 01:02:08 -05:00
renovate[bot]
9746764f81 Update dependency @testing-library/react to v14.1.2 2023-11-17 19:15:15 +00:00
David Baker
92d61fa0f1 Hide rooms we don't have the key for in recents list 2023-11-16 16:32:25 +00:00
renovate[bot]
ebfd79dfea Update opentelemetry-js monorepo 2023-11-08 22:20:16 +00:00
renovate[bot]
0a2ea8315b Update dependency @types/sdp-transform to v2.4.9 2023-11-08 08:52:46 +00:00
renovate[bot]
cd490a44fd Update dependency @types/grecaptcha to v3.0.7 2023-11-08 08:51:16 +00:00
47 changed files with 2306 additions and 1631 deletions

View File

@@ -38,6 +38,15 @@ module.exports = {
"jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly.
"no-console": ["error"],
"no-restricted-imports": [
"error",
{
name: "@react-rxjs/core",
importNames: ["Subscribe", "RemoveSubscribe"],
message:
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
},
],
},
settings: {
react: {

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@1f401f745bf57e30b3a2800ad308a87d2ebdf14b
uses: docker/login-action@3d58c274f17dffee475a5520cbe67f0a882c4dbb
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -62,7 +62,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@62339db73c56dd749060f65a6ebb93a6e056b755
uses: docker/metadata-action@e6428a5c4e294a61438ed7f43155db912025b6b3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -72,10 +72,10 @@ jobs:
type=raw,value=latest-ci_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6d5347c4025fdf2bb05167a2519cac535a14a408
uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345
- name: Build and push Docker image
uses: docker/build-push-action@fdf7f43ecf7c1a5c7afe936410233728a8c2d9c2
uses: docker/build-push-action@9f6f8c940b91232557f8699b21341a08624a8dce
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -15,7 +15,7 @@ jobs:
- name: Checkout the code
uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
cache: "yarn"

View File

@@ -93,12 +93,12 @@ service for development. These use a test 'secret' published in this
repository, so this must be used only for local development and
**_never be exposed to the public Internet._**
To use it, add SFU parameter in your local config `./public/config.yml`:
To use it, add SFU parameter in your local config `./public/config.json`:
```yaml
```json
"livekit": {
"jwt_service_url": "http://localhost:8881"
},
"livekit_service_url": "http://localhost:8881"
},
```
Run backend components:

View File

@@ -18,14 +18,15 @@
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@livekit/components-core": "^0.7.0",
"@livekit/components-react": "^1.1.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.45.0",
"@opentelemetry/instrumentation-document-load": "^0.33.0",
"@opentelemetry/instrumentation-user-interaction": "^0.33.0",
"@opentelemetry/instrumentation-document-load": "^0.34.0",
"@opentelemetry/instrumentation-user-interaction": "^0.34.0",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-visually-hidden": "^1.0.3",
@@ -37,6 +38,7 @@
"@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",
@@ -70,6 +72,7 @@
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sdp-transform": "^2.14.1",
"tinyqueue": "^2.0.3",
"unique-names-generator": "^4.6.0",
@@ -120,7 +123,7 @@
"sass": "^1.42.1",
"typescript": "^5.1.6",
"typescript-eslint-language-service": "^5.0.5",
"vite": "^4.2.0",
"vite": "^5.0.0",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^4.0.0"
},

View File

@@ -22,7 +22,9 @@
"text": "Bereit, beizutreten?",
"title": "App auswählen"
},
"application_opened_another_tab": "Diese Anwendung wurde in einem anderen Tab geöffnet.",
"browser_media_e2ee_unsupported": "Dein Webbrowser unterstützt keine Medien-Ende-zu-Ende-Verschlüsselung. Unterstützte Browser sind Chrome, Safari, Firefox >=117",
"browser_media_e2ee_unsupported_heading": "Inkompatibler Browser",
"call_ended_view": {
"body": "Deine Verbindung wurde getrennt",
"create_account_button": "Konto erstellen",
@@ -36,10 +38,13 @@
},
"call_name": "Name des Anrufs",
"common": {
"audio": "Audio",
"avatar": "Profilbild",
"camera": "Kamera",
"copied": "Kopiert!",
"display_name": "Anzeigename",
"encrypted": "Verschlüsselt",
"error": "Fehler",
"home": "Startseite",
"loading": "Lade …",
"microphone": "Mikrofon",
@@ -47,7 +52,8 @@
"profile": "Profil",
"settings": "Einstellungen",
"unencrypted": "Nicht verschlüsselt",
"username": "Benutzername"
"username": "Benutzername",
"video": "Video"
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"exit_fullscreen_button_label": "Vollbildmodus verlassen",
@@ -75,8 +81,11 @@
"leave_button": "Zurück zu kürzlichen Anrufen"
},
"local_volume_label": "Lokale Lautstärke",
"log_in": "Anmelden",
"logging_in": "Anmelden …",
"login_auth_links": "<0>Konto erstellen</0> Oder <2>Als Gast betreten</2>",
"login_auth_links_prompt": "Noch nicht registriert?",
"login_subheading": "Weiter zu Element",
"login_title": "Anmelden",
"microphone_off": "Mikrofon aus",
"microphone_on": "Mikrofon an",
@@ -130,6 +139,7 @@
"unauthenticated_view_eula_caption": "Mit einem Klick auf „Los gehts“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
"unauthenticated_view_login_button": "Melde dich mit deinem Konto an",
"unmute_microphone_button_label": "Mikrofon aktivieren",
"version": "Version: {{version}}",
"video_tile": {
"presenter_label": "{{displayName}} präsentiert",
"sfu_participant_local": "Du"

View File

@@ -24,6 +24,7 @@
},
"application_opened_another_tab": "This application has been opened in another tab.",
"browser_media_e2ee_unsupported": "Your web browser does not support media end-to-end encryption. Supported Browsers are Chrome, Safari, Firefox >=117",
"browser_media_e2ee_unsupported_heading": "Incompatible Browser",
"call_ended_view": {
"body": "You were disconnected from the call",
"create_account_button": "Create account",
@@ -37,12 +38,14 @@
},
"call_name": "Name of call",
"common": {
"analytics": "Analytics",
"audio": "Audio",
"avatar": "Avatar",
"camera": "Camera",
"copied": "Copied!",
"display_name": "Display name",
"encrypted": "Encrypted",
"error": "Error",
"home": "Home",
"loading": "Loading…",
"microphone": "Microphone",
@@ -79,8 +82,11 @@
"leave_button": "Back to recents"
},
"local_volume_label": "Local volume",
"log_in": "Log In",
"logging_in": "Logging in…",
"login_auth_links": "<0>Create an account</0> Or <2>Access as a guest</2>",
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
@@ -105,6 +111,7 @@
},
"register_auth_links": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
"register_confirm_password_label": "Confirm password",
"register_heading": "Create your account",
"return_home_button": "Return to home screen",
"room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"room_auth_view_join_button": "Join call now",

View File

@@ -71,7 +71,7 @@ export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
return (
<FullScreenView>
<h1>Error</h1>
<h1>{t("common.error")}</h1>
<p>
{error instanceof TranslatedError
? error.translatedMessage

View File

@@ -14,10 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import i18n from "i18next";
import type { ParseKeys, TFunction } from "i18next/typescript/t";
import type { DefaultNamespace, TOptions } from "i18next/typescript/options";
import type { DefaultNamespace, ParseKeys, TFunction, TOptions } from "i18next";
/**
* An error with messages in both English and the user's preferred language.
@@ -45,5 +42,5 @@ class TranslatedErrorImpl extends TranslatedError {}
// function instead
export const translatedError = (
messageKey: ParseKeys<DefaultNamespace, TOptions>,
t: typeof i18n.t,
t: TFunction<"app", undefined>,
): TranslatedError => new TranslatedErrorImpl(messageKey, t);

View File

@@ -32,7 +32,7 @@ interface RoomIdentifier {
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
interface UrlParams {
export interface UrlParams {
// Widget api related params
widgetId: string | null;
parentUrl: string | null;

View File

@@ -66,7 +66,7 @@ export const UserMenu: FC<Props> = ({
arr.push({
key: "settings",
icon: SettingsIcon,
label: "common.settings",
label: t("common.settings"),
});
if (isPasswordlessUser && !preventNavigation) {
@@ -96,7 +96,7 @@ export const UserMenu: FC<Props> = ({
if (!isAuthenticated) {
return (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
{t("log_in")}
</LinkButton>
);
}

View File

@@ -90,8 +90,8 @@ export const LoginPage: FC = () => {
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Log In</h2>
<h4>To continue to Element</h4>
<h2>{t("log_in")}</h2>
<h4>{t("login_subheading")}</h4>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
@@ -132,7 +132,7 @@ export const LoginPage: FC = () => {
</form>
</div>
<div className={styles.authLinks}>
<p>Not registered yet?</p>
<p>{t("login_auth_links_prompt")}</p>
<p>
<Trans i18nKey="login_auth_links">
<Link to="/register">Create an account</Link>

View File

@@ -166,7 +166,7 @@ export const RegisterPage: FC = () => {
<div className={styles.content}>
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Create your account</h2>
<h2>{t("register_heading")}</h2>
<form onSubmit={onSubmitRegisterForm}>
<FieldRow>
<InputField

View File

@@ -15,13 +15,18 @@ limitations under the License.
*/
import { useEffect, useMemo } from "react";
import { Room } from "matrix-js-sdk";
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
import { useClient } from "../ClientContext";
import { useUrlParams } from "../UrlParams";
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
import { widget } from "../widget";
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
export function saveKeyForRoom(roomId: string, password: string): void {
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
}
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`;
const useInternalRoomSharedKey = (roomId: string): string | null => {
@@ -31,6 +36,22 @@ const useInternalRoomSharedKey = (roomId: string): string | null => {
return roomSharedKey;
};
export function getKeyForRoom(roomId: string): string | null {
saveKeyFromUrlParams(getUrlParams());
const key = getRoomSharedKeyLocalStorageKey(roomId);
return localStorage.getItem(key);
}
function saveKeyFromUrlParams(urlParams: UrlParams): void {
if (!urlParams.password || !urlParams.roomId) return;
// Take the key from the URL and save it.
// It's important to always use the room ID specified in the URL
// when saving keys rather than whatever the current room ID might be,
// in case we've moved to a different room but the URL hasn't changed.
saveKeyForRoom(urlParams.roomId, urlParams.password);
}
/**
* Extracts the room password from the URL if one is present, saving it in localstorage
* and returning it in a tuple with the corresponding room ID from the URL.
@@ -40,18 +61,7 @@ const useInternalRoomSharedKey = (roomId: string): string | null => {
const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
const urlParams = useUrlParams();
useEffect(() => {
if (!urlParams.password || !urlParams.roomId) return;
if (!urlParams.roomId) return;
setLocalStorageItem(
// We set the Item by only using data from the url. This way we
// make sure, we always have matching pairs in the LocalStorage,
// as they occur in the call links.
getRoomSharedKeyLocalStorageKey(urlParams.roomId),
urlParams.password,
);
}, [urlParams]);
useEffect(() => saveKeyFromUrlParams(urlParams), [urlParams]);
return urlParams.roomId && urlParams.password
? [urlParams.roomId, urlParams.password]
@@ -74,12 +84,14 @@ export const useRoomSharedKey = (roomId: string): string | undefined => {
export const useIsRoomE2EE = (roomId: string): boolean | null => {
const { client } = useClient();
const room = useMemo(() => client?.getRoom(roomId) ?? null, [roomId, client]);
const room = useMemo(() => client?.getRoom(roomId), [roomId, client]);
return useMemo(() => !room || isRoomE2EE(room), [room]);
};
export function isRoomE2EE(room: Room): boolean {
// For now, rooms in widget mode are never considered encrypted.
// In the future, when widget mode gains encryption support, then perhaps we
// should inspect the e2eEnabled URL parameter here?
return useMemo(
() => widget === null && (room === null || !room.getCanonicalAlias()),
[room],
);
};
return widget === null && !room.getCanonicalAlias();
}

View File

@@ -21,6 +21,8 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { useState, useEffect } from "react";
import { getKeyForRoom, isRoomE2EE } from "../e2ee/sharedKeyManagement";
export interface GroupCallRoom {
roomAlias?: string;
roomName: string;
@@ -78,6 +80,14 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
});
}
function roomIsJoinable(room: Room): boolean {
if (isRoomE2EE(room)) {
return Boolean(getKeyForRoom(room.roomId));
} else {
return true;
}
}
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
const [rooms, setRooms] = useState<GroupCallRoom[]>([]);
@@ -88,7 +98,9 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
}
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const rooms = Array.from(groupCalls)
.map((groupCall) => groupCall.room)
.filter(roomIsJoinable);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId)!;

View File

@@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.37785 4.70549C6.37785 2.79707 7.99961 1.25 10.0001 1.25C12.0007 1.25 13.6224 2.79707 13.6224 4.70548V9.87324C13.6224 11.7817 12.0007 13.3287 10.0001 13.3287C7.99961 13.3287 6.37785 11.7817 6.37785 9.87324V4.70549Z" fill="white"/>
<path d="M3.66099 8.77015C4.30219 8.77015 4.82198 9.266 4.82198 9.87768C4.82198 12.5957 7.1314 14.8024 9.98617 14.8095C9.99084 14.8094 9.99552 14.8094 10.0002 14.8094C10.0049 14.8094 10.0095 14.8094 10.0141 14.8095C12.8688 14.8022 15.178 12.5956 15.178 9.87768C15.178 9.266 15.6978 8.77015 16.339 8.77015C16.9802 8.77015 17.5 9.266 17.5 9.87768C17.5 13.4496 14.7509 16.4077 11.1612 16.9394V17.6425C11.1612 18.2541 10.6414 18.75 10.0002 18.75C9.359 18.75 8.83921 18.2541 8.83921 17.6425V16.9395C5.24925 16.4079 2.5 13.4497 2.5 9.87768C2.5 9.266 3.01979 8.77015 3.66099 8.77015Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 938 B

View File

@@ -1,5 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,5 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.74372 1.50628C2.40201 1.16457 1.84799 1.16457 1.50628 1.50628C1.16457 1.84799 1.16457 2.40201 1.50628 2.74372L6.5 7.73744V10C6.5 11.933 8.067 13.5 10 13.5C10.6491 13.5 11.2569 13.3233 11.778 13.0154L13.042 14.2794C12.1838 14.8906 11.1339 15.25 10 15.25C7.10051 15.25 4.75 12.8995 4.75 10C4.75 9.51675 4.35825 9.125 3.875 9.125C3.39175 9.125 3 9.51675 3 10C3 13.5697 5.67198 16.5153 9.125 16.9458V17.875C9.125 18.3582 9.51675 18.75 10 18.75C10.4832 18.75 10.875 18.3582 10.875 17.875V16.9458C12.1531 16.7865 13.3242 16.2825 14.2924 15.5299L17.2563 18.4937C17.598 18.8354 18.152 18.8354 18.4937 18.4937C18.8354 18.152 18.8354 17.598 18.4937 17.2563L2.74372 1.50628Z" fill="#394049"/>
<path d="M15.0678 11.3763L16.439 12.7504C16.8001 11.9061 17 10.9764 17 10C17 9.51675 16.6082 9.125 16.125 9.125C15.6418 9.125 15.25 9.51675 15.25 10C15.25 10.4762 15.1866 10.9376 15.0678 11.3763Z" fill="#394049"/>
<path d="M6.87692 3.16834L13.5 9.80523V4.75C13.5 2.817 11.933 1.25 10 1.25C8.63618 1.25 7.45455 2.03005 6.87692 3.16834Z" fill="#394049"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -89,14 +89,17 @@ function useMediaDevice(
const [selectedId, select] = useState(fallbackDevice);
return useMemo(() => {
const devId = available.some((d) => d.deviceId === selectedId)
? selectedId
: available.some((d) => d.deviceId === fallbackDevice)
? fallbackDevice
: available.at(0)?.deviceId;
let devId;
if (available) {
devId = available.some((d) => d.deviceId === selectedId)
? selectedId
: available.some((d) => d.deviceId === fallbackDevice)
? fallbackDevice
: available.at(0)?.deviceId;
}
return {
available,
available: available ?? [],
selectedId: alwaysDefault ? undefined : devId,
select,
};

View File

@@ -72,35 +72,37 @@ async function doConnect(
}
logger.info("Pre-creating microphone track");
const audioTracks = await livekitRoom!.localParticipant.createTracks({
audio: audioOptions,
});
if (audioTracks.length < 1) {
logger.info("Tried to pre-create local audio track but got no tracks");
return;
let preCreatedAudioTrack: LocalTrack | undefined;
try {
const audioTracks = await livekitRoom!.localParticipant.createTracks({
audio: audioOptions,
});
if (audioTracks.length < 1) {
logger.info("Tried to pre-create local audio track but got no tracks");
} else {
preCreatedAudioTrack = audioTracks[0];
}
logger.info("Pre-created microphone track");
} catch (e) {
logger.error("Failed to pre-create microphone track", e);
}
if (!audioEnabled) await audioTracks[0].mute();
logger.info("Pre-created microphone track");
if (!audioEnabled) await preCreatedAudioTrack?.mute();
// check again having awaited for the track to create
if (livekitRoom!.localParticipant.getTrack(Track.Source.Microphone)) {
logger.warn(
"Publishing pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!",
"Pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!",
);
for (const t of audioTracks) {
t.stop();
}
preCreatedAudioTrack?.stop();
return;
}
logger.info("Connecting & publishing");
try {
await connectAndPublish(livekitRoom, sfuConfig, audioTracks[0], []);
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
} catch (e) {
for (const t of audioTracks) {
t.stop();
}
preCreatedAudioTrack?.stop();
}
}
@@ -246,6 +248,6 @@ export function useECConnectionState(
return isSwitchingFocus
? ECAddonConnectionState.ECSwitchingFocus
: isInDoConnect
? ConnectionState.Connecting
: connState;
? ConnectionState.Connecting
: connState;
}

View File

@@ -245,11 +245,25 @@ export function useLiveKit(
);
}
} catch (e) {
logger.error(
"Failed to sync audio mute state with LiveKit (will retry to sync in 1s):",
e,
);
setTimeout(() => syncMuteState(iterCount + 1, type), 1000);
if ((e as DOMException).name === "NotAllowedError") {
logger.error(
"Fatal errror while syncing mute state: resetting",
e,
);
if (type === MuteDevice.Microphone) {
audioMuteUpdating.current = false;
muteStates.audio.setEnabled?.(false);
} else {
videoMuteUpdating.current = false;
muteStates.video.setEnabled?.(false);
}
} else {
logger.error(
"Failed to sync audio mute state with LiveKit (will retry to sync in 1s):",
e,
);
setTimeout(() => syncMuteState(iterCount + 1, type), 1000);
}
}
}
};

View File

@@ -36,9 +36,8 @@ import IndexedDBWorker from "./IndexedDBWorker?worker";
import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
import { loadOlm } from "./olm";
import { Config } from "./config/Config";
import { setLocalStorageItem } from "./useLocalStorage";
import { getRoomSharedKeyLocalStorageKey } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { saveKeyForRoom } from "./e2ee/sharedKeyManagement";
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
@@ -359,10 +358,7 @@ export async function createRoom(
let password;
if (e2ee == E2eeType.SHARED_KEY) {
password = secureRandomBase64Url(16);
setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(result.room_id),
password,
);
saveKeyForRoom(result.room_id, password);
}
return {

View File

@@ -29,17 +29,22 @@ interface Props {
export const EncryptionLock: FC<Props> = ({ encrypted }) => {
const { t } = useTranslation();
const Icon = encrypted ? LockIcon : LockOffIcon;
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
return (
<Tooltip
label={encrypted ? t("common.encrypted") : t("common.unencrypted")}
side="right"
>
<Tooltip label={label} side="right">
<Icon
width={16}
height={16}
className={styles.lock}
data-encrypted={encrypted}
aria-label={label}
// Make the icon focusable so that the tooltip can be opened
// with keyboard navigation
// TODO: Replace this with the solution from
// https://github.com/vector-im/compound-web/pull/130 once it
// lands
tabIndex={0}
/>
</Tooltip>
);

View File

@@ -76,9 +76,6 @@ export const GroupCallView: FC<Props> = ({
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
const isRoomE2EE = useIsRoomE2EE(rtcSession.room.roomId);
useEffect(() => {
window.rtcSession = rtcSession;
return () => {
@@ -89,8 +86,10 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
const roomEncrypted = useIsRoomE2EE(rtcSession.room.roomId)!;
const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
const { perParticipantE2EE } = useUrlParams();
const roomEncrypted =
useIsRoomE2EE(rtcSession.room.roomId) || perParticipantE2EE;
const matrixInfo = useMemo((): MatrixInfo => {
return {
@@ -127,6 +126,16 @@ export const GroupCallView: FC<Props> = ({
const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates;
const e2eeConfig = useMemo((): E2EEConfig => {
if (perParticipantE2EE) {
return { mode: E2eeType.PER_PARTICIPANT };
} else if (e2eeSharedKey) {
return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
} else {
return { mode: E2eeType.NONE };
}
}, [perParticipantE2EE, e2eeSharedKey]);
useEffect(() => {
// this effect is only if we don't want to show the lobby (skipLobby = true)
if (!skipLobby) return;
@@ -221,6 +230,7 @@ export const GroupCallView: FC<Props> = ({
sendInstantly,
);
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
await leaveRTCSession(rtcSession);
if (
@@ -240,6 +250,7 @@ export const GroupCallView: FC<Props> = ({
ev: CustomEvent<IWidgetApiRequest>,
): Promise<void> => {
widget!.api.transport.reply(ev.detail, {});
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
await leaveRTCSession(rtcSession);
};
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
@@ -249,16 +260,6 @@ export const GroupCallView: FC<Props> = ({
}
}, [isJoined, rtcSession]);
const e2eeConfig = useMemo((): E2EEConfig => {
if (perParticipantE2EE) {
return { mode: E2eeType.PER_PARTICIPANT };
} else if (e2eeSharedKey) {
return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
} else {
return { mode: E2eeType.NONE };
}
}, [perParticipantE2EE, e2eeSharedKey]);
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
@@ -289,7 +290,7 @@ export const GroupCallView: FC<Props> = ({
const { t } = useTranslation();
if (isRoomE2EE && !perParticipantE2EE && !e2eeSharedKey) {
if (roomEncrypted && !perParticipantE2EE && !e2eeSharedKey) {
return (
<ErrorView
error={
@@ -299,10 +300,10 @@ export const GroupCallView: FC<Props> = ({
}
/>
);
} else if (!isE2EESupported() && isRoomE2EE) {
} else if (!isE2EESupported() && roomEncrypted) {
return (
<FullScreenView>
<Heading>Incompatible Browser</Heading>
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
<Text>{t("browser_media_e2ee_unsupported")}</Text>
<Link href="/" onClick={onHomeClick}>
{t("common.home")}

View File

@@ -28,7 +28,9 @@ limitations under the License.
flex-direction: column;
overflow: auto;
overflow-inline: hidden;
contain: strict;
/* 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. */
}
.centerMessage {

View File

@@ -19,14 +19,11 @@ import {
RoomAudioRenderer,
RoomContext,
useLocalParticipant,
useParticipants,
useTracks,
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
import { ConnectionState, Room, Track } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room";
import {
FC,
ReactNode,
@@ -39,9 +36,9 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useStateObservable } from "@react-rxjs/core";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -54,19 +51,14 @@ import {
SettingsButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import {
useVideoGridLayout,
TileDescriptor,
VideoGrid,
} from "../video-grid/VideoGrid";
import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid";
import { useShowConnectionStats } from "../settings/useSetting";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css";
import { ItemData, TileContent, VideoTile } from "../video-grid/VideoTile";
import { VideoTile } from "../video-grid/VideoTile";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
@@ -81,18 +73,14 @@ import { MuteStates } from "./MuteStates";
import { MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import {
ECAddonConnectionState,
ECConnectionState,
} from "../livekit/useECConnectionState";
import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import { useCallViewModel } from "../state/CallViewModel";
import { subscribe } from "../state/subscribe";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
// How long we wait after a focus switch before showing the real participant list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
export interface ActiveCallProps
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
e2eeConfig: E2EEConfig;
@@ -107,9 +95,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeConfig,
);
if (!livekitRoom) {
return null;
}
useEffect(() => {
return () => {
livekitRoom?.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!livekitRoom) return null;
return (
<RoomContext.Provider value={livekitRoom}>
@@ -132,477 +125,333 @@ export interface InCallViewProps {
onShareClick: (() => void) | null;
}
export const InCallView: FC<InCallViewProps> = ({
client,
matrixInfo,
rtcSession,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
otelGroupCallMembership,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
usePreventScroll();
useWakeLock();
export const InCallView: FC<InCallViewProps> = subscribe(
({
client,
matrixInfo,
rtcSession,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
otelGroupCallMembership,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
usePreventScroll();
useWakeLock();
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
const boundsValid = bounds.height > 0;
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
const boundsValid = bounds.height > 0;
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
const screenSharingTracks = useTracks(
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
{
room: livekitRoom,
},
);
const { layout, setLayout } = useVideoGridLayout(
screenSharingTracks.length > 0,
);
const [showConnectionStats] = useShowConnectionStats();
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 onLeavePress = useCallback(() => {
// Disconnect from the room. We don't do this in onLeave because that's
// also called on an unintentional disconnect. Plus we don't have the
// livekit room in onLeave anyway.
livekitRoom.disconnect();
onLeave();
}, [livekitRoom, onLeave]);
useEffect(() => {
widget?.api.transport.send(
layout === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
const screenSharingTracks = useTracks(
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
{
room: livekitRoom,
},
);
const { layout, setLayout } = useVideoGridLayout(
screenSharingTracks.length > 0,
);
}, [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, {});
};
const [showConnectionStats] = useShowConnectionStats();
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
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),
);
useEffect(() => {
widget?.api.transport.send(
layout === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
);
}, [layout]);
return () => {
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget!.lazyActions.off(
useEffect(() => {
if (widget) {
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setLayout("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (
ev: CustomEvent<IWidgetApiRequest>,
): void => {
setLayout("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
};
}
}, [setLayout]);
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
return () => {
widget!.lazyActions.off(
ElementWidgetActions.TileLayout,
onTileLayout,
);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
};
}
}, [setLayout]);
const items = useParticipantTiles(livekitRoom, rtcSession.room, connState);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(items);
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
// 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 vm = useCallViewModel(rtcSession.room, livekitRoom, connState);
const items = useStateObservable(vm.tiles);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(items);
const Grid =
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
// 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 prefersReducedMotion = usePrefersReducedMotion();
const Grid =
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
// 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 prefersReducedMotion = usePrefersReducedMotion();
// This state is lifted out of NewVideoGrid so that layout states can be
// restored after a layout switch or upon exiting fullscreen
const layoutStates = useLayoutStates();
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("waiting_for_participants")}</p>
</div>
);
}
if (maximisedParticipant) {
return (
<VideoTile
vm={maximisedParticipant.data}
maximised={true}
fullscreen={maximisedParticipant === fullscreenItem}
onToggleFullscreen={toggleFullscreen}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
showSpeakingIndicator={false}
showConnectionStats={showConnectionStats}
matrixInfo={matrixInfo}
/>
);
}
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("waiting_for_participants")}</p>
</div>
<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}
showConnectionStats={showConnectionStats}
matrixInfo={matrixInfo}
{...props}
ref={props.ref as Ref<HTMLDivElement>}
/>
)}
</Grid>
);
}
if (maximisedParticipant) {
return (
<VideoTile
maximised={true}
fullscreen={maximisedParticipant === fullscreenItem}
onToggleFullscreen={toggleFullscreen}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
data={maximisedParticipant.data}
showSpeakingIndicator={false}
showConnectionStats={showConnectionStats}
/>
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[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>
);
}
return (
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
>
{(props): ReactNode => (
<VideoTile
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
showSpeakingIndicator={items.length > 2}
showConnectionStats={showConnectionStats}
{...props}
ref={props.ref as Ref<HTMLDivElement>}
/>
<div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && (
<Header>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.roomEncrypted}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{!reducedControls && showControls && onShareClick !== null && (
<InviteButton onClick={onShareClick} />
)}
</RightNav>
</Header>
)}
</Grid>
);
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[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={onLeavePress}
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 className={styles.controlsOverlay}>
<RoomAudioRenderer />
{renderContent()}
{footer}
</div>
{!noControls && (
<RageshakeRequestModal {...rageshakeRequestModalProps} />
)}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
/>
</div>
);
}
return (
<div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && (
<Header>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.roomEncrypted}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{!reducedControls && showControls && onShareClick !== null && (
<InviteButton onClick={onShareClick} />
)}
</RightNav>
</Header>
)}
<div className={styles.controlsOverlay}>
<RoomAudioRenderer />
{renderContent()}
{footer}
</div>
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
/>
</div>
);
};
function findMatrixMember(
room: MatrixRoom,
id: string,
): RoomMember | undefined {
if (!id) return 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
if (parts.length < 3) {
logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination",
);
return undefined;
}
parts.pop();
const userId = parts.join(":");
return room.getMember(userId) ?? undefined;
}
function useParticipantTiles(
livekitRoom: Room,
matrixRoom: MatrixRoom,
connState: ECConnectionState,
): TileDescriptor<ItemData>[] {
const previousTiles = useRef<TileDescriptor<ItemData>[]>([]);
const sfuParticipants = useParticipants({
room: livekitRoom,
});
const items = useMemo(() => {
const hasPresenter =
sfuParticipants.find((p) => p.isScreenShareEnabled) !== undefined;
let allGhosts = true;
const speakActiveTime = new Date();
speakActiveTime.setSeconds(speakActiveTime.getSeconds() - 10);
// Iterate over SFU participants (those who actually are present from the SFU perspective) and create tiles for them.
const tiles: TileDescriptor<ItemData>[] = sfuParticipants.flatMap(
(sfuParticipant) => {
const hadSpokedInTime =
!hasPresenter && sfuParticipant.lastSpokeAt
? sfuParticipant.lastSpokeAt > speakActiveTime
: false;
const id = sfuParticipant.identity;
const member = findMatrixMember(matrixRoom, id);
// We always start with a local participant wit the empty string as their ID before we're
// connected, this is fine and we'll be in "all ghosts" mode.
if (id !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`,
);
}
allGhosts &&= member === undefined;
const userMediaTile = {
id,
focused: false,
isPresenter: sfuParticipant.isScreenShareEnabled,
isSpeaker:
(sfuParticipant.isSpeaking || hadSpokedInTime) &&
!sfuParticipant.isLocal,
hasVideo: sfuParticipant.isCameraEnabled,
local: sfuParticipant.isLocal,
largeBaseSize: false,
data: {
id,
member,
sfuParticipant,
content: TileContent.UserMedia,
},
};
// If there is a screen sharing enabled for this participant, create a tile for it as well.
let screenShareTile: TileDescriptor<ItemData> | undefined;
if (sfuParticipant.isScreenShareEnabled) {
const screenShareId = `${id}:screen-share`;
screenShareTile = {
...userMediaTile,
id: screenShareId,
focused: true,
largeBaseSize: true,
placeNear: id,
data: {
...userMediaTile.data,
id: screenShareId,
content: TileContent.ScreenShare,
},
};
}
return screenShareTile
? [userMediaTile, screenShareTile]
: [userMediaTile];
},
);
PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged(
tiles.length,
);
// If every item is a ghost, that probably means we're still connecting and
// shouldn't bother showing anything yet
return allGhosts ? [] : tiles;
}, [matrixRoom, sfuParticipants]);
// We carry over old tiles from the previous focus for some time after a focus switch
// so that the video tiles don't all disappear and reappear.
// This is set to true when the state transitions to Switching Focus and remains
// true for a short time after it changes (ie. connState is only switching focus for
// the time it takes us to reconnect to the conference).
// If there are still members that haven't reconnected after that time, they'll just
// appear to disconnect and will reappear once they reconnect.
const [isSwitchingFocus, setIsSwitchingFocus] = useState(false);
useEffect(() => {
if (connState === ECAddonConnectionState.ECSwitchingFocus) {
setIsSwitchingFocus(true);
} else if (isSwitchingFocus) {
setTimeout(() => {
setIsSwitchingFocus(false);
}, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS);
}
}, [connState, setIsSwitchingFocus, isSwitchingFocus]);
if (
connState === ECAddonConnectionState.ECSwitchingFocus ||
isSwitchingFocus
) {
logger.debug("Switching focus: injecting previous tiles");
// inject the previous tile for members that haven't rejoined yet
const newItems = items.slice(0);
const rejoined = new Set(newItems.map((p) => p.id));
for (const prevTile of previousTiles.current) {
if (!rejoined.has(prevTile.id)) {
newItems.push(prevTile);
}
}
return newItems;
} else {
previousTiles.current = items;
return items;
}
}
},
);

View File

@@ -18,6 +18,7 @@ limitations under the License.
text-align: center;
color: var(--cpd-color-text-secondary);
margin-block-end: var(--cpd-space-8x);
overflow-wrap: break-word;
}
.button {

View File

@@ -82,6 +82,8 @@ export const VideoPreview: FC<Props> = ({
},
(error) => {
logger.error("Error while creating preview Tracks:", error);
muteStates.audio.setEnabled?.(false);
muteStates.video.setEnabled?.(false);
},
);
const videoTrack = useMemo(
@@ -106,7 +108,21 @@ export const VideoPreview: FC<Props> = ({
const content = (
<>
<video ref={videoEl} muted playsInline disablePictureInPicture />
<video
ref={videoEl}
muted
playsInline
// There's no reason for this to be focusable
tabIndex={-1}
// React supports the disablePictureInPicture attribute, but Firefox
// only recognizes a value of "true", whereas React sets it to the empty
// string. So we need to bypass React and set it specifically to "true".
// https://bugzilla.mozilla.org/show_bug.cgi?id=1865748
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
disablepictureinpicture="true"
/>
{!muteStates.video.enabled && (
<div className={styles.avatarContainer}>
<Avatar

View File

@@ -18,9 +18,9 @@ limitations under the License.
import { logger } from "matrix-js-sdk/src/logger";
import { useCallback, useLayoutEffect, useRef } from "react";
import { TileDescriptor } from "../video-grid/VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useEventTarget } from "../useEvents";
import { TileDescriptor } from "../state/CallViewModel";
const isFullscreen = (): boolean =>
Boolean(document.fullscreenElement) ||

View File

@@ -93,7 +93,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
</FieldRow>
)}
<Button type="submit" disabled={sending}>
{sending ? t("submitting") : "action.submit"}
{sending ? t("submitting") : t("action.submit")}
</Button>
</FieldRow>
)}

View File

@@ -186,8 +186,12 @@ export const SettingsModal: FC<Props> = (props) => {
</>
}
>
<h4>Developer</h4>
<p>Version: {(import.meta.env.VITE_APP_VERSION as string) || "dev"}</p>
<h4>{t("settings.developer_tab_title")}</h4>
<p>
{t("version", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<FieldRow>
<InputField
id="developerSettingsTab"
@@ -200,7 +204,7 @@ export const SettingsModal: FC<Props> = (props) => {
}
/>
</FieldRow>
<h4>Analytics</h4>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"

267
src/state/CallViewModel.ts Normal file
View File

@@ -0,0 +1,267 @@
/*
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 {
connectedParticipantsObserver,
observeParticipantMedia,
} from "@livekit/components-core";
import { Room as LivekitRoom, RemoteParticipant } from "livekit-client";
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useRef } from "react";
import {
EMPTY,
Observable,
combineLatest,
concat,
mergeAll,
of,
sample,
scan,
startWith,
takeUntil,
zip,
} from "rxjs";
import { state } from "@react-rxjs/core";
import { logger } from "matrix-js-sdk/src/logger";
import { ViewModel } from "./ViewModel";
import { useObservable } from "./useObservable";
import {
ECAddonConnectionState,
ECConnectionState,
} from "../livekit/useECConnectionState";
import { usePrevious } from "../usePrevious";
import {
TileViewModel,
UserMediaTileViewModel,
ScreenShareTileViewModel,
} from "./TileViewModel";
// Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed.
export interface TileDescriptor<T> {
id: string;
focused: boolean;
isPresenter: boolean;
isSpeaker: boolean;
hasVideo: boolean;
local: boolean;
largeBaseSize: boolean;
placeNear?: string;
data: T;
}
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
function findMatrixMember(
room: MatrixRoom,
id: string,
): RoomMember | undefined {
if (!id) return 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
if (parts.length < 3) {
logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination",
);
return undefined;
}
parts.pop();
const userId = parts.join(":");
return room.getMember(userId) ?? undefined;
}
// 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),
);
// Lists of participants to "hold" on display, even if LiveKit claims that
// they've left
private readonly remoteParticipantHolds = zip(
this.connectionState,
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
(s, ps) => {
// Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
// give their clients time to switch over and avoid jarring layout shifts
if (s === ECAddonConnectionState.ECSwitchingFocus) {
return concat(
// Hold these participants
of({ hold: ps }),
// Wait for time to pass and the connection state to have changed
Promise.all([
new Promise<void>((resolve) =>
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
),
new Promise<void>((resolve) => {
const subscription = this.connectionState
.pipe(takeUntil(this.destroyed))
.subscribe((s) => {
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
resolve();
subscription.unsubscribe();
}
});
}),
// Then unhold them
]).then(() => Promise.resolve({ unhold: ps })),
);
} else {
return EMPTY;
}
},
).pipe(
mergeAll(),
// Aggregate 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[][],
),
startWith([]),
);
private readonly remoteParticipants = combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
// Incorporate the held participants into the list
for (const hold of holds) {
for (const p of hold) {
if (!resultIds.has(p.identity)) {
result.push(p);
resultIds.add(p.identity);
}
}
}
return result;
},
);
public readonly tiles = 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 id = p.identity;
const member = findMatrixMember(this.matrixRoom, id);
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 (id !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`,
);
}
const userMediaTile: TileDescriptor<TileViewModel> = {
id,
focused: false,
isPresenter: p.isScreenShareEnabled,
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
hasVideo: p.isCameraEnabled,
local: p.isLocal,
largeBaseSize: false,
data:
tilesById.get(id)?.data ??
new UserMediaTileViewModel(id, member, p),
};
if (p.isScreenShareEnabled) {
const screenShareId = `${id}:screen-share`;
const screenShareTile: TileDescriptor<TileViewModel> = {
id: screenShareId,
focused: true,
isPresenter: false,
isSpeaker: false,
hasVideo: true,
local: p.isLocal,
largeBaseSize: true,
placeNear: id,
data:
tilesById.get(id)?.data ??
new ScreenShareTileViewModel(id, member, p),
};
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// 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<TileViewModel>[]),
),
);
public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom,
private readonly livekitRoom: LivekitRoom,
private readonly connectionState: Observable<ECConnectionState>,
) {
super();
}
}
export function useCallViewModel(
matrixRoom: MatrixRoom,
livekitRoom: LivekitRoom,
connectionState: ECConnectionState,
): CallViewModel {
const prevMatrixRoom = usePrevious(matrixRoom);
const prevLivekitRoom = usePrevious(livekitRoom);
const connectionStateObservable = useObservable(connectionState);
const vm = useRef<CallViewModel>();
if (matrixRoom !== prevMatrixRoom || livekitRoom !== prevLivekitRoom) {
vm.current?.destroy();
vm.current = new CallViewModel(
matrixRoom,
livekitRoom,
connectionStateObservable,
);
}
useEffect(() => vm.current?.destroy(), []);
return vm.current!;
}

View File

@@ -0,0 +1,53 @@
/*
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 { LocalParticipant, RemoteParticipant } from "livekit-client";
import { RoomMember } from "matrix-js-sdk/src/matrix";
export abstract class TileViewModel {
// TODO: Properly separate the data layer from the UI layer by keeping the
// member and LiveKit participant objects internal. The only LiveKit-specific
// thing we need to expose here is a TrackReference for the video, everything
// else should be simple strings, flags, and callbacks.
public abstract readonly id: string;
public abstract readonly member: RoomMember | undefined;
public abstract readonly sfuParticipant: LocalParticipant | RemoteParticipant;
}
// Right now it looks kind of pointless to have user media and screen share be
// represented by two classes rather than a single flag, but this will come in
// handy when we go to move more business logic out of VideoTile and into this
// file
export class UserMediaTileViewModel extends TileViewModel {
public constructor(
public readonly id: string,
public readonly member: RoomMember | undefined,
public readonly sfuParticipant: LocalParticipant | RemoteParticipant,
) {
super();
}
}
export class ScreenShareTileViewModel extends TileViewModel {
public constructor(
public readonly id: string,
public readonly member: RoomMember | undefined,
public readonly sfuParticipant: LocalParticipant | RemoteParticipant,
) {
super();
}
}

33
src/state/ViewModel.ts Normal file
View File

@@ -0,0 +1,33 @@
/*
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 { Subject } from "rxjs";
/**
* An MVVM view model.
*/
export abstract class ViewModel {
protected readonly destroyed = new Subject<void>();
/**
* Instructs the ViewModel to clean up its resources. If you forget to call
* this, there may be memory leaks!
*/
public destroy(): void {
this.destroyed.next();
this.destroyed.complete();
}
}

38
src/state/subscribe.tsx Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC } 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>(render: FC<P>): FC<P> {
const InnerComponent: FC<{ p: P }> = ({ p }) => (
<RemoveSubscribe>{render(p)}</RemoveSubscribe>
);
const OuterComponent: FC<P> = (p) => (
<Subscribe>
<InnerComponent p={p} />
</Subscribe>
);
// Copy over the component's display name, default props, etc.
Object.assign(OuterComponent, render);
return OuterComponent;
}

View File

@@ -0,0 +1,31 @@
/*
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 { useEffect, useRef } from "react";
import { BehaviorSubject, Observable } from "rxjs";
/**
* React hook that creates an Observable from a changing value. The Observable
* replays its current value upon subscription, emits whenever the value
* changes, and completes when the component is unmounted.
*/
export function useObservable<T>(value: T): Observable<T> {
const subject = useRef<BehaviorSubject<T>>();
subject.current ??= new BehaviorSubject(value);
if (value !== subject.current.value) subject.current.next(value);
useEffect(() => subject.current!.complete(), []);
return subject.current;
}

27
src/usePrevious.ts Normal file
View File

@@ -0,0 +1,27 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef } from "react";
/**
* React hook that returns the value given on the previous render.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
const previous = ref.current;
ref.current = value;
return previous;
}

View File

@@ -19,11 +19,11 @@ import { RectReadOnly } from "react-use-measure";
import { FC, memo, ReactNode } from "react";
import { zip } from "lodash";
import { TileDescriptor } from "./VideoGrid";
import { Slot } from "./NewVideoGrid";
import { Layout } from "./Layout";
import { count, findLastIndex } from "../array-utils";
import styles from "./BigGrid.module.css";
import { TileDescriptor } from "../state/CallViewModel";
/**
* A 1×1 cell in a grid which belongs to a tile.

View File

@@ -18,7 +18,7 @@ import { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react";
import type { RectReadOnly } from "react-use-measure";
import { useReactiveState } from "../useReactiveState";
import type { TileDescriptor } from "./VideoGrid";
import { TileDescriptor } from "../state/CallViewModel";
/**
* A video grid layout system with concrete states of type State.

View File

@@ -32,7 +32,6 @@ import styles from "./NewVideoGrid.module.css";
import {
VideoGridProps as Props,
TileSpring,
TileDescriptor,
ChildrenProperties,
} from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
@@ -40,6 +39,7 @@ 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;

View File

@@ -16,7 +16,6 @@ limitations under the License.
import {
ComponentProps,
Key,
MutableRefObject,
ReactNode,
Ref,
@@ -45,6 +44,7 @@ import styles from "./VideoGrid.module.css";
import { Layout } from "../room/LayoutToggle";
import { TileWrapper } from "./TileWrapper";
import { LayoutStatesMap } from "./Layout";
import { TileDescriptor } from "../state/CallViewModel";
interface TilePosition {
x: number;
@@ -54,7 +54,7 @@ interface TilePosition {
zIndex: number;
}
interface Tile<T> {
export interface Tile<T> {
key: string;
order: number;
item: TileDescriptor<T>;
@@ -728,7 +728,7 @@ function displayedTileCount(
// Sets the 'order' property on tiles based on the layout param and
// other properties of the tiles, eg. 'focused' and 'presenter'
function reorderTiles<T>(
export function reorderTiles<T>(
tiles: Tile<T>[],
layout: Layout,
displayedTile = -1,
@@ -750,7 +750,6 @@ function reorderTiles<T>(
} else {
const focusedTiles: Tile<T>[] = [];
const presenterTiles: Tile<T>[] = [];
const speakerTiles: Tile<T>[] = [];
const onlyVideoTiles: Tile<T>[] = [];
const otherTiles: Tile<T>[] = [];
@@ -763,8 +762,6 @@ function reorderTiles<T>(
focusedTiles.push(tile);
} else if (tile.isPresenter) {
presenterTiles.push(tile);
} else if (tile.isSpeaker && displayedTile < tile.order) {
speakerTiles.push(tile);
} else if (tile.hasVideo) {
if (tile.order === 0 && tile.item.local) {
firstLocalTile = tile;
@@ -788,20 +785,34 @@ function reorderTiles<T>(
}
}
[
const reorderedTiles = [
...focusedTiles,
...presenterTiles,
...speakerTiles,
...onlyVideoTiles,
...otherTiles,
].forEach((tile, i) => (tile.order = i));
];
let nextSpeakerTileIndex = focusedTiles.length + presenterTiles.length;
reorderedTiles.forEach((tile, i) => {
// If a speaker's natural ordering would place it outside the default
// visible area, promote them to the section dedicated to speakers
if (tile.isSpeaker && displayedTile <= i && nextSpeakerTileIndex < i) {
// Remove the tile from its current section
reorderedTiles.splice(i, 1);
// Insert it into the speaker section
reorderedTiles.splice(nextSpeakerTileIndex, 0, tile);
nextSpeakerTileIndex++;
}
});
reorderedTiles.forEach((tile, i) => (tile.order = i));
}
}
interface DragTileData {
offsetX: number;
offsetY: number;
key: Key;
key: string;
x: number;
y: number;
}
@@ -828,20 +839,6 @@ export interface VideoGridProps<T> {
children: (props: ChildrenProperties<T>) => ReactNode;
}
// Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed.
export interface TileDescriptor<T> {
id: string;
focused: boolean;
isPresenter: boolean;
isSpeaker: boolean;
hasVideo: boolean;
local: boolean;
largeBaseSize: boolean;
placeNear?: string;
data: T;
}
export function VideoGrid<T>({
items,
layout,
@@ -861,7 +858,7 @@ export function VideoGrid<T>({
});
const [scrollPosition, setScrollPosition] = useState<number>(0);
const draggingTileRef = useRef<DragTileData | null>(null);
const lastTappedRef = useRef<{ [index: Key]: number }>({});
const lastTappedRef = useRef<{ [index: string]: number }>({});
const lastLayoutRef = useRef<Layout>(layout);
const isMounted = useIsMounted();
@@ -876,7 +873,7 @@ export function VideoGrid<T>({
useEffect(() => {
setTileState(({ tiles, ...rest }) => {
const newTiles: Tile<T>[] = [];
const removedTileKeys: Set<Key> = new Set();
const removedTileKeys: Set<string> = new Set();
for (const tile of tiles) {
let item = items.find((item) => item.id === tile.key);
@@ -1138,7 +1135,7 @@ export function VideoGrid<T>({
]) as unknown as [SpringValues<TileSpring>[], SpringRef<TileSpring>];
const onTap = useCallback(
(tileKey: Key) => {
(tileKey: string) => {
const lastTapped = lastTappedRef.current[tileKey];
if (!lastTapped || Date.now() - lastTapped > 500) {

View File

@@ -38,6 +38,10 @@ limitations under the License.
height: 100%;
object-fit: cover;
background-color: var(--cpd-color-bg-subtle-primary);
/* This transform is a no-op, but it forces Firefox to use a different
rendering path, one that actually clips the corners of <video> elements into
the intended rounded shape. We can remove this if Firefox stops being broken. */
transform: translate(0);
}
.videoTile.isLocal:not(.screenshare) video {
@@ -88,11 +92,11 @@ limitations under the License.
flex-shrink: 0;
}
.nameTag > svg[data-muted="true"] {
.muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary);
}
.nameTag > svg[data-muted="false"] {
.muteIcon[data-muted="false"] {
color: var(--cpd-color-icon-primary);
}
@@ -103,6 +107,10 @@ limitations under the License.
padding-inline: var(--cpd-space-2x);
}
.errorIcon {
color: var(--cpd-color-icon-critical-primary);
}
.toolbar {
position: absolute;
top: 0;

View File

@@ -36,13 +36,20 @@ import {
} 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 { Text } from "@vector-im/compound-web";
import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
import { Text, Tooltip } from "@vector-im/compound-web";
import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css";
import { useReactiveState } from "../useReactiveState";
import { AudioButton, FullscreenButton } from "../button/Button";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { MatrixInfo } from "../room/VideoPreview";
import {
ScreenShareTileViewModel,
TileViewModel,
UserMediaTileViewModel,
} from "../state/TileViewModel";
export interface ItemData {
id: string;
@@ -57,7 +64,7 @@ export enum TileContent {
}
interface Props {
data: ItemData;
vm: TileViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
@@ -68,12 +75,15 @@ interface Props {
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean;
showConnectionStats: boolean;
// TODO: This dependency in particular should probably not be here. We can fix
// this with a view model.
matrixInfo: MatrixInfo;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
data,
vm,
maximised,
fullscreen,
onToggleFullscreen,
@@ -83,12 +93,13 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
targetHeight,
showSpeakingIndicator,
showConnectionStats,
matrixInfo,
},
tileRef,
) => {
const { t } = useTranslation();
const { content, sfuParticipant, member } = data;
const { id, sfuParticipant, member } = vm;
// Handle display name changes.
const [displayName, setDisplayName] = useReactiveState(
@@ -108,19 +119,28 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
}
}, [member, setDisplayName]);
const muted =
useMediaTrack(
content === TileContent.UserMedia
? Track.Source.Microphone
: Track.Source.ScreenShareAudio,
sfuParticipant,
).isMuted !== false;
const audioInfo = useMediaTrack(
vm instanceof UserMediaTileViewModel
? Track.Source.Microphone
: Track.Source.ScreenShareAudio,
sfuParticipant,
);
const videoInfo = useMediaTrack(
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare,
sfuParticipant,
);
const muted = audioInfo.isMuted !== false;
const encrypted =
audioInfo.publication?.isEncrypted !== false &&
videoInfo.publication?.isEncrypted !== false;
const MicIcon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const onFullscreen = useCallback(() => {
onToggleFullscreen(data.id);
}, [data, onToggleFullscreen]);
onToggleFullscreen(id);
}, [id, onToggleFullscreen]);
const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] =
useState(false);
@@ -144,7 +164,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
/>,
);
if (content === TileContent.ScreenShare) {
if (vm instanceof ScreenShareTileViewModel) {
toolbarButtons.push(
<FullscreenButton
key="fullscreen"
@@ -156,18 +176,15 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
}
}
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: sfuParticipant.isLocal,
[styles.speaking]:
sfuParticipant.isSpeaking &&
content === TileContent.UserMedia &&
vm instanceof UserMediaTileViewModel &&
showSpeakingIndicator,
[styles.screenshare]: content === TileContent.ScreenShare,
[styles.screenshare]: vm instanceof ScreenShareTileViewModel,
[styles.maximised]: maximised,
})}
style={style}
@@ -177,7 +194,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
)}
{content === TileContent.UserMedia &&
{vm instanceof UserMediaTileViewModel &&
!sfuParticipant.isCameraEnabled && (
<>
<div className={styles.videoMutedOverlay} />
@@ -191,7 +208,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
/>
</>
)}
{content === TileContent.ScreenShare ? (
{vm instanceof ScreenShareTileViewModel ? (
<div className={styles.presenterLabel}>
<span>{t("video_tile.presenter_label", { displayName })}</span>
</div>
@@ -202,12 +219,29 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
height={20}
aria-label={muted ? t("microphone_off") : t("microphone_on")}
data-muted={muted}
className={styles.muteIcon}
/>
<Text as="span" size="sm" weight="medium">
{sfuParticipant.isLocal
? t("video_tile.sfu_participant_local")
: displayName}
</Text>
{matrixInfo.roomEncrypted && !encrypted && (
<Tooltip label={t("common.unencrypted")} side="bottom">
<ErrorIcon
width={20}
height={20}
aria-label={t("common.unencrypted")}
className={styles.errorIcon}
// Make the icon focusable so that the tooltip can be opened
// with keyboard navigation
// TODO: Replace this with the solution from
// https://github.com/vector-im/compound-web/pull/130 once it
// lands
tabIndex={0}
/>
</Tooltip>
)}
{showConnectionStats && (
<ConnectionQualityIndicator participant={sfuParticipant} />
)}
@@ -216,14 +250,29 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
<VideoTrack
participant={sfuParticipant}
source={
content === TileContent.UserMedia
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare
}
// There's no reason for this to be focusable
tabIndex={-1}
// React supports the disablePictureInPicture attribute, but Firefox
// only recognizes a value of "true", whereas React sets it to the empty
// string. So we need to bypass React and set it specifically to "true".
// https://bugzilla.mozilla.org/show_bug.cgi?id=1865748
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
disablepictureinpicture="true"
/>
{!maximised && (
{!maximised && sfuParticipant instanceof RemoteParticipant && (
<VideoTileSettingsModal
data={data}
participant={sfuParticipant}
media={
vm instanceof UserMediaTileViewModel
? "user media"
: "screen share"
}
open={videoTileSettingsModalOpen}
onDismiss={closeVideoTileSettingsModal}
/>

View File

@@ -22,19 +22,18 @@ import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
import styles from "./VideoTileSettingsModal.module.css";
import { VolumeIcon } from "../button/VolumeIcon";
import { ItemData, TileContent } from "./VideoTile";
interface LocalVolumeProps {
participant: RemoteParticipant;
content: TileContent;
media: "user media" | "screen share";
}
const LocalVolume: FC<LocalVolumeProps> = ({
participant,
content,
media,
}: LocalVolumeProps) => {
const source =
content === TileContent.UserMedia
media === "user media"
? Track.Source.Microphone
: Track.Source.ScreenShareAudio;
@@ -67,13 +66,15 @@ const LocalVolume: FC<LocalVolumeProps> = ({
};
interface Props {
data: ItemData;
participant: RemoteParticipant;
media: "user media" | "screen share";
open: boolean;
onDismiss: () => void;
}
export const VideoTileSettingsModal: FC<Props> = ({
data,
participant,
media,
open,
onDismiss,
}) => {
@@ -87,10 +88,7 @@ export const VideoTileSettingsModal: FC<Props> = ({
onDismiss={onDismiss}
>
<div className={styles.content}>
<LocalVolume
participant={data.sfuParticipant as RemoteParticipant}
content={data.content}
/>
<LocalVolume participant={participant} media={media} />
</div>
</Modal>
);

View File

@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { TileDescriptor } from "../../src/state/CallViewModel";
import {
addItems,
column,
@@ -26,7 +27,6 @@ import {
row,
moveTile,
} from "../../src/video-grid/BigGrid";
import { TileDescriptor } from "../../src/video-grid/VideoGrid";
/**
* Builds a grid from a string specifying the contents of each cell as a letter.
@@ -315,7 +315,7 @@ jk`;
const grid = mkGrid(input);
let gridAfter = grid;
const toggle = (tileId: string) => {
const toggle = (tileId: string): void => {
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
gridAfter = cycleTileSize(gridAfter, tile);
};

View File

@@ -0,0 +1,69 @@
/*
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 }),
]);
});

2120
yarn.lock

File diff suppressed because it is too large Load Diff