Merge branch 'livekit' into fkwp/dev_build

This commit is contained in:
fkwp
2025-06-06 00:14:45 +02:00
14 changed files with 285 additions and 139 deletions

View File

@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
[plugins] [plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" } maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" }

View File

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

View File

@@ -29,6 +29,7 @@ import { useAudioContext } from "../useAudioContext";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
import { import {
flushPromises, flushPromises,
mockEmitter,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockRtcMembership, mockRtcMembership,
@@ -129,6 +130,7 @@ function createGroupCallView(
getMxcAvatarUrl: () => null, getMxcAvatarUrl: () => null,
getCanonicalAlias: () => null, getCanonicalAlias: () => null,
currentState: { currentState: {
...mockEmitter(),
getJoinRule: () => JoinRule.Invite, getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState, } as Partial<RoomState> as RoomState,
}); });

View File

@@ -9,11 +9,13 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef } from "react";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
import { useTypedEventEmitterState } from "../useEvents";
/** /**
* Gets the currently active (livekit) focus for a MatrixRTC session * Gets the currently active (livekit) focus for a MatrixRTC session
* This logic is specific to livekit foci where the whole call must use one * This logic is specific to livekit foci where the whole call must use one
@@ -22,38 +24,27 @@ import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
export function useActiveLivekitFocus( export function useActiveLivekitFocus(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): LivekitFocus | undefined { ): LivekitFocus | undefined {
const [activeFocus, setActiveFocus] = useState(() => { const activeFocus = useTypedEventEmitterState(
const f = rtcSession.getActiveFocus(); rtcSession,
// Only handle foci with type="livekit" for now. MatrixRTCSessionEvent.MembershipsChanged,
return !!f && isLivekitFocus(f) ? f : undefined; useCallback(() => {
}); const f = rtcSession.getActiveFocus();
// Only handle foci with type="livekit" for now.
return !!f && isLivekitFocus(f) ? f : undefined;
}, [rtcSession]),
);
const onMembershipsChanged = useCallback(() => { const prevActiveFocus = useRef(activeFocus);
const newActiveFocus = rtcSession.getActiveFocus(); useEffect(() => {
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return; if (!deepCompare(activeFocus, prevActiveFocus.current)) {
if (!deepCompare(activeFocus, newActiveFocus)) {
const oldestMembership = rtcSession.getOldestMembership(); const oldestMembership = rtcSession.getOldestMembership();
logger.warn( logger.warn(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}. `Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`, Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
); );
setActiveFocus(newActiveFocus); prevActiveFocus.current = activeFocus;
} }
}, [activeFocus, rtcSession]); }, [activeFocus, rtcSession]);
useEffect(() => {
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
return (): void => {
rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
};
});
return activeFocus; return activeFocus;
} }

View File

@@ -1,18 +1,19 @@
/* /*
Copyright 2023, 2024 New Vector Ltd. Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type Room, RoomEvent } from "matrix-js-sdk"; import { type Room, RoomEvent } from "matrix-js-sdk";
import { useState } from "react"; import { useCallback } from "react";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitterState } from "../useEvents";
export function useRoomName(room: Room): string { export function useRoomName(room: Room): string {
const [, setNumUpdates] = useState(0); return useTypedEventEmitterState(
// Whenever the name changes, force an update room,
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1)); RoomEvent.Name,
return room.name; useCallback(() => room.name, [room]),
);
} }

View File

@@ -5,10 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback } from "react";
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk"; import {
type RoomState,
RoomStateEvent,
type Room,
RoomEvent,
} from "matrix-js-sdk";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitterState } from "../useEvents";
/** /**
* A React hook for values computed from room state. * A React hook for values computed from room state.
@@ -16,14 +21,17 @@ import { useTypedEventEmitter } from "../useEvents";
* @param f A mapping from the current room state to the computed value. * @param f A mapping from the current room state to the computed value.
* @returns The computed value. * @returns The computed value.
*/ */
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => { export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
const [numUpdates, setNumUpdates] = useState(0); // TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
useTypedEventEmitter( // clear how to reactively track the current state of the room without it
const currentState = useTypedEventEmitterState(
room, room,
RoomStateEvent.Update, RoomEvent.CurrentStateUpdated,
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]), useCallback(() => room.currentState, [room]),
); );
// We want any change to the update counter to trigger an update here return useTypedEventEmitterState(
// eslint-disable-next-line react-hooks/exhaustive-deps currentState,
return useMemo(() => f(room.currentState), [room, f, numUpdates]); RoomStateEvent.Update,
}; useCallback(() => f(currentState), [f, currentState]),
);
}

