mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-26 21:09:21 +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
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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 up -d
|
||||
docker ps
|
||||
- name: Copy config file
|
||||
run: cp config/config.devenv.json public/config.json
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY ./dist /dist
|
||||
WORKDIR /dist/assets
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
- `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")`)
|
||||
- `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-Tos & Tutorials
|
||||
## 🛠️ How-Tos & Tutorials
|
||||
|
||||
- [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/)
|
||||
- [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/)
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
|
||||
|
||||
## 🤝 Want to Contribute?
|
||||
|
||||
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
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.10.1"
|
||||
android_gradle_plugin = "8.10.0"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
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
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -27,7 +27,7 @@ android {
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
|
||||
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
|
||||
|
||||
signAllPublications()
|
||||
|
||||
|
||||
12
package.json
12
package.json
@@ -54,7 +54,7 @@
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@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/vite-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
@@ -70,8 +70,8 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
@@ -83,7 +83,6 @@
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
@@ -114,10 +113,11 @@
|
||||
"posthog-js": "1.160.3",
|
||||
"prettier": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.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";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
use: {
|
||||
/* 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 */
|
||||
trace: "on-first-retry",
|
||||
@@ -77,13 +73,9 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
command: "yarn dev",
|
||||
url: "https://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
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`.
|
||||
* @param page
|
||||
*/
|
||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
||||
? async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"http://localhost:8080/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
: async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
};
|
||||
async function setDevToolElementCallDevUrl(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.
|
||||
|
||||
@@ -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`] = `
|
||||
<div
|
||||
aria-labelledby="radix-«r4»"
|
||||
aria-labelledby="radix-:r4:"
|
||||
class="overlay animate modal dialog _glass_sepwu_8"
|
||||
data-state="open"
|
||||
id="radix-«r3»"
|
||||
id="radix-:r3:"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
id="radix-«r4»"
|
||||
id="radix-:r4:"
|
||||
>
|
||||
My modal
|
||||
</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`] = `
|
||||
<div
|
||||
aria-labelledby="radix-«ra»"
|
||||
aria-labelledby="radix-:ra:"
|
||||
class="overlay modal drawer"
|
||||
data-state="open"
|
||||
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-direction="bottom"
|
||||
data-vaul-snap-points="false"
|
||||
id="radix-«r9»"
|
||||
id="radix-:r9:"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
class="handle"
|
||||
/>
|
||||
<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;"
|
||||
>
|
||||
My modal
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-«r4»"
|
||||
aria-labelledby="radix-:r4:"
|
||||
class="overlay animate toast"
|
||||
data-state="open"
|
||||
id="radix-«r3»"
|
||||
id="radix-:r3:"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
|
||||
>
|
||||
<h3
|
||||
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
||||
id="radix-«r4»"
|
||||
id="radix-:r4:"
|
||||
>
|
||||
Hello world!
|
||||
</h3>
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«rb5»"
|
||||
aria-labelledby=":rb5:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«r7m»"
|
||||
aria-labelledby=":r7m:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -74,7 +74,7 @@ exports[`Can lower hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«r36»"
|
||||
aria-labelledby=":r36:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -108,7 +108,7 @@ exports[`Can open menu 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«r0»"
|
||||
aria-labelledby=":r0:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -139,7 +139,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="«r1j»"
|
||||
aria-labelledby=":r1j:"
|
||||
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface Controls {
|
||||
/** @deprecated use onAudioDeviceSelect instead*/
|
||||
onOutputDeviceSelect?: (id: string) => void;
|
||||
onAudioDeviceSelect?: (id: string) => void;
|
||||
onAudioPlaybackStarted?: () => void;
|
||||
/** @deprecated use setAudioEnabled instead*/
|
||||
setOutputEnabled(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.
|
||||
*/
|
||||
export const setAudioEnabled$ = new Subject<boolean>();
|
||||
let playbackStartedEmitted = false;
|
||||
export const setPlaybackStarted = (): void => {
|
||||
if (!playbackStartedEmitted) {
|
||||
playbackStartedEmitted = true;
|
||||
window.controls.onAudioPlaybackStarted?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.controls = {
|
||||
canEnterPip(): boolean {
|
||||
return setPipEnabled$.observed;
|
||||
|
||||
@@ -6,70 +6,58 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||
import { type UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
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 {
|
||||
if (
|
||||
localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) !== password
|
||||
)
|
||||
setLocalStorageItemReactive(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
password,
|
||||
);
|
||||
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||
}
|
||||
|
||||
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||
`room-shared-key-${roomId}`;
|
||||
|
||||
/**
|
||||
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
||||
* @param roomId
|
||||
* @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]);
|
||||
const useInternalRoomSharedKey = (roomId: string): string | null => {
|
||||
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||
const [roomSharedKey] = useLocalStorage(key);
|
||||
|
||||
// make sure we never return the initial null value from `useLocalStorage`
|
||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||
return roomSharedKey;
|
||||
};
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: urlRoomId, password } = getUrlParams();
|
||||
if (roomId !== urlRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
urlRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
saveKeyFromUrlParams(getUrlParams());
|
||||
const key = getRoomSharedKeyLocalStorageKey(roomId);
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function saveKeyFromUrlParams(urlParams: UrlParams): void {
|
||||
if (!urlParams.password || !urlParams.roomId) return;
|
||||
|
||||
// Take the key from the URL and save it.
|
||||
// It's important to always use the room ID specified in the URL
|
||||
// when saving keys rather than whatever the current room ID might be,
|
||||
// in case we've moved to a different room but the URL hasn't changed.
|
||||
saveKeyForRoom(urlParams.roomId, urlParams.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the room password from the URL if one is present, saving it in localstorage
|
||||
* and returning it in a tuple with the corresponding room ID from the URL.
|
||||
* @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 SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
@@ -78,11 +66,12 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
getKeyForRoom(roomId) ?? undefined,
|
||||
);
|
||||
|
||||
// make sure we've extracted the key from the URL first
|
||||
// (and we still need to take the value it returns because
|
||||
// the effect won't run in time for it to save to localstorage in
|
||||
// time for us to read it out again).
|
||||
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
@@ -91,10 +80,15 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: storedPassword,
|
||||
};
|
||||
if (urlRoomId === roomId)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: passwordFromUrl,
|
||||
};
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return { kind: E2eeType.PER_PARTICIPANT };
|
||||
}
|
||||
return { kind: E2eeType.NONE };
|
||||
}, [room, storedPassword]);
|
||||
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||
return e2eeSystem;
|
||||
}
|
||||
|
||||
@@ -81,9 +81,8 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
||||
}
|
||||
|
||||
const roomIsJoinable = (room: Room): boolean => {
|
||||
const password = getKeyForRoom(room.roomId);
|
||||
if (!room.hasEncryptionStateEvent() && !password) {
|
||||
// if we have a non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||
// in case this key also does not exists we cannot join the room.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import * as controls from "../controls";
|
||||
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The list of participants to render audio for.
|
||||
@@ -204,7 +204,6 @@ function AudioTrackWithAudioNodes({
|
||||
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
||||
);
|
||||
setTrackReady(true);
|
||||
controls.setPlaybackStarted();
|
||||
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
type JSX,
|
||||
} from "react";
|
||||
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 { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import {
|
||||
useSetting,
|
||||
@@ -141,16 +140,7 @@ function useMediaDeviceHandle(
|
||||
kind,
|
||||
() => logger.error("Error creating MediaDeviceObserver"),
|
||||
requestPermissions,
|
||||
).pipe(
|
||||
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),
|
||||
),
|
||||
).pipe(startWith([])),
|
||||
[kind, requestPermissions],
|
||||
);
|
||||
const available = useObservableEagerState(
|
||||
|
||||
@@ -10,14 +10,7 @@ import {
|
||||
supportsBackgroundProcessors,
|
||||
type BackgroundOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type JSX,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
|
||||
import {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RoomOptions,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -24,7 +24,11 @@ import { map } from "rxjs";
|
||||
import { defaultLiveKitOptions } from "./options";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { type MuteStates } from "../room/MuteStates";
|
||||
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
|
||||
import {
|
||||
type MediaDeviceHandle,
|
||||
type MediaDevices,
|
||||
useMediaDevices,
|
||||
} from "./MediaDevicesContext";
|
||||
import {
|
||||
type ECConnectionState,
|
||||
useECConnectionState,
|
||||
@@ -36,9 +40,9 @@ import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "./TrackProcessorContext";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
@@ -53,78 +57,27 @@ export function useLivekit(
|
||||
): UseLivekitResult {
|
||||
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) {
|
||||
logger.info("Created MatrixKeyProvider (per participant)");
|
||||
e2ee = {
|
||||
return {
|
||||
keyProvider: new MatrixKeyProvider(),
|
||||
worker: new E2EEWorker(),
|
||||
};
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
||||
e2ee = {
|
||||
|
||||
return {
|
||||
keyProvider: new ExternalE2EEKeyProvider(),
|
||||
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(() => {
|
||||
const e2eeOptions = room.options.e2ee;
|
||||
if (
|
||||
e2eeSystem.kind === E2eeType.NONE ||
|
||||
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
||||
)
|
||||
return;
|
||||
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||
@@ -135,7 +88,56 @@ export function useLivekit(
|
||||
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
|
||||
useTrackProcessorSync(
|
||||
@@ -157,8 +159,8 @@ export function useLivekit(
|
||||
);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
initialDevices.audioInput.selectedId,
|
||||
initialMuteStates.audio.enabled,
|
||||
initialDevices.current.audioInput.selectedId,
|
||||
initialMuteStates.current.audio.enabled,
|
||||
room,
|
||||
sfuConfig,
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useAudioContext } from "../useAudioContext";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import {
|
||||
flushPromises,
|
||||
mockEmitter,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRtcMembership,
|
||||
@@ -130,7 +129,6 @@ function createGroupCallView(
|
||||
getMxcAvatarUrl: () => null,
|
||||
getCanonicalAlias: () => null,
|
||||
currentState: {
|
||||
...mockEmitter(),
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
|
||||
@@ -43,10 +43,7 @@ import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import {
|
||||
saveKeyForRoom,
|
||||
useRoomEncryptionSystem,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
@@ -170,12 +167,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
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);
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
|
||||
@@ -104,7 +104,6 @@ import { ConnectionLostError } from "../utils/errors.ts";
|
||||
import { useTypedEventEmitter } from "../useEvents.ts";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
}, [livekitRoom]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
@@ -250,7 +250,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
() =>
|
||||
@@ -724,7 +723,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
|
||||
<MatrixAudioRenderer
|
||||
members={rtcSession.memberships}
|
||||
muted={muteAllAudio}
|
||||
/>
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} 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 { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
@@ -42,17 +42,18 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
);
|
||||
const [, setCopied] = useClipboard(url);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
||||
|
||||
const onButtonClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copy(url);
|
||||
setCopied();
|
||||
onDismiss();
|
||||
setToastOpen(true);
|
||||
},
|
||||
[url, onDismiss],
|
||||
[setCopied, onDismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
useTrackProcessorSync,
|
||||
} from "../livekit/TrackProcessorContext";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -160,14 +159,13 @@ export const LobbyView: FC<Props> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
latestMuteStates.current.audio.setEnabled?.(false);
|
||||
latestMuteStates.current.video.setEnabled?.(false);
|
||||
muteStates.audio.setEnabled?.(false);
|
||||
muteStates.video.setEnabled?.(false);
|
||||
},
|
||||
[latestMuteStates],
|
||||
[muteStates.audio, muteStates.video],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
|
||||
@@ -60,7 +60,7 @@ function useMuteState(
|
||||
// Determine the default value once devices are actually connected
|
||||
(prev) =>
|
||||
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
|
||||
[device.available.size],
|
||||
[device],
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -70,7 +70,7 @@ function useMuteState(
|
||||
enabled: enabled ?? false,
|
||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||
},
|
||||
[device.available.size, enabled, setEnabled],
|
||||
[device, enabled, setEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r0»"
|
||||
aria-labelledby=":r0:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -69,7 +69,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r5»"
|
||||
aria-labelledby=":r5:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -91,7 +91,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«ra»"
|
||||
aria-labelledby=":ra:"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -112,7 +112,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rf»"
|
||||
aria-labelledby=":rf:"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -138,7 +138,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="«rk»"
|
||||
aria-labelledby=":rk:"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -156,7 +156,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="«rp»"
|
||||
aria-labelledby=":rp:"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} 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 { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
/**
|
||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||
* 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(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const activeFocus = useTypedEventEmitterState(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
useCallback(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
}, [rtcSession]),
|
||||
);
|
||||
const [activeFocus, setActiveFocus] = useState(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
});
|
||||
|
||||
const prevActiveFocus = useRef(activeFocus);
|
||||
useEffect(() => {
|
||||
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
const newActiveFocus = rtcSession.getActiveFocus();
|
||||
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
`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]);
|
||||
|
||||
useEffect(() => {
|
||||
rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
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 {
|
||||
return useTypedEventEmitterState(
|
||||
room,
|
||||
RoomEvent.Name,
|
||||
useCallback(() => room.name, [room]),
|
||||
);
|
||||
const [, setNumUpdates] = useState(0);
|
||||
// Whenever the name changes, force an update
|
||||
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1));
|
||||
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.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns The computed value.
|
||||
*/
|
||||
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
|
||||
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
|
||||
// clear how to reactively track the current state of the room without it
|
||||
const currentState = useTypedEventEmitterState(
|
||||
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => {
|
||||
const [numUpdates, setNumUpdates] = useState(0);
|
||||
useTypedEventEmitter(
|
||||
room,
|
||||
RoomEvent.CurrentStateUpdated,
|
||||
useCallback(() => room.currentState, [room]),
|
||||
);
|
||||
return useTypedEventEmitterState(
|
||||
currentState,
|
||||
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,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
import { isEqual } from "lodash-es";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { CallViewModel, type Layout } from "./CallViewModel";
|
||||
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
|
||||
// extra values.
|
||||
debounceTime(0),
|
||||
distinctUntilChanged(deepCompare),
|
||||
distinctUntilChanged(isEqual),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "./livekit/MediaDevicesContext";
|
||||
import { type PrefetchedSounds } from "./soundUtils";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import * as controls from "./controls";
|
||||
|
||||
/**
|
||||
* Play a sound though a given AudioContext. Will take
|
||||
@@ -43,7 +42,6 @@ async function playSound(
|
||||
src.buffer = buffer;
|
||||
src.connect(gain).connect(pan).connect(ctx.destination);
|
||||
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
|
||||
controls.setPlaybackStarted();
|
||||
src.start();
|
||||
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.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type {
|
||||
Listener,
|
||||
@@ -13,9 +13,7 @@ import type {
|
||||
TypedEventEmitter,
|
||||
} 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>(
|
||||
target: EventTarget | null | undefined,
|
||||
eventType: string,
|
||||
@@ -35,9 +33,7 @@ export function useEventTarget<T extends Event>(
|
||||
}, [target, eventType, listener, options]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for registering a listener on a TypedEventEmitter.
|
||||
*/
|
||||
// Shortcut for registering a listener on a TypedEventEmitter
|
||||
export function useTypedEventEmitter<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events>,
|
||||
@@ -54,33 +50,3 @@ export function useTypedEventEmitter<
|
||||
};
|
||||
}, [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.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
import EventEmitter from "events";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
|
||||
|
||||
// Bus to notify other useLocalStorage consumers when an item is changed
|
||||
export const localStorageBus = new TypedEventEmitter<
|
||||
string,
|
||||
{ [key: string]: () => void }
|
||||
>();
|
||||
export const localStorageBus = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Like useState, but reads from and persists the value to localStorage
|
||||
* 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(
|
||||
// Like useState, but reads from and persists the value to localStorage
|
||||
export const useLocalStorage = (
|
||||
key: string,
|
||||
): [LocalStorageItem, (value: string) => void] {
|
||||
const value = useTypedEventEmitterState(
|
||||
localStorageBus,
|
||||
key,
|
||||
useCallback(() => localStorage.getItem(key), [key]),
|
||||
);
|
||||
const setValue = useCallback(
|
||||
(newValue: string) => setLocalStorageItemReactive(key, newValue),
|
||||
[key],
|
||||
): [LocalStorageItem, (value: string) => void] => {
|
||||
const [value, setValue] = useState<LocalStorageItem>(() =>
|
||||
localStorage.getItem(key),
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
useEffect(() => {
|
||||
localStorageBus.on(key, setValue);
|
||||
return (): void => {
|
||||
localStorageBus.off(key, setValue);
|
||||
};
|
||||
}, [key, setValue]);
|
||||
|
||||
export const setLocalStorageItemReactive = (
|
||||
key: string,
|
||||
value: string,
|
||||
): void => {
|
||||
localStorage.setItem(key, value);
|
||||
localStorageBus.emit(key);
|
||||
return [
|
||||
value,
|
||||
useCallback(
|
||||
(newValue: string) => {
|
||||
setValue(newValue);
|
||||
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,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
|
||||
const dummySession = new TypedEventEmitter();
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMatrixRTCSessionJoinState(
|
||||
rtcSession: MatrixRTCSession | undefined,
|
||||
): boolean {
|
||||
// React doesn't allow you to run a hook conditionally, so we have to plug in
|
||||
// a dummy event emitter in case there is no rtcSession yet
|
||||
const isJoined = useTypedEventEmitterState(
|
||||
rtcSession ?? dummySession,
|
||||
MatrixRTCSessionEvent.JoinStateChanged,
|
||||
useCallback(() => rtcSession?.isJoined() ?? false, [rtcSession]),
|
||||
);
|
||||
const [, setNumUpdates] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
`Session in room ${rtcSession?.room.roomId} changed to ${
|
||||
isJoined ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
}, [rtcSession, isJoined]);
|
||||
if (rtcSession !== undefined) {
|
||||
const onJoinStateChanged = (isJoined: boolean): void => {
|
||||
logger.info(
|
||||
`Session in room ${rtcSession.room.roomId} changed to ${
|
||||
isJoined ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
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.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useMatrixRTCSessionMemberships(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): CallMembership[] {
|
||||
return useTypedEventEmitterState(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
useCallback(() => rtcSession.memberships, [rtcSession]),
|
||||
);
|
||||
const [memberships, setMemberships] = useState(rtcSession.memberships);
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
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
|
||||
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.
|
||||
@@ -13,13 +15,14 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const mediaQuery = useMemo(() => window.matchMedia(query), [query]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
mediaQuery.addEventListener("change", onChange);
|
||||
return (): void => mediaQuery.removeEventListener("change", onChange);
|
||||
},
|
||||
[mediaQuery],
|
||||
const [numChanges, setNumChanges] = useState(0);
|
||||
useEventTarget(
|
||||
mediaQuery,
|
||||
"change",
|
||||
useCallback(() => setNumChanges((n) => n + 1), [setNumChanges]),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
export function mockEmitter<T>(): EmitterMock<T> {
|
||||
function mockEmitter<T>(): EmitterMock<T> {
|
||||
return {
|
||||
on(): T {
|
||||
return this as T;
|
||||
|
||||
167
yarn.lock
167
yarn.lock
@@ -4391,69 +4391,69 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/animated@npm:~10.0.1":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/animated@npm:10.0.1"
|
||||
"@react-spring/animated@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/animated@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/shared": "npm:~10.0.1"
|
||||
"@react-spring/types": "npm:~10.0.1"
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/aaccd4a8b0280ac846d463b253ad8f092ee4afc9dbedc8e77616adf5399ffec755344f09fdd8487cadaf815840dff84d354d1143579c27c2fcd6937549b5fc40
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/core@npm:~10.0.1":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/core@npm:10.0.1"
|
||||
"@react-spring/core@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/core@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/animated": "npm:~10.0.1"
|
||||
"@react-spring/shared": "npm:~10.0.1"
|
||||
"@react-spring/types": "npm:~10.0.1"
|
||||
"@react-spring/animated": "npm:~9.7.5"
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/059b122dda4138e5e7e461abd49350921e326735ca9a1d8aa19b1fdbae0937661b5f71af6fe82fd8f59e8db5549627849b38cc3f7ef2ec7ee9c93c3d6225174f
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/rafz@npm:~10.0.1":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/rafz@npm:10.0.1"
|
||||
checksum: 10c0/cba76f143d3a06f79dd0c09f7aefd17df9cca9b2c1ef7f9103255e5351326f4a42a5a1366f731a78f74380d96ba683bcc2a49312ed1e4b9e9e249e72c9ff68cb
|
||||
"@react-spring/rafz@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/rafz@npm:9.7.5"
|
||||
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/shared@npm:~10.0.1":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/shared@npm:10.0.1"
|
||||
"@react-spring/shared@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/shared@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/rafz": "npm:~10.0.1"
|
||||
"@react-spring/types": "npm:~10.0.1"
|
||||
"@react-spring/rafz": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/f056aaa018b3744afd8244e8eea24534d32f92fad9ace815b80e159b296fb5db148e2c9bd840ad9a5188e7a3c0778fd564b8af9ae02cd500e019a228398fb3cf
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/types@npm:~10.0.1":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/types@npm:10.0.1"
|
||||
checksum: 10c0/260890f9c156dc69b77c846510017156d8c0a07cce70edc7c108e57b0cf4122b26a15e724b191481a51b2c914296de9e81d56618b2c339339d4b221930691baa
|
||||
"@react-spring/types@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/types@npm:9.7.5"
|
||||
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/web@npm:^10.0.0":
|
||||
version: 10.0.1
|
||||
resolution: "@react-spring/web@npm:10.0.1"
|
||||
"@react-spring/web@npm:^9.4.4":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/web@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/animated": "npm:~10.0.1"
|
||||
"@react-spring/core": "npm:~10.0.1"
|
||||
"@react-spring/shared": "npm:~10.0.1"
|
||||
"@react-spring/types": "npm:~10.0.1"
|
||||
"@react-spring/animated": "npm:~9.7.5"
|
||||
"@react-spring/core": "npm:~9.7.5"
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/a0c788c9fd881ccb834feb22fc0694e74e59f7b76e498f0096f5b65e2c9812513955bf45ee27d7c5348f56a7bba7c5a6961d4be663728bb2172fe5aa6b6bdfc4
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5224,6 +5224,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.5.5
|
||||
resolution: "@types/qrcode@npm:1.5.5"
|
||||
@@ -5233,21 +5240,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^19.0.0":
|
||||
version: 19.1.5
|
||||
resolution: "@types/react-dom@npm:19.1.5"
|
||||
"@types/react-dom@npm:^18.3.0":
|
||||
version: 18.3.7
|
||||
resolution: "@types/react-dom@npm:18.3.7"
|
||||
peerDependencies:
|
||||
"@types/react": ^19.0.0
|
||||
checksum: 10c0/2a29e77cf6bb6e9f57bcfa54509c216cad2e16e244f0bd56369966ec88c072b9c91f6011d14f9e18fbfe2b801b18b86f616de75e5c8aef0be73c1f74abb33b49
|
||||
"@types/react": ^18.0.0
|
||||
checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:^19.0.0":
|
||||
version: 19.1.6
|
||||
resolution: "@types/react@npm:19.1.6"
|
||||
"@types/react@npm:^18.3.0":
|
||||
version: 18.3.23
|
||||
resolution: "@types/react@npm:18.3.23"
|
||||
dependencies:
|
||||
"@types/prop-types": "npm:*"
|
||||
csstype: "npm:^3.0.2"
|
||||
checksum: 10c0/8b10b198e28997b3c57559750f8bcf5ae7b33c554b16b6f4fe2ece1d4de6a2fc8cb53e7effe08ec9cb939d2f479eb97c5e08aac2cf83b10a90164fe451cc8ea2
|
||||
checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6859,7 +6867,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"copy-to-clipboard@npm:^3.3.3":
|
||||
"copy-to-clipboard@npm:^3.3.1":
|
||||
version: 3.3.3
|
||||
resolution: "copy-to-clipboard@npm:3.3.3"
|
||||
dependencies:
|
||||
@@ -7449,7 +7457,7 @@ __metadata:
|
||||
"@radix-ui/react-dialog": "npm:^1.0.4"
|
||||
"@radix-ui/react-slider": "npm:^1.1.2"
|
||||
"@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/vite-plugin": "npm:^3.0.0"
|
||||
"@stylistic/eslint-plugin": "npm:^3.0.0"
|
||||
@@ -7465,8 +7473,8 @@ __metadata:
|
||||
"@types/node": "npm:^22.0.0"
|
||||
"@types/pako": "npm:^2.0.3"
|
||||
"@types/qrcode": "npm:^1.5.5"
|
||||
"@types/react": "npm:^19.0.0"
|
||||
"@types/react-dom": "npm:^19.0.0"
|
||||
"@types/react": "npm:^18.3.0"
|
||||
"@types/react-dom": "npm:^18.3.0"
|
||||
"@types/sdp-transform": "npm:^2.4.5"
|
||||
"@types/uuid": "npm:10"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^8.31.0"
|
||||
@@ -7478,7 +7486,6 @@ __metadata:
|
||||
"@vitest/coverage-v8": "npm:^3.0.0"
|
||||
babel-plugin-transform-vite-meta-env: "npm:^1.0.3"
|
||||
classnames: "npm:^2.3.1"
|
||||
copy-to-clipboard: "npm:^3.3.3"
|
||||
eslint: "npm:^8.14.0"
|
||||
eslint-config-google: "npm:^0.14.0"
|
||||
eslint-config-prettier: "npm:^10.0.0"
|
||||
@@ -7509,10 +7516,11 @@ __metadata:
|
||||
posthog-js: "npm:1.160.3"
|
||||
prettier: "npm:^3.0.0"
|
||||
qrcode: "npm:^1.5.4"
|
||||
react: "npm:19"
|
||||
react-dom: "npm:19"
|
||||
react: "npm:18"
|
||||
react-dom: "npm:18"
|
||||
react-i18next: "npm:^15.0.0"
|
||||
react-router-dom: "npm:^7.0.0"
|
||||
react-use-clipboard: "npm:^1.0.7"
|
||||
react-use-measure: "npm:^2.1.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
sass: "npm:^1.42.1"
|
||||
@@ -9999,7 +10007,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.4.0":
|
||||
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
dependencies:
|
||||
@@ -11569,14 +11577,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:19":
|
||||
version: 19.1.0
|
||||
resolution: "react-dom@npm:19.1.0"
|
||||
"react-dom@npm:18":
|
||||
version: 18.3.1
|
||||
resolution: "react-dom@npm:18.3.1"
|
||||
dependencies:
|
||||
scheduler: "npm:^0.26.0"
|
||||
loose-envify: "npm:^1.1.0"
|
||||
scheduler: "npm:^0.23.2"
|
||||
peerDependencies:
|
||||
react: ^19.1.0
|
||||
checksum: 10c0/3e26e89bb6c67c9a6aa86cb888c7a7f8258f2e347a6d2a15299c17eb16e04c19194e3452bc3255bd34000a61e45e2cb51e46292392340432f133e5a5d2dfb5fc
|
||||
react: ^18.3.1
|
||||
checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11720,6 +11729,18 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.1.7
|
||||
resolution: "react-use-measure@npm:2.1.7"
|
||||
@@ -11733,10 +11754,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:19":
|
||||
version: 19.1.0
|
||||
resolution: "react@npm:19.1.0"
|
||||
checksum: 10c0/530fb9a62237d54137a13d2cfb67a7db6a2156faed43eecc423f4713d9b20c6f2728b026b45e28fcd72e8eadb9e9ed4b089e99f5e295d2f0ad3134251bdd3698
|
||||
"react@npm:18":
|
||||
version: 18.3.1
|
||||
resolution: "react@npm:18.3.1"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.1.0"
|
||||
checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12329,10 +12352,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scheduler@npm:^0.26.0":
|
||||
version: 0.26.0
|
||||
resolution: "scheduler@npm:0.26.0"
|
||||
checksum: 10c0/5b8d5bfddaae3513410eda54f2268e98a376a429931921a81b5c3a2873aab7ca4d775a8caac5498f8cbc7d0daeab947cf923dbd8e215d61671f9f4e392d34356
|
||||
"scheduler@npm:^0.23.2":
|
||||
version: 0.23.2
|
||||
resolution: "scheduler@npm:0.23.2"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.1.0"
|
||||
checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user