Compare commits

..

2 Commits

Author SHA1 Message Date
Timo
7f47892f0a review 2025-06-04 22:44:50 +02:00
Timo
6940cb1a96 Fix react errors in settings tab 2025-06-04 12:27:04 +02:00
42 changed files with 430 additions and 627 deletions

View File

@@ -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() }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
signAllPublications()

View File

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

View File

@@ -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,
},
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
);

View File

@@ -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,
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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],
);
}

View File

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

View File

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

View File

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

View File

@@ -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]);
};

View File

@@ -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),
);
}

View File

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

View File

@@ -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");
});

View File

@@ -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);
}

View File

@@ -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");
});

View File

@@ -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);
};

View File

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

View File

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

View File

@@ -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]);
}

View File

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

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