88
src/useEvents.test.tsx Normal file
View File

@@ -0,0 +1,88 @@
/*
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. Please see LICENSE in the repository root for full details.
*/ */
import { useEffect } from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react";
import type { import type {
Listener, Listener,
@@ -13,7 +13,9 @@ import type {
TypedEventEmitter, TypedEventEmitter,
} from "matrix-js-sdk/lib/models/typed-event-emitter"; } from "matrix-js-sdk/lib/models/typed-event-emitter";
// Shortcut for registering a listener on an EventTarget /**
* Shortcut for registering a listener on an EventTarget.
*/
export function useEventTarget<T extends Event>( export function useEventTarget<T extends Event>(
target: EventTarget | null | undefined, target: EventTarget | null | undefined,
eventType: string, eventType: string,
@@ -33,7 +35,9 @@ export function useEventTarget<T extends Event>(
}, [target, eventType, listener, options]); }, [target, eventType, listener, options]);
} }
// Shortcut for registering a listener on a TypedEventEmitter /**
* Shortcut for registering a listener on a TypedEventEmitter.
*/
export function useTypedEventEmitter< export function useTypedEventEmitter<
Events extends string, Events extends string,
Arguments extends ListenerMap<Events>, Arguments extends ListenerMap<Events>,
@@ -50,3 +54,33 @@ export function useTypedEventEmitter<
}; };
}, [emitter, eventType, listener]); }, [emitter, eventType, listener]);
} }
/**
* Reactively tracks a value which is recalculated whenever the provided event
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
* into React.
*/
export function useTypedEventEmitterState<
Events extends string,
Arguments extends ListenerMap<Events>,
T extends Events,
State,
>(
emitter: TypedEventEmitter<Events, Arguments>,
eventType: T,
getState: () => State,
): State {
const subscribe = useCallback(
(onChange: () => void) => {
emitter.on(eventType, onChange as Listener<Events, Arguments, T>);
return (): void => {
emitter.off(eventType, onChange as Listener<Events, Arguments, T>);
};
},
[emitter, eventType],
);
// See the React docs for useSyncExternalStore; given that we're trying to
// bridge state from an external source into React, using this hook is exactly
// what React recommends.
return useSyncExternalStore(subscribe, getState);
}

View File

@@ -0,0 +1,51 @@
/*
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,50 +5,44 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import EventEmitter from "events"; import { useCallback } from "react";
import { useCallback, useEffect, useState } from "react"; import { TypedEventEmitter } from "matrix-js-sdk";
import { useTypedEventEmitterState } from "./useEvents";
type LocalStorageItem = ReturnType<typeof localStorage.getItem>; type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
// Bus to notify other useLocalStorage consumers when an item is changed // Bus to notify other useLocalStorage consumers when an item is changed
export const localStorageBus = new EventEmitter(); export const localStorageBus = new TypedEventEmitter<
string,
{ [key: string]: () => void }
>();
/** /**
* Like useState, but reads from and persists the value to localStorage * Like useState, but reads from and persists the value to localStorage
* This hook will not update when we write to localStorage.setItem(key, value) directly. * 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`. * For the hook to react either use the returned setter or `setLocalStorageItemReactive`.
*/ */
export const useLocalStorage = ( export function useLocalStorage(
key: string, key: string,
): [LocalStorageItem, (value: string) => void] => { ): [LocalStorageItem, (value: string) => void] {
const [value, setValue] = useState<LocalStorageItem>(() => const value = useTypedEventEmitterState(
localStorage.getItem(key), localStorageBus,
key,
useCallback(() => localStorage.getItem(key), [key]),
);
const setValue = useCallback(
(newValue: string) => setLocalStorageItemReactive(key, newValue),
[key],
); );
useEffect(() => { return [value, setValue];
localStorageBus.on(key, setValue); }
return (): void => {
localStorageBus.off(key, setValue);
};
}, [key, setValue]);
return [
value,
useCallback(
(newValue: string) => {
setValue(newValue);
localStorage.setItem(key, newValue);
localStorageBus.emit(key, newValue);
},
[key, setValue],
),
];
};
export const setLocalStorageItemReactive = ( export const setLocalStorageItemReactive = (
key: string, key: string,
value: string, value: string,
): void => { ): void => {
localStorage.setItem(key, value); localStorage.setItem(key, value);
localStorageBus.emit(key, value); localStorageBus.emit(key);
}; };

