Compare commits

..

69 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
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
renovate[bot]
a0cdafcd7c Update dependency @types/jest to v29.5.10 2023-11-29 13:06:10 +00:00
renovate[bot]
2d8aaf0a3b Update dependency @opentelemetry/instrumentation-document-load to ^0.34.0 2023-11-29 12:06:25 +00:00
renovate[bot]
9746764f81 Update dependency @testing-library/react to v14.1.2 2023-11-17 19:15:15 +00:00
renovate[bot]
0a2ea8315b Update dependency @types/sdp-transform to v2.4.9 2023-11-08 08:52:46 +00:00
31 changed files with 1825 additions and 1464 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

@@ -62,7 +62,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@2a4836ac76fe8f5d0ee3a0d89aa12a80cc552ad3
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@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56
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

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

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

@@ -26,7 +26,6 @@ import {
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { Observable } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
import {
isFirefox,
@@ -83,14 +82,7 @@ function useMediaDevice(
// Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests.
const deviceObserver = useMemo(
() =>
createMediaDeviceObserver(
kind,
() => {
logger.error("Error creating MediaDeviceObserver");
},
requestPermissions,
),
() => createMediaDeviceObserver(kind, requestPermissions),
[kind, requestPermissions],
);
const available = useObservableState(deviceObserver, []);
@@ -102,8 +94,8 @@ function useMediaDevice(
devId = available.some((d) => d.deviceId === selectedId)
? selectedId
: available.some((d) => d.deviceId === fallbackDevice)
? fallbackDevice
: available.at(0)?.deviceId;
? fallbackDevice
: available.at(0)?.deviceId;
}
return {

View File

@@ -248,6 +248,6 @@ export function useECConnectionState(
return isSwitchingFocus
? ECAddonConnectionState.ECSwitchingFocus
: isInDoConnect
? ConnectionState.Connecting
: connState;
? ConnectionState.Connecting
: connState;
}

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;
@@ -137,470 +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),
);
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}
matrixInfo={matrixInfo}
/>
};
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}
matrixInfo={matrixInfo}
{...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={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 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(() => {
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 spokeRecently =
sfuParticipant.lastSpokeAt !== undefined &&
sfuParticipant.lastSpokeAt > speakActiveTime;
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 || spokeRecently) &&
!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,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>
)}

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;
@@ -812,7 +812,7 @@ export function reorderTiles<T>(
interface DragTileData {
offsetX: number;
offsetY: number;
key: Key;
key: string;
x: number;
y: number;
}
@@ -839,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,
@@ -872,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();
@@ -887,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);
@@ -1149,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 {

View File

@@ -45,6 +45,11 @@ 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;
@@ -59,7 +64,7 @@ export enum TileContent {
}
interface Props {
data: ItemData;
vm: TileViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
@@ -78,7 +83,7 @@ interface Props {
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
data,
vm,
maximised,
fullscreen,
onToggleFullscreen,
@@ -94,7 +99,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
) => {
const { t } = useTranslation();
const { content, sfuParticipant, member } = data;
const { id, sfuParticipant, member } = vm;
// Handle display name changes.
const [displayName, setDisplayName] = useReactiveState(
@@ -115,13 +120,13 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
}, [member, setDisplayName]);
const audioInfo = useMediaTrack(
content === TileContent.UserMedia
vm instanceof UserMediaTileViewModel
? Track.Source.Microphone
: Track.Source.ScreenShareAudio,
sfuParticipant,
);
const videoInfo = useMediaTrack(
content === TileContent.UserMedia
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare,
sfuParticipant,
@@ -134,8 +139,8 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
const MicIcon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const onFullscreen = useCallback(() => {
onToggleFullscreen(data.id);
}, [data, onToggleFullscreen]);
onToggleFullscreen(id);
}, [id, onToggleFullscreen]);
const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] =
useState(false);
@@ -159,7 +164,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
/>,
);
if (content === TileContent.ScreenShare) {
if (vm instanceof ScreenShareTileViewModel) {
toolbarButtons.push(
<FullscreenButton
key="fullscreen"
@@ -177,9 +182,9 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
[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}
@@ -189,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} />
@@ -203,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>
@@ -245,7 +250,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
<VideoTrack
participant={sfuParticipant}
source={
content === TileContent.UserMedia
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare
}
@@ -260,9 +265,14 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
// 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

@@ -14,11 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {
Tile,
TileDescriptor,
reorderTiles,
} from "../../src/video-grid/VideoGrid";
import { TileDescriptor } from "../../src/state/CallViewModel";
import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid";
const alice: Tile<unknown> = {
key: "alice",

1891
yarn.lock

File diff suppressed because it is too large Load Diff