mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
2 Commits
fkwp/doc_s
...
toger5/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f47892f0a | ||
|
|
6940cb1a96 |
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
playwright:
|
playwright:
|
||||||
name: Run end-to-end tests
|
name: Run end-to-end tests
|
||||||
timeout-minutes: 30
|
timeout-minutes: 10
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
@@ -49,9 +49,9 @@ jobs:
|
|||||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||||
docker ps
|
docker ps
|
||||||
|
- name: Copy config file
|
||||||
|
run: cp config/config.devenv.json public/config.json
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
env:
|
|
||||||
USE_DOCKER: 1
|
|
||||||
run: yarn playwright test
|
run: yarn playwright test
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ COPY ./dist /dist
|
|||||||
WORKDIR /dist/assets
|
WORKDIR /dist/assets
|
||||||
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
|
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:alpine-slim
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
COPY --from=builder ./dist /app
|
COPY --from=builder ./dist /app
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,3 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
|
|||||||
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
|
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
|
||||||
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
|
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
|
||||||
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
|
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
|
||||||
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
|
|
||||||
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
|
|
||||||
In particular android is using it to setup the output channel so that the call volume can
|
|
||||||
be controlled by the hardware volume rocker.
|
|
||||||
|
|||||||
@@ -255,17 +255,13 @@ self-hosters and developers working with Element Call.
|
|||||||
|
|
||||||
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
|
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
|
||||||
|
|
||||||
## 📝 How-Tos & Tutorials
|
## 🛠️ How-Tos & Tutorials
|
||||||
|
|
||||||
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
|
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
|
||||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||||
|
|
||||||
## 🛠️ Tools
|
|
||||||
|
|
||||||
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
|
|
||||||
|
|
||||||
## 🤝 Want to Contribute?
|
## 🤝 Want to Contribute?
|
||||||
|
|
||||||
Have a guide or blog post you'd like to share? Open a
|
Have a guide or blog post you'd like to share? Open a
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||||
|
|
||||||
[versions]
|
[versions]
|
||||||
android_gradle_plugin = "8.10.1"
|
android_gradle_plugin = "8.10.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }
|
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mavenPublishing {
|
mavenPublishing {
|
||||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
|
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
|
||||||
|
|
||||||
signAllPublications()
|
signAllPublications()
|
||||||
|
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -54,7 +54,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||||
"@react-spring/web": "^10.0.0",
|
"@react-spring/web": "^9.4.4",
|
||||||
"@sentry/react": "^8.0.0",
|
"@sentry/react": "^8.0.0",
|
||||||
"@sentry/vite-plugin": "^3.0.0",
|
"@sentry/vite-plugin": "^3.0.0",
|
||||||
"@stylistic/eslint-plugin": "^3.0.0",
|
"@stylistic/eslint-plugin": "^3.0.0",
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/sdp-transform": "^2.4.5",
|
"@types/sdp-transform": "^2.4.5",
|
||||||
"@types/uuid": "10",
|
"@types/uuid": "10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||||
@@ -83,7 +83,6 @@
|
|||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
|
||||||
"eslint": "^8.14.0",
|
"eslint": "^8.14.0",
|
||||||
"eslint-config-google": "^0.14.0",
|
"eslint-config-google": "^0.14.0",
|
||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
@@ -114,10 +113,11 @@
|
|||||||
"posthog-js": "1.160.3",
|
"posthog-js": "1.160.3",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19",
|
"react": "18",
|
||||||
"react-dom": "19",
|
"react-dom": "18",
|
||||||
"react-i18next": "^15.0.0",
|
"react-i18next": "^15.0.0",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
|
"react-use-clipboard": "^1.0.7",
|
||||||
"react-use-measure": "^2.1.1",
|
"react-use-measure": "^2.1.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sass": "^1.42.1",
|
"sass": "^1.42.1",
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = process.env.USE_DOCKER
|
|
||||||
? "http://localhost:8080"
|
|
||||||
: "https://localhost:3000";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +25,7 @@ export default defineConfig({
|
|||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL,
|
baseURL: "https://localhost:3000",
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
@@ -77,13 +73,9 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "./scripts/playwright-webserver-command.sh",
|
command: "yarn dev",
|
||||||
url: baseURL,
|
url: "https://localhost:3000",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
gracefulShutdown: {
|
|
||||||
signal: "SIGTERM",
|
|
||||||
timeout: 500,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,27 +69,16 @@ const CONFIG_JSON = {
|
|||||||
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
||||||
* @param page
|
* @param page
|
||||||
*/
|
*/
|
||||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
|
||||||
? async (page: Page): Promise<void> => {
|
await page.evaluate(() => {
|
||||||
await page.evaluate(() => {
|
window.mxSettingsStore.setValue(
|
||||||
window.mxSettingsStore.setValue(
|
"Developer.elementCallUrl",
|
||||||
"Developer.elementCallUrl",
|
null,
|
||||||
null,
|
"device",
|
||||||
"device",
|
"https://localhost:3000/room",
|
||||||
"http://localhost:8080/room",
|
);
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
: async (page: Page): Promise<void> => {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.mxSettingsStore.setValue(
|
|
||||||
"Developer.elementCallUrl",
|
|
||||||
null,
|
|
||||||
"device",
|
|
||||||
"https://localhost:3000/room",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a new user and returns page, clientHandle and mxId.
|
* Registers a new user and returns page, clientHandle and mxId.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
if [ -n "$USE_DOCKER" ]; then
|
|
||||||
set -ex
|
|
||||||
yarn build
|
|
||||||
docker build -t element-call:testing .
|
|
||||||
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
|
|
||||||
else
|
|
||||||
cp config/config.devenv.json public/config.json
|
|
||||||
exec yarn dev
|
|
||||||
fi
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
exports[`the content is rendered when the modal is open 1`] = `
|
exports[`the content is rendered when the modal is open 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-labelledby="radix-«r4»"
|
aria-labelledby="radix-:r4:"
|
||||||
class="overlay animate modal dialog _glass_sepwu_8"
|
class="overlay animate modal dialog _glass_sepwu_8"
|
||||||
data-state="open"
|
data-state="open"
|
||||||
id="radix-«r3»"
|
id="radix-:r3:"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
style="pointer-events: auto;"
|
style="pointer-events: auto;"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
|||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||||
id="radix-«r4»"
|
id="radix-:r4:"
|
||||||
>
|
>
|
||||||
My modal
|
My modal
|
||||||
</h2>
|
</h2>
|
||||||
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
|||||||
|
|
||||||
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-labelledby="radix-«ra»"
|
aria-labelledby="radix-:ra:"
|
||||||
class="overlay modal drawer"
|
class="overlay modal drawer"
|
||||||
data-state="open"
|
data-state="open"
|
||||||
data-vaul-animate="true"
|
data-vaul-animate="true"
|
||||||
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
|||||||
data-vaul-drawer=""
|
data-vaul-drawer=""
|
||||||
data-vaul-drawer-direction="bottom"
|
data-vaul-drawer-direction="bottom"
|
||||||
data-vaul-snap-points="false"
|
data-vaul-snap-points="false"
|
||||||
id="radix-«r9»"
|
id="radix-:r9:"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
style="pointer-events: auto;"
|
style="pointer-events: auto;"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
|||||||
class="handle"
|
class="handle"
|
||||||
/>
|
/>
|
||||||
<h2
|
<h2
|
||||||
id="radix-«ra»"
|
id="radix-:ra:"
|
||||||
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
|
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
|
||||||
>
|
>
|
||||||
My modal
|
My modal
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
exports[`Toast > renders 1`] = `
|
exports[`Toast > renders 1`] = `
|
||||||
<button
|
<button
|
||||||
aria-labelledby="radix-«r4»"
|
aria-labelledby="radix-:r4:"
|
||||||
class="overlay animate toast"
|
class="overlay animate toast"
|
||||||
data-state="open"
|
data-state="open"
|
||||||
id="radix-«r3»"
|
id="radix-:r3:"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
style="pointer-events: auto;"
|
style="pointer-events: auto;"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
||||||
id="radix-«r4»"
|
id="radix-:r4:"
|
||||||
>
|
>
|
||||||
Hello world!
|
Hello world!
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-labelledby="«rb5»"
|
aria-labelledby=":rb5:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-labelledby="«r7m»"
|
aria-labelledby=":r7m:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -74,7 +74,7 @@ exports[`Can lower hand 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-labelledby="«r36»"
|
aria-labelledby=":r36:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -108,7 +108,7 @@ exports[`Can open menu 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-labelledby="«r0»"
|
aria-labelledby=":r0:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -139,7 +139,7 @@ exports[`Can raise hand 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-labelledby="«r1j»"
|
aria-labelledby=":r1j:"
|
||||||
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface Controls {
|
|||||||
/** @deprecated use onAudioDeviceSelect instead*/
|
/** @deprecated use onAudioDeviceSelect instead*/
|
||||||
onOutputDeviceSelect?: (id: string) => void;
|
onOutputDeviceSelect?: (id: string) => void;
|
||||||
onAudioDeviceSelect?: (id: string) => void;
|
onAudioDeviceSelect?: (id: string) => void;
|
||||||
onAudioPlaybackStarted?: () => void;
|
|
||||||
/** @deprecated use setAudioEnabled instead*/
|
/** @deprecated use setAudioEnabled instead*/
|
||||||
setOutputEnabled(enabled: boolean): void;
|
setOutputEnabled(enabled: boolean): void;
|
||||||
setAudioEnabled(enabled: boolean): void;
|
setAudioEnabled(enabled: boolean): void;
|
||||||
@@ -55,13 +54,7 @@ export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
|
|||||||
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
|
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
|
||||||
*/
|
*/
|
||||||
export const setAudioEnabled$ = new Subject<boolean>();
|
export const setAudioEnabled$ = new Subject<boolean>();
|
||||||
let playbackStartedEmitted = false;
|
|
||||||
export const setPlaybackStarted = (): void => {
|
|
||||||
if (!playbackStartedEmitted) {
|
|
||||||
playbackStartedEmitted = true;
|
|
||||||
window.controls.onAudioPlaybackStarted?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.controls = {
|
window.controls = {
|
||||||
canEnterPip(): boolean {
|
canEnterPip(): boolean {
|
||||||
return setPipEnabled$.observed;
|
return setPipEnabled$.observed;
|
||||||
|
|||||||
@@ -6,70 +6,58 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import {
|
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||||
setLocalStorageItemReactive,
|
import { type UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||||
useLocalStorage,
|
|
||||||
} from "../useLocalStorage";
|
|
||||||
import { getUrlParams } from "../UrlParams";
|
|
||||||
import { E2eeType } from "./e2eeType";
|
import { E2eeType } from "./e2eeType";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
|
|
||||||
/**
|
|
||||||
* This setter will update the state for all `useRoomSharedKey` hooks
|
|
||||||
* if the password is different from the one in local storage or if its not yet in the local storage.
|
|
||||||
*/
|
|
||||||
export function saveKeyForRoom(roomId: string, password: string): void {
|
export function saveKeyForRoom(roomId: string, password: string): void {
|
||||||
if (
|
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||||
localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) !== password
|
|
||||||
)
|
|
||||||
setLocalStorageItemReactive(
|
|
||||||
getRoomSharedKeyLocalStorageKey(roomId),
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||||
`room-shared-key-${roomId}`;
|
`room-shared-key-${roomId}`;
|
||||||
|
|
||||||
/**
|
const useInternalRoomSharedKey = (roomId: string): string | null => {
|
||||||
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||||
* @param roomId
|
const [roomSharedKey] = useLocalStorage(key);
|
||||||
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
|
|
||||||
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
|
|
||||||
*/
|
|
||||||
const useRoomSharedKey = (
|
|
||||||
roomId: string,
|
|
||||||
setInitialValue?: string,
|
|
||||||
): [string | null, setKey: (key: string) => void] => {
|
|
||||||
const [roomSharedKey, setRoomSharedKey] = useLocalStorage(
|
|
||||||
getRoomSharedKeyLocalStorageKey(roomId),
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
// If setInitialValue is available, update the local storage (usually the password from the url).
|
|
||||||
// This will update roomSharedKey but wont update the returned value since
|
|
||||||
// that already defaults to setInitialValue.
|
|
||||||
if (setInitialValue) setRoomSharedKey(setInitialValue);
|
|
||||||
}, [setInitialValue, setRoomSharedKey]);
|
|
||||||
|
|
||||||
// make sure we never return the initial null value from `useLocalStorage`
|
return roomSharedKey;
|
||||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getKeyForRoom(roomId: string): string | null {
|
export function getKeyForRoom(roomId: string): string | null {
|
||||||
const { roomId: urlRoomId, password } = getUrlParams();
|
saveKeyFromUrlParams(getUrlParams());
|
||||||
if (roomId !== urlRoomId)
|
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||||
logger.warn(
|
return localStorage.getItem(key);
|
||||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
|
||||||
roomId,
|
|
||||||
urlRoomId,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
* @returns A tuple of the roomId and password from the URL if the URL has both,
|
||||||
|
* otherwise [undefined, undefined]
|
||||||
|
*/
|
||||||
|
const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
|
||||||
|
const urlParams = useUrlParams();
|
||||||
|
|
||||||
|
useEffect(() => saveKeyFromUrlParams(urlParams), [urlParams]);
|
||||||
|
|
||||||
|
return urlParams.roomId && urlParams.password
|
||||||
|
? [urlParams.roomId, urlParams.password]
|
||||||
|
: [undefined, undefined];
|
||||||
|
};
|
||||||
|
|
||||||
export type Unencrypted = { kind: E2eeType.NONE };
|
export type Unencrypted = { kind: E2eeType.NONE };
|
||||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||||
@@ -78,11 +66,12 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
|||||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
|
||||||
const [storedPassword] = useRoomSharedKey(
|
// make sure we've extracted the key from the URL first
|
||||||
getRoomSharedKeyLocalStorageKey(roomId),
|
// (and we still need to take the value it returns because
|
||||||
getKeyForRoom(roomId) ?? undefined,
|
// the effect won't run in time for it to save to localstorage in
|
||||||
);
|
// time for us to read it out again).
|
||||||
|
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||||
|
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||||
const room = client?.getRoom(roomId);
|
const room = client?.getRoom(roomId);
|
||||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||||
if (!room) return { kind: E2eeType.NONE };
|
if (!room) return { kind: E2eeType.NONE };
|
||||||
@@ -91,10 +80,15 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
|||||||
kind: E2eeType.SHARED_KEY,
|
kind: E2eeType.SHARED_KEY,
|
||||||
secret: storedPassword,
|
secret: storedPassword,
|
||||||
};
|
};
|
||||||
|
if (urlRoomId === roomId)
|
||||||
|
return {
|
||||||
|
kind: E2eeType.SHARED_KEY,
|
||||||
|
secret: passwordFromUrl,
|
||||||
|
};
|
||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
return { kind: E2eeType.PER_PARTICIPANT };
|
return { kind: E2eeType.PER_PARTICIPANT };
|
||||||
}
|
}
|
||||||
return { kind: E2eeType.NONE };
|
return { kind: E2eeType.NONE };
|
||||||
}, [room, storedPassword]);
|
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||||
return e2eeSystem;
|
return e2eeSystem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,9 +81,8 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roomIsJoinable = (room: Room): boolean => {
|
const roomIsJoinable = (room: Room): boolean => {
|
||||||
const password = getKeyForRoom(room.roomId);
|
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||||
if (!room.hasEncryptionStateEvent() && !password) {
|
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||||
// if we have a non encrypted room (no encryption state event) we need a locally stored shared key.
|
|
||||||
// in case this key also does not exists we cannot join the room.
|
// in case this key also does not exists we cannot join the room.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
|
|
||||||
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
|
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import * as controls from "../controls";
|
|
||||||
export interface MatrixAudioRendererProps {
|
export interface MatrixAudioRendererProps {
|
||||||
/**
|
/**
|
||||||
* The list of participants to render audio for.
|
* The list of participants to render audio for.
|
||||||
@@ -204,7 +204,6 @@ function AudioTrackWithAudioNodes({
|
|||||||
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
||||||
);
|
);
|
||||||
setTrackReady(true);
|
setTrackReady(true);
|
||||||
controls.setPlaybackStarted();
|
|
||||||
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ import {
|
|||||||
type JSX,
|
type JSX,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||||
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
|
import { combineLatest, map, startWith } from "rxjs";
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useSetting,
|
useSetting,
|
||||||
@@ -141,16 +140,7 @@ function useMediaDeviceHandle(
|
|||||||
kind,
|
kind,
|
||||||
() => logger.error("Error creating MediaDeviceObserver"),
|
() => logger.error("Error creating MediaDeviceObserver"),
|
||||||
requestPermissions,
|
requestPermissions,
|
||||||
).pipe(
|
).pipe(startWith([])),
|
||||||
startWith([]),
|
|
||||||
// This Observable emits new values whenever the browser fires a
|
|
||||||
// MediaDevices 'devicechange' event. One would think, innocently, that
|
|
||||||
// a 'devicechange' event means the devices have changed. But as of the
|
|
||||||
// time of writing, we are seeing mobile Safari firing spurious
|
|
||||||
// 'devicechange' events (where no change has actually occurred) when
|
|
||||||
// we call MediaDevices.getUserMedia. So, filter by deep equality.
|
|
||||||
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
|
|
||||||
),
|
|
||||||
[kind, requestPermissions],
|
[kind, requestPermissions],
|
||||||
);
|
);
|
||||||
const available = useObservableEagerState(
|
const available = useObservableEagerState(
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import {
|
|||||||
supportsBackgroundProcessors,
|
supportsBackgroundProcessors,
|
||||||
type BackgroundOptions,
|
type BackgroundOptions,
|
||||||
} from "@livekit/track-processors";
|
} from "@livekit/track-processors";
|
||||||
import {
|
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
|
||||||
createContext,
|
|
||||||
type FC,
|
|
||||||
type JSX,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
} from "react";
|
|
||||||
import { type LocalVideoTrack } from "livekit-client";
|
import { type LocalVideoTrack } from "livekit-client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -24,7 +24,11 @@ import { map } from "rxjs";
|
|||||||
import { defaultLiveKitOptions } from "./options";
|
import { defaultLiveKitOptions } from "./options";
|
||||||
import { type SFUConfig } from "./openIDSFU";
|
import { type SFUConfig } from "./openIDSFU";
|
||||||
import { type MuteStates } from "../room/MuteStates";
|
import { type MuteStates } from "../room/MuteStates";
|
||||||
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
|
import {
|
||||||
|
type MediaDeviceHandle,
|
||||||
|
type MediaDevices,
|
||||||
|
useMediaDevices,
|
||||||
|
} from "./MediaDevicesContext";
|
||||||
import {
|
import {
|
||||||
type ECConnectionState,
|
type ECConnectionState,
|
||||||
useECConnectionState,
|
useECConnectionState,
|
||||||
@@ -36,9 +40,9 @@ import {
|
|||||||
useTrackProcessor,
|
useTrackProcessor,
|
||||||
useTrackProcessorSync,
|
useTrackProcessorSync,
|
||||||
} from "./TrackProcessorContext";
|
} from "./TrackProcessorContext";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
import { observeTrackReference$ } from "../state/MediaViewModel";
|
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { useInitial } from "../useInitial";
|
|
||||||
|
|
||||||
interface UseLivekitResult {
|
interface UseLivekitResult {
|
||||||
livekitRoom?: Room;
|
livekitRoom?: Room;
|
||||||
@@ -53,78 +57,27 @@ export function useLivekit(
|
|||||||
): UseLivekitResult {
|
): UseLivekitResult {
|
||||||
const { controlledAudioDevices } = useUrlParams();
|
const { controlledAudioDevices } = useUrlParams();
|
||||||
|
|
||||||
const initialMuteStates = useInitial(() => muteStates);
|
const e2eeOptions = useMemo((): E2EEManagerOptions | undefined => {
|
||||||
|
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
|
||||||
const initialDevices = useInitial(() => devices);
|
|
||||||
|
|
||||||
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
|
||||||
// to setMicrophoneEnabled/setCameraEnabled
|
|
||||||
const audioMuteUpdating = useRef(false);
|
|
||||||
const videoMuteUpdating = useRef(false);
|
|
||||||
// Store the current button mute state that gets passed to this hook via props.
|
|
||||||
// We need to store it for awaited code that relies on the current value.
|
|
||||||
const buttonEnabled = useRef({
|
|
||||||
audio: initialMuteStates.audio.enabled,
|
|
||||||
video: initialMuteStates.video.enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { processor } = useTrackProcessor();
|
|
||||||
|
|
||||||
// Only ever create the room once via useInitial.
|
|
||||||
const room = useInitial(() => {
|
|
||||||
logger.info("[LivekitRoom] Create LiveKit room");
|
|
||||||
|
|
||||||
let e2ee: E2EEManagerOptions | undefined;
|
|
||||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
logger.info("Created MatrixKeyProvider (per participant)");
|
logger.info("Created MatrixKeyProvider (per participant)");
|
||||||
e2ee = {
|
return {
|
||||||
keyProvider: new MatrixKeyProvider(),
|
keyProvider: new MatrixKeyProvider(),
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
};
|
};
|
||||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
||||||
e2ee = {
|
|
||||||
|
return {
|
||||||
keyProvider: new ExternalE2EEKeyProvider(),
|
keyProvider: new ExternalE2EEKeyProvider(),
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}, [e2eeSystem]);
|
||||||
|
|
||||||
const roomOptions: RoomOptions = {
|
|
||||||
...defaultLiveKitOptions,
|
|
||||||
videoCaptureDefaults: {
|
|
||||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
|
||||||
deviceId: initialDevices.videoInput.selectedId,
|
|
||||||
processor,
|
|
||||||
},
|
|
||||||
audioCaptureDefaults: {
|
|
||||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
|
||||||
deviceId: initialDevices.audioInput.selectedId,
|
|
||||||
},
|
|
||||||
audioOutput: {
|
|
||||||
deviceId: initialDevices.audioOutput.selectedId,
|
|
||||||
},
|
|
||||||
e2ee,
|
|
||||||
};
|
|
||||||
// We have to create the room manually here due to a bug inside
|
|
||||||
// @livekit/components-react. JSON.stringify() is used in deps of a
|
|
||||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
|
||||||
const room = new Room(roomOptions);
|
|
||||||
room.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
|
||||||
logger.error("Failed to set E2EE enabled on room", e);
|
|
||||||
});
|
|
||||||
|
|
||||||
return room;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup and update the keyProvider which was create by `createRoom`
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const e2eeOptions = room.options.e2ee;
|
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||||
if (
|
|
||||||
e2eeSystem.kind === E2eeType.NONE ||
|
|
||||||
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||||
@@ -135,7 +88,56 @@ export function useLivekit(
|
|||||||
logger.error("Failed to set shared key for E2EE", e);
|
logger.error("Failed to set shared key for E2EE", e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [room.options.e2ee, e2eeSystem, rtcSession]);
|
}, [e2eeOptions, e2eeSystem, rtcSession]);
|
||||||
|
|
||||||
|
const initialMuteStates = useRef<MuteStates>(muteStates);
|
||||||
|
const devices = useMediaDevices();
|
||||||
|
const initialDevices = useRef<MediaDevices>(devices);
|
||||||
|
|
||||||
|
const { processor } = useTrackProcessor();
|
||||||
|
const initialProcessor = useInitial(() => processor);
|
||||||
|
const roomOptions = useMemo(
|
||||||
|
(): RoomOptions => ({
|
||||||
|
...defaultLiveKitOptions,
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||||
|
deviceId: initialDevices.current.videoInput.selectedId,
|
||||||
|
processor: initialProcessor,
|
||||||
|
},
|
||||||
|
audioCaptureDefaults: {
|
||||||
|
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||||
|
deviceId: initialDevices.current.audioInput.selectedId,
|
||||||
|
},
|
||||||
|
audioOutput: {
|
||||||
|
deviceId: initialDevices.current.audioOutput.selectedId,
|
||||||
|
},
|
||||||
|
e2ee: e2eeOptions,
|
||||||
|
}),
|
||||||
|
[e2eeOptions, initialProcessor],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
||||||
|
// to setMicrophoneEnabled/setCameraEnabled
|
||||||
|
const audioMuteUpdating = useRef(false);
|
||||||
|
const videoMuteUpdating = useRef(false);
|
||||||
|
// Store the current button mute state that gets passed to this hook via props.
|
||||||
|
// We need to store it for awaited code that relies on the current value.
|
||||||
|
const buttonEnabled = useRef({
|
||||||
|
audio: initialMuteStates.current.audio.enabled,
|
||||||
|
video: initialMuteStates.current.video.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We have to create the room manually here due to a bug inside
|
||||||
|
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||||
|
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||||
|
const room = useMemo(() => {
|
||||||
|
logger.info("[LivekitRooms] Create LiveKit room with options", roomOptions);
|
||||||
|
const r = new Room(roomOptions);
|
||||||
|
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
||||||
|
logger.error("Failed to set E2EE enabled on room", e);
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}, [roomOptions, e2eeSystem]);
|
||||||
|
|
||||||
// Sync the requested track processors with LiveKit
|
// Sync the requested track processors with LiveKit
|
||||||
useTrackProcessorSync(
|
useTrackProcessorSync(
|
||||||
@@ -157,8 +159,8 @@ export function useLivekit(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const connectionState = useECConnectionState(
|
const connectionState = useECConnectionState(
|
||||||
initialDevices.audioInput.selectedId,
|
initialDevices.current.audioInput.selectedId,
|
||||||
initialMuteStates.audio.enabled,
|
initialMuteStates.current.audio.enabled,
|
||||||
room,
|
room,
|
||||||
sfuConfig,
|
sfuConfig,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { useAudioContext } from "../useAudioContext";
|
|||||||
import { ActiveCall } from "./InCallView";
|
import { ActiveCall } from "./InCallView";
|
||||||
import {
|
import {
|
||||||
flushPromises,
|
flushPromises,
|
||||||
mockEmitter,
|
|
||||||
mockMatrixRoom,
|
mockMatrixRoom,
|
||||||
mockMatrixRoomMember,
|
mockMatrixRoomMember,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
@@ -130,7 +129,6 @@ function createGroupCallView(
|
|||||||
getMxcAvatarUrl: () => null,
|
getMxcAvatarUrl: () => null,
|
||||||
getCanonicalAlias: () => null,
|
getCanonicalAlias: () => null,
|
||||||
currentState: {
|
currentState: {
|
||||||
...mockEmitter(),
|
|
||||||
getJoinRule: () => JoinRule.Invite,
|
getJoinRule: () => JoinRule.Invite,
|
||||||
} as Partial<RoomState> as RoomState,
|
} as Partial<RoomState> as RoomState,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
|||||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||||
import {
|
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
saveKeyForRoom,
|
|
||||||
useRoomEncryptionSystem,
|
|
||||||
} from "../e2ee/sharedKeyManagement";
|
|
||||||
import { useRoomAvatar } from "./useRoomAvatar";
|
import { useRoomAvatar } from "./useRoomAvatar";
|
||||||
import { useRoomName } from "./useRoomName";
|
import { useRoomName } from "./useRoomName";
|
||||||
import { useJoinRule } from "./useJoinRule";
|
import { useJoinRule } from "./useJoinRule";
|
||||||
@@ -170,12 +167,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
useExperimentalToDeviceTransportSetting,
|
useExperimentalToDeviceTransportSetting,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save the password once we start the groupCallView
|
|
||||||
const { password: passwordFromUrl } = useUrlParams();
|
|
||||||
useEffect(() => {
|
|
||||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
|
||||||
}, [passwordFromUrl, room.roomId]);
|
|
||||||
|
|
||||||
usePageTitle(roomName);
|
usePageTitle(roomName);
|
||||||
|
|
||||||
const matrixInfo = useMemo((): MatrixInfo => {
|
const matrixInfo = useMemo((): MatrixInfo => {
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ import { ConnectionLostError } from "../utils/errors.ts";
|
|||||||
import { useTypedEventEmitter } from "../useEvents.ts";
|
import { useTypedEventEmitter } from "../useEvents.ts";
|
||||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -148,7 +147,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
|
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [livekitRoom]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (livekitRoom !== undefined) {
|
if (livekitRoom !== undefined) {
|
||||||
@@ -250,7 +250,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
useExperimentalToDeviceTransportSetting,
|
useExperimentalToDeviceTransportSetting,
|
||||||
);
|
);
|
||||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
|
||||||
|
|
||||||
const showToDeviceEncryption = useMemo(
|
const showToDeviceEncryption = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -724,7 +723,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
|
<MatrixAudioRenderer
|
||||||
|
members={rtcSession.memberships}
|
||||||
|
muted={muteAllAudio}
|
||||||
|
/>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
LinkIcon,
|
LinkIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import copy from "copy-to-clipboard";
|
import useClipboard from "react-use-clipboard";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||||
@@ -42,17 +42,18 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
|||||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||||
[e2eeSystem, room.name, room.roomId],
|
[e2eeSystem, room.name, room.roomId],
|
||||||
);
|
);
|
||||||
|
const [, setCopied] = useClipboard(url);
|
||||||
const [toastOpen, setToastOpen] = useState(false);
|
const [toastOpen, setToastOpen] = useState(false);
|
||||||
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
||||||
|
|
||||||
const onButtonClick = useCallback(
|
const onButtonClick = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
copy(url);
|
setCopied();
|
||||||
onDismiss();
|
onDismiss();
|
||||||
setToastOpen(true);
|
setToastOpen(true);
|
||||||
},
|
},
|
||||||
[url, onDismiss],
|
[setCopied, onDismiss],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
useTrackProcessorSync,
|
useTrackProcessorSync,
|
||||||
} from "../livekit/TrackProcessorContext";
|
} from "../livekit/TrackProcessorContext";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
import { useLatest } from "../useLatest";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -160,14 +159,13 @@ export const LobbyView: FC<Props> = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestMuteStates = useLatest(muteStates);
|
|
||||||
const onError = useCallback(
|
const onError = useCallback(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
logger.error("Error while creating preview Tracks:", error);
|
logger.error("Error while creating preview Tracks:", error);
|
||||||
latestMuteStates.current.audio.setEnabled?.(false);
|
muteStates.audio.setEnabled?.(false);
|
||||||
latestMuteStates.current.video.setEnabled?.(false);
|
muteStates.video.setEnabled?.(false);
|
||||||
},
|
},
|
||||||
[latestMuteStates],
|
[muteStates.audio, muteStates.video],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function useMuteState(
|
|||||||
// Determine the default value once devices are actually connected
|
// Determine the default value once devices are actually connected
|
||||||
(prev) =>
|
(prev) =>
|
||||||
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
|
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
|
||||||
[device.available.size],
|
[device],
|
||||||
);
|
);
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -70,7 +70,7 @@ function useMuteState(
|
|||||||
enabled: enabled ?? false,
|
enabled: enabled ?? false,
|
||||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||||
},
|
},
|
||||||
[device.available.size, enabled, setEnabled],
|
[device, enabled, setEnabled],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«r0»"
|
aria-labelledby=":r0:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -69,7 +69,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«r5»"
|
aria-labelledby=":r5:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -91,7 +91,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«ra»"
|
aria-labelledby=":ra:"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -112,7 +112,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rf»"
|
aria-labelledby=":rf:"
|
||||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -138,7 +138,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
class="toggle layout"
|
class="toggle layout"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«rk»"
|
aria-labelledby=":rk:"
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
@@ -156,7 +156,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«rp»"
|
aria-labelledby=":rp:"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import {
|
|||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "../useEvents";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||||
* This logic is specific to livekit foci where the whole call must use one
|
* This logic is specific to livekit foci where the whole call must use one
|
||||||
@@ -24,27 +22,38 @@ import { useTypedEventEmitterState } from "../useEvents";
|
|||||||
export function useActiveLivekitFocus(
|
export function useActiveLivekitFocus(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
): LivekitFocus | undefined {
|
): LivekitFocus | undefined {
|
||||||
const activeFocus = useTypedEventEmitterState(
|
const [activeFocus, setActiveFocus] = useState(() => {
|
||||||
rtcSession,
|
const f = rtcSession.getActiveFocus();
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
// Only handle foci with type="livekit" for now.
|
||||||
useCallback(() => {
|
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||||
const f = rtcSession.getActiveFocus();
|
});
|
||||||
// Only handle foci with type="livekit" for now.
|
|
||||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
|
||||||
}, [rtcSession]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevActiveFocus = useRef(activeFocus);
|
const onMembershipsChanged = useCallback(() => {
|
||||||
useEffect(() => {
|
const newActiveFocus = rtcSession.getActiveFocus();
|
||||||
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
|
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||||
|
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||||
const oldestMembership = rtcSession.getOldestMembership();
|
const oldestMembership = rtcSession.getOldestMembership();
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
||||||
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
|
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`,
|
||||||
);
|
);
|
||||||
prevActiveFocus.current = activeFocus;
|
setActiveFocus(newActiveFocus);
|
||||||
}
|
}
|
||||||
}, [activeFocus, rtcSession]);
|
}, [activeFocus, rtcSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rtcSession.on(
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
onMembershipsChanged,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
rtcSession.off(
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
onMembershipsChanged,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return activeFocus;
|
return activeFocus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 New Vector Ltd.
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Room, RoomEvent } from "matrix-js-sdk";
|
import { type Room, RoomEvent } from "matrix-js-sdk";
|
||||||
import { useCallback } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "../useEvents";
|
import { useTypedEventEmitter } from "../useEvents";
|
||||||
|
|
||||||
export function useRoomName(room: Room): string {
|
export function useRoomName(room: Room): string {
|
||||||
return useTypedEventEmitterState(
|
const [, setNumUpdates] = useState(0);
|
||||||
room,
|
// Whenever the name changes, force an update
|
||||||
RoomEvent.Name,
|
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1));
|
||||||
useCallback(() => room.name, [room]),
|
return room.name;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
|
||||||
type RoomState,
|
|
||||||
RoomStateEvent,
|
|
||||||
type Room,
|
|
||||||
RoomEvent,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "../useEvents";
|
import { useTypedEventEmitter } from "../useEvents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A React hook for values computed from room state.
|
* A React hook for values computed from room state.
|
||||||
@@ -21,17 +16,14 @@ import { useTypedEventEmitterState } from "../useEvents";
|
|||||||
* @param f A mapping from the current room state to the computed value.
|
* @param f A mapping from the current room state to the computed value.
|
||||||
* @returns The computed value.
|
* @returns The computed value.
|
||||||
*/
|
*/
|
||||||
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
|
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => {
|
||||||
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
|
const [numUpdates, setNumUpdates] = useState(0);
|
||||||
// clear how to reactively track the current state of the room without it
|
useTypedEventEmitter(
|
||||||
const currentState = useTypedEventEmitterState(
|
|
||||||
room,
|
room,
|
||||||
RoomEvent.CurrentStateUpdated,
|
|
||||||
useCallback(() => room.currentState, [room]),
|
|
||||||
);
|
|
||||||
return useTypedEventEmitterState(
|
|
||||||
currentState,
|
|
||||||
RoomStateEvent.Update,
|
RoomStateEvent.Update,
|
||||||
useCallback(() => f(currentState), [f, currentState]),
|
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]),
|
||||||
);
|
);
|
||||||
}
|
// We want any change to the update counter to trigger an update here
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
return useMemo(() => f(room.currentState), [room, f, numUpdates]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ import {
|
|||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import * as ComponentsCore from "@livekit/components-core";
|
import * as ComponentsCore from "@livekit/components-core";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
|
||||||
|
|
||||||
import { CallViewModel, type Layout } from "./CallViewModel";
|
import { CallViewModel, type Layout } from "./CallViewModel";
|
||||||
import {
|
import {
|
||||||
@@ -200,7 +200,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
// care about the most recent value for each time step, so discard these
|
// care about the most recent value for each time step, so discard these
|
||||||
// extra values.
|
// extra values.
|
||||||
debounceTime(0),
|
debounceTime(0),
|
||||||
distinctUntilChanged(deepCompare),
|
distinctUntilChanged(isEqual),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "./livekit/MediaDevicesContext";
|
} from "./livekit/MediaDevicesContext";
|
||||||
import { type PrefetchedSounds } from "./soundUtils";
|
import { type PrefetchedSounds } from "./soundUtils";
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import * as controls from "./controls";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play a sound though a given AudioContext. Will take
|
* Play a sound though a given AudioContext. Will take
|
||||||
@@ -43,7 +42,6 @@ async function playSound(
|
|||||||
src.buffer = buffer;
|
src.buffer = buffer;
|
||||||
src.connect(gain).connect(pan).connect(ctx.destination);
|
src.connect(gain).connect(pan).connect(ctx.destination);
|
||||||
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
|
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
|
||||||
controls.setPlaybackStarted();
|
|
||||||
src.start();
|
src.start();
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { type FC, useEffect, useState } from "react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "./useEvents";
|
|
||||||
|
|
||||||
class TestEmitter extends TypedEventEmitter<"change", { change: () => void }> {
|
|
||||||
private state = 1;
|
|
||||||
public readonly getState = (): number => this.state;
|
|
||||||
public readonly getNegativeState = (): number => -this.state;
|
|
||||||
public readonly setState = (value: number): void => {
|
|
||||||
this.state = value;
|
|
||||||
this.emit("change");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("useTypedEventEmitterState reacts to events", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const emitter = new TestEmitter();
|
|
||||||
|
|
||||||
const Test: FC = () => {
|
|
||||||
const value = useTypedEventEmitterState(
|
|
||||||
emitter,
|
|
||||||
"change",
|
|
||||||
emitter.getState,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => emitter.setState(2)}>Change value</button>
|
|
||||||
<div>Value is {value}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<Test />);
|
|
||||||
screen.getByText("Value is 1");
|
|
||||||
await user.click(screen.getByText("Change value"));
|
|
||||||
screen.getByText("Value is 2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("useTypedEventEmitterState reacts to changes made by an effect mounted on the same render", () => {
|
|
||||||
const emitter = new TestEmitter();
|
|
||||||
|
|
||||||
const Test: FC = () => {
|
|
||||||
useEffect(() => emitter.setState(2), []);
|
|
||||||
const value = useTypedEventEmitterState(
|
|
||||||
emitter,
|
|
||||||
"change",
|
|
||||||
emitter.getState,
|
|
||||||
);
|
|
||||||
return `Value is ${value}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<Test />);
|
|
||||||
screen.getByText("Value is 2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("useTypedEventEmitterState reacts to changes in getState", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const emitter = new TestEmitter();
|
|
||||||
|
|
||||||
const Test: FC = () => {
|
|
||||||
const [fn, setFn] = useState(() => emitter.getState);
|
|
||||||
const value = useTypedEventEmitterState(emitter, "change", fn);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => setFn(() => emitter.getNegativeState)}>
|
|
||||||
Change getState
|
|
||||||
</button>
|
|
||||||
<div>Value is {value}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<Test />);
|
|
||||||
screen.getByText("Value is 1");
|
|
||||||
await user.click(screen.getByText("Change getState"));
|
|
||||||
screen.getByText("Value is -1");
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Listener,
|
Listener,
|
||||||
@@ -13,9 +13,7 @@ import type {
|
|||||||
TypedEventEmitter,
|
TypedEventEmitter,
|
||||||
} from "matrix-js-sdk/lib/models/typed-event-emitter";
|
} from "matrix-js-sdk/lib/models/typed-event-emitter";
|
||||||
|
|
||||||
/**
|
// Shortcut for registering a listener on an EventTarget
|
||||||
* Shortcut for registering a listener on an EventTarget.
|
|
||||||
*/
|
|
||||||
export function useEventTarget<T extends Event>(
|
export function useEventTarget<T extends Event>(
|
||||||
target: EventTarget | null | undefined,
|
target: EventTarget | null | undefined,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
@@ -35,9 +33,7 @@ export function useEventTarget<T extends Event>(
|
|||||||
}, [target, eventType, listener, options]);
|
}, [target, eventType, listener, options]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Shortcut for registering a listener on a TypedEventEmitter
|
||||||
* Shortcut for registering a listener on a TypedEventEmitter.
|
|
||||||
*/
|
|
||||||
export function useTypedEventEmitter<
|
export function useTypedEventEmitter<
|
||||||
Events extends string,
|
Events extends string,
|
||||||
Arguments extends ListenerMap<Events>,
|
Arguments extends ListenerMap<Events>,
|
||||||
@@ -54,33 +50,3 @@ export function useTypedEventEmitter<
|
|||||||
};
|
};
|
||||||
}, [emitter, eventType, listener]);
|
}, [emitter, eventType, listener]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactively tracks a value which is recalculated whenever the provided event
|
|
||||||
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
|
|
||||||
* into React.
|
|
||||||
*/
|
|
||||||
export function useTypedEventEmitterState<
|
|
||||||
Events extends string,
|
|
||||||
Arguments extends ListenerMap<Events>,
|
|
||||||
T extends Events,
|
|
||||||
State,
|
|
||||||
>(
|
|
||||||
emitter: TypedEventEmitter<Events, Arguments>,
|
|
||||||
eventType: T,
|
|
||||||
getState: () => State,
|
|
||||||
): State {
|
|
||||||
const subscribe = useCallback(
|
|
||||||
(onChange: () => void) => {
|
|
||||||
emitter.on(eventType, onChange as Listener<Events, Arguments, T>);
|
|
||||||
return (): void => {
|
|
||||||
emitter.off(eventType, onChange as Listener<Events, Arguments, T>);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[emitter, eventType],
|
|
||||||
);
|
|
||||||
// See the React docs for useSyncExternalStore; given that we're trying to
|
|
||||||
// bridge state from an external source into React, using this hook is exactly
|
|
||||||
// what React recommends.
|
|
||||||
return useSyncExternalStore(subscribe, getState);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { type FC, useEffect, useState } from "react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
|
|
||||||
import {
|
|
||||||
setLocalStorageItemReactive,
|
|
||||||
useLocalStorage,
|
|
||||||
} from "./useLocalStorage";
|
|
||||||
|
|
||||||
test("useLocalStorage reacts to changes made by an effect mounted on the same render", () => {
|
|
||||||
localStorage.clear();
|
|
||||||
const Test: FC = () => {
|
|
||||||
useEffect(() => setLocalStorageItemReactive("my-value", "Hello!"), []);
|
|
||||||
const [myValue] = useLocalStorage("my-value");
|
|
||||||
return myValue;
|
|
||||||
};
|
|
||||||
render(<Test />);
|
|
||||||
screen.getByText("Hello!");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("useLocalStorage reacts to key changes", async () => {
|
|
||||||
localStorage.clear();
|
|
||||||
localStorage.setItem("value-1", "1");
|
|
||||||
localStorage.setItem("value-2", "2");
|
|
||||||
|
|
||||||
const Test: FC = () => {
|
|
||||||
const [key, setKey] = useState("value-1");
|
|
||||||
const [value] = useLocalStorage(key);
|
|
||||||
if (key !== `value-${value}`) throw new Error("Value is out of sync");
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => setKey("value-2")}>Switch keys</button>
|
|
||||||
<div>Value is: {value}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<Test />);
|
|
||||||
|
|
||||||
screen.getByText("Value is: 1");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Switch keys" }));
|
|
||||||
screen.getByText("Value is: 2");
|
|
||||||
});
|
|
||||||
@@ -5,44 +5,51 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import EventEmitter from "events";
|
||||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "./useEvents";
|
|
||||||
|
|
||||||
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
|
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
|
||||||
|
|
||||||
// Bus to notify other useLocalStorage consumers when an item is changed
|
// Bus to notify other useLocalStorage consumers when an item is changed
|
||||||
export const localStorageBus = new TypedEventEmitter<
|
export const localStorageBus = new EventEmitter();
|
||||||
string,
|
|
||||||
{ [key: string]: () => void }
|
|
||||||
>();
|
|
||||||
|
|
||||||
/**
|
// Like useState, but reads from and persists the value to localStorage
|
||||||
* Like useState, but reads from and persists the value to localStorage
|
export const useLocalStorage = (
|
||||||
* This hook will not update when we write to localStorage.setItem(key, value) directly.
|
|
||||||
* For the hook to react either use the returned setter or `setLocalStorageItemReactive`.
|
|
||||||
*/
|
|
||||||
export function useLocalStorage(
|
|
||||||
key: string,
|
key: string,
|
||||||
): [LocalStorageItem, (value: string) => void] {
|
): [LocalStorageItem, (value: string) => void] => {
|
||||||
const value = useTypedEventEmitterState(
|
const [value, setValue] = useState<LocalStorageItem>(() =>
|
||||||
localStorageBus,
|
localStorage.getItem(key),
|
||||||
key,
|
|
||||||
useCallback(() => localStorage.getItem(key), [key]),
|
|
||||||
);
|
|
||||||
const setValue = useCallback(
|
|
||||||
(newValue: string) => setLocalStorageItemReactive(key, newValue),
|
|
||||||
[key],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [value, setValue];
|
useEffect(() => {
|
||||||
}
|
localStorageBus.on(key, setValue);
|
||||||
|
return (): void => {
|
||||||
|
localStorageBus.off(key, setValue);
|
||||||
|
};
|
||||||
|
}, [key, setValue]);
|
||||||
|
|
||||||
export const setLocalStorageItemReactive = (
|
return [
|
||||||
key: string,
|
value,
|
||||||
value: string,
|
useCallback(
|
||||||
): void => {
|
(newValue: string) => {
|
||||||
localStorage.setItem(key, value);
|
setValue(newValue);
|
||||||
localStorageBus.emit(key);
|
localStorage.setItem(key, newValue);
|
||||||
|
localStorageBus.emit(key, newValue);
|
||||||
|
},
|
||||||
|
[key, setValue],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setLocalStorageItem = (key: string, value: string): void => {
|
||||||
|
// Avoid unnecessary updates. Not avoiding them so can cause unexpected state updates across hooks.
|
||||||
|
// For instance:
|
||||||
|
// - In call view uses useRoomEncryptionSystem
|
||||||
|
// - This will set the key again.
|
||||||
|
// - All other instances of useRoomEncryptionSystem will now do a useMemo update of the e2eeSystem
|
||||||
|
// - because the dependency `storedPassword = useInternalRoomSharedKey(roomId);` would change.
|
||||||
|
if (localStorage.getItem(key) === value) return;
|
||||||
|
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
localStorageBus.emit(key, value);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,31 +10,33 @@ import {
|
|||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
import { useEffect, useState } from "react";
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "./useEvents";
|
|
||||||
|
|
||||||
const dummySession = new TypedEventEmitter();
|
|
||||||
|
|
||||||
export function useMatrixRTCSessionJoinState(
|
export function useMatrixRTCSessionJoinState(
|
||||||
rtcSession: MatrixRTCSession | undefined,
|
rtcSession: MatrixRTCSession | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
// React doesn't allow you to run a hook conditionally, so we have to plug in
|
const [, setNumUpdates] = useState(0);
|
||||||
// a dummy event emitter in case there is no rtcSession yet
|
|
||||||
const isJoined = useTypedEventEmitterState(
|
|
||||||
rtcSession ?? dummySession,
|
|
||||||
MatrixRTCSessionEvent.JoinStateChanged,
|
|
||||||
useCallback(() => rtcSession?.isJoined() ?? false, [rtcSession]),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.info(
|
if (rtcSession !== undefined) {
|
||||||
`Session in room ${rtcSession?.room.roomId} changed to ${
|
const onJoinStateChanged = (isJoined: boolean): void => {
|
||||||
isJoined ? "joined" : "left"
|
logger.info(
|
||||||
}`,
|
`Session in room ${rtcSession.room.roomId} changed to ${
|
||||||
);
|
isJoined ? "joined" : "left"
|
||||||
}, [rtcSession, isJoined]);
|
}`,
|
||||||
|
);
|
||||||
|
setNumUpdates((n) => n + 1); // Force an update
|
||||||
|
};
|
||||||
|
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
|
||||||
|
|
||||||
return isJoined;
|
return (): void => {
|
||||||
|
rtcSession.off(
|
||||||
|
MatrixRTCSessionEvent.JoinStateChanged,
|
||||||
|
onJoinStateChanged,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [rtcSession]);
|
||||||
|
|
||||||
|
return rtcSession?.isJoined() ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "./useEvents";
|
|
||||||
|
|
||||||
export function useMatrixRTCSessionMemberships(
|
export function useMatrixRTCSessionMemberships(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
): CallMembership[] {
|
): CallMembership[] {
|
||||||
return useTypedEventEmitterState(
|
const [memberships, setMemberships] = useState(rtcSession.memberships);
|
||||||
rtcSession,
|
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
const onMembershipsChanged = useCallback(() => {
|
||||||
useCallback(() => rtcSession.memberships, [rtcSession]),
|
logger.info(
|
||||||
);
|
`Memberships changed for call in room ${rtcSession.room.roomId} (${rtcSession.memberships.length} members)`,
|
||||||
|
);
|
||||||
|
setMemberships(rtcSession.memberships);
|
||||||
|
}, [rtcSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rtcSession.on(
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
onMembershipsChanged,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
rtcSession.off(
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
onMembershipsChanged,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [rtcSession, onMembershipsChanged]);
|
||||||
|
|
||||||
|
return memberships;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023-2025 New Vector Ltd.
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useEventTarget } from "./useEvents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook that tracks whether the given media query matches.
|
* React hook that tracks whether the given media query matches.
|
||||||
@@ -13,13 +15,14 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|||||||
export function useMediaQuery(query: string): boolean {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const mediaQuery = useMemo(() => window.matchMedia(query), [query]);
|
const mediaQuery = useMemo(() => window.matchMedia(query), [query]);
|
||||||
|
|
||||||
const subscribe = useCallback(
|
const [numChanges, setNumChanges] = useState(0);
|
||||||
(onChange: () => void) => {
|
useEventTarget(
|
||||||
mediaQuery.addEventListener("change", onChange);
|
mediaQuery,
|
||||||
return (): void => mediaQuery.removeEventListener("change", onChange);
|
"change",
|
||||||
},
|
useCallback(() => setNumChanges((n) => n + 1), [setNumChanges]),
|
||||||
[mediaQuery],
|
|
||||||
);
|
);
|
||||||
const getState = useCallback(() => mediaQuery.matches, [mediaQuery]);
|
|
||||||
return useSyncExternalStore(subscribe, getState);
|
// We want any change to the update counter to trigger an update here
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
return useMemo(() => mediaQuery.matches, [mediaQuery, numChanges]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ interface EmitterMock<T> {
|
|||||||
removeListener: () => T;
|
removeListener: () => T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockEmitter<T>(): EmitterMock<T> {
|
function mockEmitter<T>(): EmitterMock<T> {
|
||||||
return {
|
return {
|
||||||
on(): T {
|
on(): T {
|
||||||
return this as T;
|
return this as T;
|
||||||
|
|||||||
167
yarn.lock
167
yarn.lock
@@ -4391,69 +4391,69 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/animated@npm:~10.0.1":
|
"@react-spring/animated@npm:~9.7.5":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/animated@npm:10.0.1"
|
resolution: "@react-spring/animated@npm:9.7.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@react-spring/shared": "npm:~10.0.1"
|
"@react-spring/shared": "npm:~9.7.5"
|
||||||
"@react-spring/types": "npm:~10.0.1"
|
"@react-spring/types": "npm:~9.7.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
checksum: 10c0/aaccd4a8b0280ac846d463b253ad8f092ee4afc9dbedc8e77616adf5399ffec755344f09fdd8487cadaf815840dff84d354d1143579c27c2fcd6937549b5fc40
|
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/core@npm:~10.0.1":
|
"@react-spring/core@npm:~9.7.5":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/core@npm:10.0.1"
|
resolution: "@react-spring/core@npm:9.7.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@react-spring/animated": "npm:~10.0.1"
|
"@react-spring/animated": "npm:~9.7.5"
|
||||||
"@react-spring/shared": "npm:~10.0.1"
|
"@react-spring/shared": "npm:~9.7.5"
|
||||||
"@react-spring/types": "npm:~10.0.1"
|
"@react-spring/types": "npm:~9.7.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
checksum: 10c0/059b122dda4138e5e7e461abd49350921e326735ca9a1d8aa19b1fdbae0937661b5f71af6fe82fd8f59e8db5549627849b38cc3f7ef2ec7ee9c93c3d6225174f
|
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/rafz@npm:~10.0.1":
|
"@react-spring/rafz@npm:~9.7.5":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/rafz@npm:10.0.1"
|
resolution: "@react-spring/rafz@npm:9.7.5"
|
||||||
checksum: 10c0/cba76f143d3a06f79dd0c09f7aefd17df9cca9b2c1ef7f9103255e5351326f4a42a5a1366f731a78f74380d96ba683bcc2a49312ed1e4b9e9e249e72c9ff68cb
|
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/shared@npm:~10.0.1":
|
"@react-spring/shared@npm:~9.7.5":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/shared@npm:10.0.1"
|
resolution: "@react-spring/shared@npm:9.7.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@react-spring/rafz": "npm:~10.0.1"
|
"@react-spring/rafz": "npm:~9.7.5"
|
||||||
"@react-spring/types": "npm:~10.0.1"
|
"@react-spring/types": "npm:~9.7.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
checksum: 10c0/f056aaa018b3744afd8244e8eea24534d32f92fad9ace815b80e159b296fb5db148e2c9bd840ad9a5188e7a3c0778fd564b8af9ae02cd500e019a228398fb3cf
|
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/types@npm:~10.0.1":
|
"@react-spring/types@npm:~9.7.5":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/types@npm:10.0.1"
|
resolution: "@react-spring/types@npm:9.7.5"
|
||||||
checksum: 10c0/260890f9c156dc69b77c846510017156d8c0a07cce70edc7c108e57b0cf4122b26a15e724b191481a51b2c914296de9e81d56618b2c339339d4b221930691baa
|
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@react-spring/web@npm:^10.0.0":
|
"@react-spring/web@npm:^9.4.4":
|
||||||
version: 10.0.1
|
version: 9.7.5
|
||||||
resolution: "@react-spring/web@npm:10.0.1"
|
resolution: "@react-spring/web@npm:9.7.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@react-spring/animated": "npm:~10.0.1"
|
"@react-spring/animated": "npm:~9.7.5"
|
||||||
"@react-spring/core": "npm:~10.0.1"
|
"@react-spring/core": "npm:~9.7.5"
|
||||||
"@react-spring/shared": "npm:~10.0.1"
|
"@react-spring/shared": "npm:~9.7.5"
|
||||||
"@react-spring/types": "npm:~10.0.1"
|
"@react-spring/types": "npm:~9.7.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
checksum: 10c0/a0c788c9fd881ccb834feb22fc0694e74e59f7b76e498f0096f5b65e2c9812513955bf45ee27d7c5348f56a7bba7c5a6961d4be663728bb2172fe5aa6b6bdfc4
|
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -5224,6 +5224,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/prop-types@npm:*":
|
||||||
|
version: 15.7.14
|
||||||
|
resolution: "@types/prop-types@npm:15.7.14"
|
||||||
|
checksum: 10c0/1ec775160bfab90b67a782d735952158c7e702ca4502968aa82565bd8e452c2de8601c8dfe349733073c31179116cf7340710160d3836aa8a1ef76d1532893b1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/qrcode@npm:^1.5.5":
|
"@types/qrcode@npm:^1.5.5":
|
||||||
version: 1.5.5
|
version: 1.5.5
|
||||||
resolution: "@types/qrcode@npm:1.5.5"
|
resolution: "@types/qrcode@npm:1.5.5"
|
||||||
@@ -5233,21 +5240,22 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/react-dom@npm:^19.0.0":
|
"@types/react-dom@npm:^18.3.0":
|
||||||
version: 19.1.5
|
version: 18.3.7
|
||||||
resolution: "@types/react-dom@npm:19.1.5"
|
resolution: "@types/react-dom@npm:18.3.7"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/react": ^19.0.0
|
"@types/react": ^18.0.0
|
||||||
checksum: 10c0/2a29e77cf6bb6e9f57bcfa54509c216cad2e16e244f0bd56369966ec88c072b9c91f6011d14f9e18fbfe2b801b18b86f616de75e5c8aef0be73c1f74abb33b49
|
checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/react@npm:^19.0.0":
|
"@types/react@npm:^18.3.0":
|
||||||
version: 19.1.6
|
version: 18.3.23
|
||||||
resolution: "@types/react@npm:19.1.6"
|
resolution: "@types/react@npm:18.3.23"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@types/prop-types": "npm:*"
|
||||||
csstype: "npm:^3.0.2"
|
csstype: "npm:^3.0.2"
|
||||||
checksum: 10c0/8b10b198e28997b3c57559750f8bcf5ae7b33c554b16b6f4fe2ece1d4de6a2fc8cb53e7effe08ec9cb939d2f479eb97c5e08aac2cf83b10a90164fe451cc8ea2
|
checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -6859,7 +6867,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"copy-to-clipboard@npm:^3.3.3":
|
"copy-to-clipboard@npm:^3.3.1":
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
resolution: "copy-to-clipboard@npm:3.3.3"
|
resolution: "copy-to-clipboard@npm:3.3.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7449,7 +7457,7 @@ __metadata:
|
|||||||
"@radix-ui/react-dialog": "npm:^1.0.4"
|
"@radix-ui/react-dialog": "npm:^1.0.4"
|
||||||
"@radix-ui/react-slider": "npm:^1.1.2"
|
"@radix-ui/react-slider": "npm:^1.1.2"
|
||||||
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
|
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
|
||||||
"@react-spring/web": "npm:^10.0.0"
|
"@react-spring/web": "npm:^9.4.4"
|
||||||
"@sentry/react": "npm:^8.0.0"
|
"@sentry/react": "npm:^8.0.0"
|
||||||
"@sentry/vite-plugin": "npm:^3.0.0"
|
"@sentry/vite-plugin": "npm:^3.0.0"
|
||||||
"@stylistic/eslint-plugin": "npm:^3.0.0"
|
"@stylistic/eslint-plugin": "npm:^3.0.0"
|
||||||
@@ -7465,8 +7473,8 @@ __metadata:
|
|||||||
"@types/node": "npm:^22.0.0"
|
"@types/node": "npm:^22.0.0"
|
||||||
"@types/pako": "npm:^2.0.3"
|
"@types/pako": "npm:^2.0.3"
|
||||||
"@types/qrcode": "npm:^1.5.5"
|
"@types/qrcode": "npm:^1.5.5"
|
||||||
"@types/react": "npm:^19.0.0"
|
"@types/react": "npm:^18.3.0"
|
||||||
"@types/react-dom": "npm:^19.0.0"
|
"@types/react-dom": "npm:^18.3.0"
|
||||||
"@types/sdp-transform": "npm:^2.4.5"
|
"@types/sdp-transform": "npm:^2.4.5"
|
||||||
"@types/uuid": "npm:10"
|
"@types/uuid": "npm:10"
|
||||||
"@typescript-eslint/eslint-plugin": "npm:^8.31.0"
|
"@typescript-eslint/eslint-plugin": "npm:^8.31.0"
|
||||||
@@ -7478,7 +7486,6 @@ __metadata:
|
|||||||
"@vitest/coverage-v8": "npm:^3.0.0"
|
"@vitest/coverage-v8": "npm:^3.0.0"
|
||||||
babel-plugin-transform-vite-meta-env: "npm:^1.0.3"
|
babel-plugin-transform-vite-meta-env: "npm:^1.0.3"
|
||||||
classnames: "npm:^2.3.1"
|
classnames: "npm:^2.3.1"
|
||||||
copy-to-clipboard: "npm:^3.3.3"
|
|
||||||
eslint: "npm:^8.14.0"
|
eslint: "npm:^8.14.0"
|
||||||
eslint-config-google: "npm:^0.14.0"
|
eslint-config-google: "npm:^0.14.0"
|
||||||
eslint-config-prettier: "npm:^10.0.0"
|
eslint-config-prettier: "npm:^10.0.0"
|
||||||
@@ -7509,10 +7516,11 @@ __metadata:
|
|||||||
posthog-js: "npm:1.160.3"
|
posthog-js: "npm:1.160.3"
|
||||||
prettier: "npm:^3.0.0"
|
prettier: "npm:^3.0.0"
|
||||||
qrcode: "npm:^1.5.4"
|
qrcode: "npm:^1.5.4"
|
||||||
react: "npm:19"
|
react: "npm:18"
|
||||||
react-dom: "npm:19"
|
react-dom: "npm:18"
|
||||||
react-i18next: "npm:^15.0.0"
|
react-i18next: "npm:^15.0.0"
|
||||||
react-router-dom: "npm:^7.0.0"
|
react-router-dom: "npm:^7.0.0"
|
||||||
|
react-use-clipboard: "npm:^1.0.7"
|
||||||
react-use-measure: "npm:^2.1.1"
|
react-use-measure: "npm:^2.1.1"
|
||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
sass: "npm:^1.42.1"
|
sass: "npm:^1.42.1"
|
||||||
@@ -9999,7 +10007,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"loose-envify@npm:^1.4.0":
|
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
resolution: "loose-envify@npm:1.4.0"
|
resolution: "loose-envify@npm:1.4.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11569,14 +11577,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-dom@npm:19":
|
"react-dom@npm:18":
|
||||||
version: 19.1.0
|
version: 18.3.1
|
||||||
resolution: "react-dom@npm:19.1.0"
|
resolution: "react-dom@npm:18.3.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
scheduler: "npm:^0.26.0"
|
loose-envify: "npm:^1.1.0"
|
||||||
|
scheduler: "npm:^0.23.2"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.1.0
|
react: ^18.3.1
|
||||||
checksum: 10c0/3e26e89bb6c67c9a6aa86cb888c7a7f8258f2e347a6d2a15299c17eb16e04c19194e3452bc3255bd34000a61e45e2cb51e46292392340432f133e5a5d2dfb5fc
|
checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -11720,6 +11729,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-use-clipboard@npm:^1.0.7":
|
||||||
|
version: 1.0.9
|
||||||
|
resolution: "react-use-clipboard@npm:1.0.9"
|
||||||
|
dependencies:
|
||||||
|
copy-to-clipboard: "npm:^3.3.1"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18
|
||||||
|
react-dom: ^16.8.0 || ^17 || ^18
|
||||||
|
checksum: 10c0/f75ba85b885ae45d0c99a09a0652a2f64c1d9d6d015293bf01b2e6a9f50ff2a6b1830f6e60c1f639b9ae33fa434bb31adbf43f613df2402438204ea7456cf86a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-use-measure@npm:^2.1.1":
|
"react-use-measure@npm:^2.1.1":
|
||||||
version: 2.1.7
|
version: 2.1.7
|
||||||
resolution: "react-use-measure@npm:2.1.7"
|
resolution: "react-use-measure@npm:2.1.7"
|
||||||
@@ -11733,10 +11754,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react@npm:19":
|
"react@npm:18":
|
||||||
version: 19.1.0
|
version: 18.3.1
|
||||||
resolution: "react@npm:19.1.0"
|
resolution: "react@npm:18.3.1"
|
||||||
checksum: 10c0/530fb9a62237d54137a13d2cfb67a7db6a2156faed43eecc423f4713d9b20c6f2728b026b45e28fcd72e8eadb9e9ed4b089e99f5e295d2f0ad3134251bdd3698
|
dependencies:
|
||||||
|
loose-envify: "npm:^1.1.0"
|
||||||
|
checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -12329,10 +12352,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"scheduler@npm:^0.26.0":
|
"scheduler@npm:^0.23.2":
|
||||||
version: 0.26.0
|
version: 0.23.2
|
||||||
resolution: "scheduler@npm:0.26.0"
|
resolution: "scheduler@npm:0.23.2"
|
||||||
checksum: 10c0/5b8d5bfddaae3513410eda54f2268e98a376a429931921a81b5c3a2873aab7ca4d775a8caac5498f8cbc7d0daeab947cf923dbd8e215d61671f9f4e392d34356
|
dependencies:
|
||||||
|
loose-envify: "npm:^1.1.0"
|
||||||
|
checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user