View File

@@ -10,33 +10,31 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useEffect, useState } from "react"; import { TypedEventEmitter } from "matrix-js-sdk";
import { useCallback, useEffect } from "react";
import { useTypedEventEmitterState } from "./useEvents";
const dummySession = new TypedEventEmitter();
export function useMatrixRTCSessionJoinState( export function useMatrixRTCSessionJoinState(
rtcSession: MatrixRTCSession | undefined, rtcSession: MatrixRTCSession | undefined,
): boolean { ): boolean {
const [, setNumUpdates] = useState(0); // 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]),
);
useEffect(() => { useEffect(() => {
if (rtcSession !== undefined) { logger.info(
const onJoinStateChanged = (isJoined: boolean): void => { `Session in room ${rtcSession?.room.roomId} changed to ${
logger.info( isJoined ? "joined" : "left"
`Session in room ${rtcSession.room.roomId} changed to ${ }`,
isJoined ? "joined" : "left" );
}`, }, [rtcSession, isJoined]);
);
setNumUpdates((n) => n + 1); // Force an update
};
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
return (): void => { return isJoined;
rtcSession.off(
MatrixRTCSessionEvent.JoinStateChanged,
onJoinStateChanged,
);
};
}
}, [rtcSession]);
return rtcSession?.isJoined() ?? false;
} }

View File

@@ -5,39 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react"; import { useCallback } from "react";
import { useTypedEventEmitterState } from "./useEvents";
export function useMatrixRTCSessionMemberships( export function useMatrixRTCSessionMemberships(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): CallMembership[] { ): CallMembership[] {
const [memberships, setMemberships] = useState(rtcSession.memberships); return useTypedEventEmitterState(
rtcSession,
const onMembershipsChanged = useCallback(() => { MatrixRTCSessionEvent.MembershipsChanged,
logger.info( useCallback(() => rtcSession.memberships, [rtcSession]),
`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,13 +1,11 @@
/* /*
Copyright 2023, 2024 New Vector Ltd. Copyright 2023-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useEventTarget } from "./useEvents";
/** /**
* React hook that tracks whether the given media query matches. * React hook that tracks whether the given media query matches.
@@ -15,14 +13,13 @@ import { useEventTarget } from "./useEvents";
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const mediaQuery = useMemo(() => window.matchMedia(query), [query]); const mediaQuery = useMemo(() => window.matchMedia(query), [query]);
const [numChanges, setNumChanges] = useState(0); const subscribe = useCallback(
useEventTarget( (onChange: () => void) => {
mediaQuery, mediaQuery.addEventListener("change", onChange);
"change", return (): void => mediaQuery.removeEventListener("change", onChange);
useCallback(() => setNumChanges((n) => n + 1), [setNumChanges]), },
[mediaQuery],
); );
const getState = useCallback(() => mediaQuery.matches, [mediaQuery]);
// We want any change to the update counter to trigger an update here return useSyncExternalStore(subscribe, getState);
// 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; removeListener: () => T;
} }
function mockEmitter<T>(): EmitterMock<T> { export function mockEmitter<T>(): EmitterMock<T> {
return { return {
on(): T { on(): T {
return this as T; return this as T;