diff --git a/package.json b/package.json index eed707925..5e9137b80 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "matrix-widget-api": "^1.18.0", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", - "observable-hooks": "^4.2.3", "oxfmt": "^0.66.0", "oxlint": "^1.70.0", "oxlint-tsgolint": "^0.25.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12a3e8360..be07cd290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,9 +202,6 @@ importers: normalize.css: specifier: ^8.0.1 version: 8.0.1 - observable-hooks: - specifier: ^4.2.3 - version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2) oxfmt: specifier: ^0.66.0 version: 0.66.0 @@ -4816,13 +4813,6 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} - observable-hooks@4.2.4: - resolution: {integrity: sha512-FdTQgyw1h5bG/QHCBIqctdBSnv9VARJCEilgpV6L2qlw1yeLqFIwPm4U15dMtl5kDmNN0hSt+Nl6iYbLFwEcQA==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - rxjs: '>=6.0.0' - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -10410,12 +10400,6 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 - observable-hooks@4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2): - dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - rxjs: 7.8.2 - obug@2.1.4: {} once@1.4.0: diff --git a/src/MediaDevicesContext.ts b/src/MediaDevicesContext.ts index 801219b01..50de5bc97 100644 --- a/src/MediaDevicesContext.ts +++ b/src/MediaDevicesContext.ts @@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details. */ import { createContext, use, useMemo } from "react"; -import { useObservableEagerState } from "observable-hooks"; import { type MediaDevices } from "./state/MediaDevices"; +import { useBehavior } from "./useBehavior"; export const MediaDevicesContext = createContext( undefined, @@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): { volume: number; } => { const devices = useMediaDevices(); - const audioOutput = useObservableEagerState(devices.audioOutput.selected$); + const audioOutput = useBehavior(devices.audioOutput.selected$); const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false; return { // We use only the right speaker (pan = 1) for the earpiece. diff --git a/src/grid/CallLayout.test.tsx b/src/grid/CallLayout.test.tsx new file mode 100644 index 000000000..59e05d326 --- /dev/null +++ b/src/grid/CallLayout.test.tsx @@ -0,0 +1,187 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { act, render, screen } from "@testing-library/react"; +import { BehaviorSubject } from "rxjs"; +import { describe, expect, it, vi } from "vitest"; + +import { arrangeTiles, type Bounds } from "./CallLayout"; +import { makeGridLayout } from "./GridLayout"; +import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout"; +import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout"; +import { type DragCallback, type LayoutProps } from "./Grid"; +import { constant } from "../state/Behavior"; +import { + type Alignment, + type GridLayout, + type SpotlightLandscapeLayout, + type SpotlightPortraitLayout, +} from "../state/layout-types"; +import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { + GridTileViewModel, + SpotlightTileViewModel, + type TileViewModel, +} from "../state/TileViewModel"; + +// Grid supplies these hooks to the layouts it renders; on their own the +// layouts have no grid to report to +vi.mock("./Grid", async (importOriginal) => ({ + ...(await importOriginal>()), + useUpdateLayout: (): void => {}, + useVisibleTiles: (): void => {}, +})); + +describe("grid layout", () => { + it("sizes the tiles from the minimum bounds", () => { + const minBounds$ = new BehaviorSubject({ width: 800, height: 600 }); + const { scrolling: Scrolling } = makeGridLayout({ minBounds$ }); + render(); + const layer = screen.getByTestId("slot-0").parentElement!; + expect(layer.style.width).toBe("800px"); + expect(layer.style.getPropertyValue("--width")).toBe( + `${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`, + ); + + act(() => minBounds$.next({ width: 400, height: 300 })); + expect(layer.style.width).toBe("400px"); + expect(layer.style.getPropertyValue("--width")).toBe( + `${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`, + ); + }); + + it("moves the spotlight only when a drag reaches another corner", () => { + const model = gridModel(); + const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds }); + render(); + const alignments: Alignment[] = []; + model.spotlightAlignment$.subscribe((a) => alignments.push(a)); + const drag = drags.get("spotlight")!; + + // Still in the bottom right corner + act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 })); + // Over to the bottom left + act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 })); + // And a little further, still bottom left + act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 })); + // Up to the top left + act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 })); + + expect(alignments).toEqual([ + { inline: "end", block: "end" }, + { inline: "start", block: "end" }, + { inline: "start", block: "start" }, + ]); + const slot = screen.getByTestId("slot-spotlight"); + expect(slot.getAttribute("data-inline-alignment")).toBe("start"); + expect(slot.getAttribute("data-block-alignment")).toBe("start"); + }); +}); + +describe("spotlight landscape layout", () => { + it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => { + const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout( + { minBounds$: bounds }, + ); + const model = landscapeModel(); + const { unmount } = render(); + expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument(); + unmount(); + + render(); + expect(slotIds()).toEqual(model.grid.map((tile) => tile.id)); + }); +}); + +describe("spotlight portrait layout", () => { + it("sizes the grid tiles from the minimum width", () => { + const minBounds$ = new BehaviorSubject({ width: 300, height: 700 }); + const { scrolling: Scrolling } = makeSpotlightPortraitLayout({ + minBounds$, + }); + const model = portraitModel(); + render(); + expect(slotIds()).toEqual(model.grid.map((tile) => tile.id)); + const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement! + .parentElement!; + expect(layer.style.getPropertyValue("--grid-tile-width")).toBe( + `${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`, + ); + + act(() => minBounds$.next({ width: 500, height: 700 })); + expect(layer.style.getPropertyValue("--grid-tile-width")).toBe( + `${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`, + ); + }); +}); + +const bounds = constant({ width: 800, height: 600 }); + +// The layouts only pass tile view models through to their slots +const media = {} as UserMediaViewModel; +const spotlight = new SpotlightTileViewModel( + constant([]), + constant(false), + constant("solid"), +); +const tiles = (): GridTileViewModel[] => + Array.from({ length: 3 }, () => new GridTileViewModel(constant(media))); + +function gridModel(): GridLayout { + return { + type: "grid", + spotlight, + grid: tiles(), + spotlightAlignment$: new BehaviorSubject({ + inline: "end", + block: "end", + }), + setVisibleTiles: () => {}, + }; +} + +function landscapeModel(): SpotlightLandscapeLayout { + return { + type: "spotlight-landscape", + spotlight, + grid: tiles(), + setVisibleTiles: () => {}, + }; +} + +function portraitModel(): SpotlightPortraitLayout { + return { + type: "spotlight-portrait", + spotlight, + grid: tiles(), + setVisibleTiles: () => {}, + }; +} + +// Slots record their drag callback so a test can drag them, and show the id +// of the tile they hold +const drags = new Map(); +const Slot: LayoutProps["Slot"] = ({ + id, + model, + onDrag, + ...props +}) => { + drags.set(id, onDrag); + return ( +
+ {model instanceof GridTileViewModel ? model.id : "spotlight"} +
+ ); +}; + +function slotIds(): string[] { + return screen + .getAllByTestId(/^slot-/) + .map((slot) => slot.textContent ?? "") + .filter((id) => id !== "spotlight"); +} diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 3128087bc..a7dca6a36 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type Observable } from "rxjs"; import { type ComponentType } from "react"; import { type LayoutProps } from "./Grid"; import { type TileViewModel } from "../state/TileViewModel"; +import { type Behavior } from "../state/Behavior"; export interface Bounds { width: number; @@ -20,7 +20,7 @@ export interface CallLayoutInputs { /** * The minimum bounds of the layout area. */ - minBounds$: Observable; + minBounds$: Behavior; } export interface CallLayoutOutputs { diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 79c2b3a4a..ecf451435 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -11,14 +11,12 @@ import { useCallback, useMemo, } from "react"; -import { distinctUntilChanged } from "rxjs"; -import { useObservableEagerState } from "observable-hooks"; import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts"; import styles from "./GridLayout.module.css"; -import { useInitial } from "../useInitial"; import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid"; +import { useBehavior } from "../useBehavior"; interface GridCSSProperties extends CSSProperties { "--gap": string; @@ -39,22 +37,18 @@ export const makeGridLayout: CallLayout = ({ // lives fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode { useUpdateLayout(); - const alignment = useObservableEagerState( - useInitial(() => - model.spotlightAlignment$.pipe( - distinctUntilChanged( - (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, - ), - ), - ), - ); + const alignment = useBehavior(model.spotlightAlignment$); const onDragSpotlight: DragCallback = useCallback( - ({ xRatio, yRatio }) => - model.spotlightAlignment$.next({ - block: yRatio < 0.5 ? "start" : "end", - inline: xRatio < 0.5 ? "start" : "end", - }), + ({ xRatio, yRatio }) => { + const block = yRatio < 0.5 ? "start" : "end"; + const inline = xRatio < 0.5 ? "start" : "end"; + // A drag reports the same alignment on every move; only a change is + // worth a re-render + const current = model.spotlightAlignment$.value; + if (current.block !== block || current.inline !== inline) + model.spotlightAlignment$.next({ block, inline }); + }, [model.spotlightAlignment$], ); @@ -78,7 +72,7 @@ export const makeGridLayout: CallLayout = ({ scrolling: function GridLayout({ ref, model, Slot }): ReactNode { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - const { width, height: minHeight } = useObservableEagerState(minBounds$); + const { width, height: minHeight } = useBehavior(minBounds$); const { gap, tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, minHeight, model.grid.length), [width, minHeight, model.grid.length], diff --git a/src/grid/OneOnOneDesktopLayout.tsx b/src/grid/OneOnOneDesktopLayout.tsx index ccf24977f..beba14c10 100644 --- a/src/grid/OneOnOneDesktopLayout.tsx +++ b/src/grid/OneOnOneDesktopLayout.tsx @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useCallback, useMemo } from "react"; -import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts"; @@ -37,7 +36,7 @@ export const makeOneOnOneDesktopLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const { width, height } = useObservableEagerState(minBounds$); + const { width, height } = useBehavior(minBounds$); const pipAlignment = useBehavior(model.pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 5da12a89c..989e3d398 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { type FC, type ReactNode } from "react"; -import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; import { type CallLayout } from "./CallLayout"; @@ -33,7 +32,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - useObservableEagerState(minBounds$); + useBehavior(minBounds$); return (
@@ -56,7 +55,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< }): ReactNode { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - useObservableEagerState(minBounds$); + useBehavior(minBounds$); return (
diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 6939e0826..70c428e97 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, type CSSProperties } from "react"; -import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; import { type CallLayout, arrangeTiles } from "./CallLayout"; @@ -58,7 +57,7 @@ export const makeSpotlightPortraitLayout: CallLayout< }): ReactNode { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - const { width } = useObservableEagerState(minBounds$); + const { width } = useBehavior(minBounds$); const { gap, tileWidth, tileHeight } = arrangeTiles( width, // TODO: We pretend that the minimum height is the width, because the diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index 96897929f..9b930949b 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -20,8 +20,7 @@ import { } from "react"; import { type LocalVideoTrack } from "livekit-client"; import { logger } from "matrix-js-sdk/lib/logger"; -import { combineLatest, map, type Observable } from "rxjs"; -import { useObservable } from "observable-hooks"; +import { combineLatest } from "rxjs"; import { backgroundBlur as backgroundBlurSettings, @@ -31,6 +30,7 @@ import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer"; import { type Behavior } from "../state/Behavior"; import { type ObservableScope } from "../state/ObservableScope"; import { platform } from "../Platform"; +import { useValueBehavior } from "../useValueBehavior"; //TODO-MULTI-SFU: This is not yet fully there. // it is a combination of exposing observable and react hooks. @@ -52,18 +52,8 @@ export function useTrackProcessor(): ProcessorState { return state; } -export function useTrackProcessorObservable$(): Observable { - const state = use(ProcessorContext); - if (state === undefined) - throw new Error( - "useTrackProcessor must be used within a ProcessorProvider", - ); - const state$ = useObservable( - (init$) => init$.pipe(map(([init]) => init)), - [state], - ); - - return state$; +export function useTrackProcessorState$(): Behavior { + return useValueBehavior(useTrackProcessor()); } /** diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5f706cabd..b8d7ab89d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -20,8 +20,6 @@ import { import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; -import { map } from "rxjs"; -import { useObservable } from "observable-hooks"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { useTranslation } from "react-i18next"; @@ -77,10 +75,11 @@ import { useAppBarSubtitle, } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { useValueBehavior } from "../useValueBehavior.ts"; import { constant } from "../state/Behavior.ts"; import { Toast } from "../Toast.tsx"; import overlayStyles from "../Overlay.module.css"; -import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx"; +import { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx"; import { type Layout } from "../state/layout-types.ts"; import { ObservableScope } from "../state/ObservableScope.ts"; import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx"; @@ -121,7 +120,7 @@ export const ActiveCall: FC = (props) => { const urlParams = useUrlParams(); const hostBridge = useHostBridge(); const mediaDevices = useMediaDevices(); - const trackProcessorState$ = useTrackProcessorObservable$(); + const trackProcessorState$ = useTrackProcessorState$(); // The element we have to draw the call in: the page, or the container a host // gave us. Its size, not the window's, decides how the call is laid out. const rootElement = useRootElement(); @@ -151,7 +150,7 @@ export const ActiveCall: FC = (props) => { }, reactionsReader.raisedHands$, reactionsReader.reactions$, - scope.behavior(trackProcessorState$), + trackProcessorState$, ); // TODO move this somewhere else once we use the callViewModel in the lobby as well! vm.join(); @@ -252,7 +251,7 @@ export const InCallView: FC = ({ useWakeLock(); // TODO-MULTI-SFU This is unused now?? - // const connectionState = useObservableEagerState(vm.livekitConnectionState$); + // const connectionState = useBehavior(vm.livekitConnectionState$); // annoyingly we don't get the disconnection reason this way, // only by listening for the emitted event @@ -374,10 +373,7 @@ export const InCallView: FC = ({ edgeToEdge, ], ); - const gridBoundsObservable$ = useObservable( - (inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)), - [gridBounds], - ); + const gridBounds$ = useValueBehavior(gridBounds); useAppBarHidden(!showHeader); useAppBarSubtitle( @@ -538,7 +534,7 @@ export const InCallView: FC = ({ ); const layouts = useMemo(() => { - const inputs = { minBounds$: gridBoundsObservable$ }; + const inputs = { minBounds$: gridBounds$ }; return { grid: makeGridLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs), @@ -547,7 +543,7 @@ export const InCallView: FC = ({ "one-on-one-desktop": makeOneOnOneDesktopLayout(inputs), "one-on-one-mobile": makeOneOnOneMobileLayout(inputs), }; - }, [gridBoundsObservable$]); + }, [gridBounds$]); const showFooter = useBehavior(footerVm.showFooter$); const renderContent = (): JSX.Element => { diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 9e6e0ed99..5814e4316 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -24,7 +24,6 @@ import { type LocalVideoTrack, Track, } from "livekit-client"; -import { useObservableEagerState } from "observable-hooks"; import inCallStyles from "./InCallView.module.css"; import styles from "./LobbyView.module.css"; @@ -125,9 +124,7 @@ export const LobbyView: FC = ({ ); const devices = useMediaDevices(); - const videoInputId = useObservableEagerState( - devices.videoInput.selected$, - )?.id; + const videoInputId = useBehavior(devices.videoInput.selected$)?.id; // Capture the audio options as they were when we first mounted, because // we're not doing anything with the audio anyway so we don't need to diff --git a/src/settings/DeviceSelection.test.tsx b/src/settings/DeviceSelection.test.tsx new file mode 100644 index 000000000..1e57d011c --- /dev/null +++ b/src/settings/DeviceSelection.test.tsx @@ -0,0 +1,82 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Form } from "@vector-im/compound-web"; +import { describe, expect, it, vi } from "vitest"; + +import { DeviceSelection } from "./DeviceSelection"; +import { constant } from "../state/Behavior"; +import { + type AudioOutputDeviceLabel, + type DeviceLabel, + type MediaDevice, + type SelectedDevice, +} from "../state/MediaDevices"; + +type Device = MediaDevice; + +function mockDevice( + available: [string, DeviceLabel][], + selectedId: string | undefined, +): Device & { select: ReturnType } { + return { + available$: constant(new Map(available)), + selected$: constant( + selectedId === undefined ? undefined : { id: selectedId }, + ), + select: vi.fn(), + } as unknown as Device & { select: ReturnType }; +} + +const twoDevices: [string, DeviceLabel][] = [ + ["a", { type: "name", name: "Mic A" }], + ["b", { type: "number", number: 2 }], +]; + +describe("DeviceSelection", () => { + it("marks the selected device and selects another on click", async () => { + const device = mockDevice(twoDevices, "a"); + render( + + `Microphone ${n}`} + /> + , + ); + + const first = screen.getByRole("radio", { + name: "Mic A", + }); + const second = screen.getByRole("radio", { + name: "Microphone 2", + }); + expect(first.checked).toBe(true); + expect(second.checked).toBe(false); + + await userEvent.click(second); + expect(device.select).toHaveBeenCalledWith("b"); + }); + + it("shows nothing when there is no choice to make", () => { + const device = mockDevice([twoDevices[0]], "a"); + render( + + `Microphone ${n}`} + /> + , + ); + expect(screen.queryByRole("radio")).toBeNull(); + expect(screen.queryByText("Microphone")).toBeNull(); + }); +}); diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 197046c34..c2bef34f0 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -21,7 +21,6 @@ import { Separator, } from "@vector-im/compound-web"; import { Trans, useTranslation } from "react-i18next"; -import { useObservableEagerState } from "observable-hooks"; import { type AudioOutputDeviceLabel, @@ -30,6 +29,7 @@ import { type MediaDevice, } from "../state/MediaDevices"; import styles from "./DeviceSelection.module.css"; +import { useBehavior } from "../useBehavior"; interface Props { device: MediaDevice; @@ -44,8 +44,8 @@ export const DeviceSelection: FC = ({ }) => { const { t } = useTranslation(); const groupId = useId(); - const available = useObservableEagerState(device.available$); - const selectedId = useObservableEagerState(device.selected$)?.id; + const available = useBehavior(device.available$); + const selectedId = useBehavior(device.selected$)?.id; const onChange = useCallback( (e: ChangeEvent) => { device.select(e.target.value); diff --git a/src/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index 9d873ccba..872fdd3ed 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -25,6 +25,7 @@ import { mockRemoteScreenShare, } from "../../utils/test"; import { constant } from "../Behavior"; +import { showConnectionStats } from "../../settings/settings"; global.MediaStreamTrack = class {} as unknown as { new (): MediaStreamTrack; @@ -42,6 +43,12 @@ vi.mock("../../Platform", () => ({ }, })); +const observeRtpStreamStatsMock = vi.hoisted(() => vi.fn()); +vi.mock("./observeRtpStreamStats", async (importOriginal) => ({ + ...(await importOriginal>()), + observeRtpStreamStats$: observeRtpStreamStatsMock, +})); + const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA"); test("control a participant's volume", () => { @@ -289,3 +296,37 @@ test("remote media is not in waiting state when user does not intend to publish ); expect(vm.waitingForMedia$.value).toBe(false); }); + +test("user media polls stream stats only while the setting is on", () => { + const participant = mockRemoteParticipant({}); + const vm = mockRemoteMedia(rtcMembership, {}, participant); + onTestFinished(() => showConnectionStats.setValue(false)); + withTestScheduler(({ cold, expectObservable, schedule }) => { + const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats; + observeRtpStreamStatsMock.mockImplementation(() => + cold("-s", { s: stats }), + ); + schedule("-a-b", { + a() { + showConnectionStats.setValue(true); + }, + b() { + showConnectionStats.setValue(false); + }, + }); + expectObservable(vm.audioStreamStats$).toBe("u-su", { + u: undefined, + s: stats, + }); + }); + expect(observeRtpStreamStatsMock).toHaveBeenCalledWith( + participant, + Track.Source.Microphone, + "inbound-rtp", + ); + expect(observeRtpStreamStatsMock).toHaveBeenCalledWith( + participant, + Track.Source.Camera, + "inbound-rtp", + ); +}); diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index 61d534656..58fd353ef 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -10,7 +10,6 @@ import { BehaviorSubject, combineLatest, map, - type Observable, of, Subject, switchMap, @@ -54,10 +53,10 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel { rtcBackendIdentity: string; handRaised$: Behavior; reaction$: Behavior; - audioStreamStats$: Observable< + audioStreamStats$: Behavior< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; - videoStreamStats$: Observable< + videoStreamStats$: Behavior< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; /** @@ -94,6 +93,21 @@ export function createBaseUserMedia( ); const toggleCropVideo$ = new Subject(); const videoAspectRatio$ = new BehaviorSubject(NaN); + const streamStats$ = ( + scope: ObservableScope, + source: Track.Source, + ): Behavior< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + > => + scope.behavior( + combineLatest([participant$, showConnectionStats.value$]).pipe( + switchMap(([p, showConnectionStats]) => + !p || !showConnectionStats + ? of(undefined) + : observeRtpStreamStats$(p, source, statsType), + ), + ), + ); return { ...createMemberMedia(scope, { @@ -130,25 +144,8 @@ export function createBaseUserMedia( rtcBackendIdentity, handRaised$, reaction$, - audioStreamStats$: combineLatest([ - participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - // - if (!p || !showConnectionStats) return of(undefined); - return observeRtpStreamStats$(p, Track.Source.Microphone, statsType); - }), - ), - videoStreamStats$: combineLatest([ - participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeRtpStreamStats$(p, Track.Source.Camera, statsType); - }), - ), + audioStreamStats$: streamStats$(scope, Track.Source.Microphone), + videoStreamStats$: streamStats$(scope, Track.Source.Camera), setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio), }; } diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 1334e210c..e00186320 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -37,7 +37,6 @@ import { Menu, Text, } from "@vector-im/compound-web"; -import { useObservableEagerState } from "observable-hooks"; import styles from "./GridTile.module.css"; import { Slider } from "../Slider"; @@ -133,12 +132,8 @@ const UserMediaTileInner: FC = ({ const { t } = useTranslation(); const video = useBehavior(vm.video$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$); - const audioStreamStats = useObservableEagerState< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >(vm.audioStreamStats$); - const videoStreamStats = useObservableEagerState< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >(vm.videoStreamStats$); + const audioStreamStats = useBehavior(vm.audioStreamStats$); + const videoStreamStats = useBehavior(vm.videoStreamStats$); const audioEnabled = useBehavior(vm.audioEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$); const speaking = useBehavior(vm.speaking$); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 97e1f4a62..71b98e8d8 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -26,8 +26,7 @@ import { VolumeOnSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; -import { type Observable, map } from "rxjs"; -import { useObservableRef } from "observable-hooks"; +import { BehaviorSubject, type Observable, map } from "rxjs"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; @@ -416,7 +415,13 @@ export const SpotlightTile: FC = ({ }) => { const { t } = useTranslation(); const rootElement = useRootElement(); - const [ourRef, root$] = useObservableRef(null); + const root$ = useInitial( + () => new BehaviorSubject(null), + ); + const ourRef = useCallback( + (root: HTMLDivElement | null) => root$.next(root), + [root$], + ); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); const background = useBehavior(vm.background$); diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index 0bed082d1..8af8bdeb0 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import { logger } from "matrix-js-sdk/lib/logger"; import { useState, useEffect } from "react"; -import { useObservableEagerState } from "observable-hooks"; import { soundEffectVolume as soundEffectVolumeSetting, @@ -17,6 +16,7 @@ import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext"; import { type PrefetchedSounds } from "./soundUtils"; import { useUrlParams } from "./UrlParams"; import * as controls from "./controls"; +import { useBehavior } from "./useBehavior"; /** * Play a sound though a given AudioContext. Will take @@ -168,7 +168,7 @@ export function useAudioContext( }; }, [props.sounds, props.latencyHint]); - const audioOutputId = useObservableEagerState( + const audioOutputId = useBehavior( useMediaDevices().audioOutput.selected$, )?.id; const { controlledAudioDevices } = useUrlParams(); diff --git a/src/useBehavior.test.tsx b/src/useBehavior.test.tsx new file mode 100644 index 000000000..e0b0faeff --- /dev/null +++ b/src/useBehavior.test.tsx @@ -0,0 +1,23 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { act, renderHook } from "@testing-library/react"; +import { BehaviorSubject } from "rxjs"; +import { describe, expect, test } from "vitest"; + +import { useBehavior } from "./useBehavior"; + +describe("useBehavior", () => { + test("reads the current value and follows changes", () => { + const value$ = new BehaviorSubject(1); + const { result } = renderHook(() => useBehavior(value$)); + expect(result.current).toBe(1); + + act(() => value$.next(2)); + expect(result.current).toBe(2); + }); +}); diff --git a/src/useValueBehavior.test.tsx b/src/useValueBehavior.test.tsx new file mode 100644 index 000000000..18de2c361 --- /dev/null +++ b/src/useValueBehavior.test.tsx @@ -0,0 +1,34 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { useValueBehavior } from "./useValueBehavior"; + +describe("useValueBehavior", () => { + test("holds the value from the first render", () => { + const { result } = renderHook(({ value }) => useValueBehavior(value), { + initialProps: { value: "a" }, + }); + expect(result.current.value).toBe("a"); + }); + + test("keeps one identity and takes each new value", () => { + const { result, rerender } = renderHook( + ({ value }) => useValueBehavior(value), + { initialProps: { value: "a" } }, + ); + const value$ = result.current; + const seen: string[] = []; + value$.subscribe((value) => seen.push(value)); + + rerender({ value: "b" }); + expect(result.current).toBe(value$); + expect(seen).toEqual(["a", "b"]); + }); +}); diff --git a/src/useValueBehavior.ts b/src/useValueBehavior.ts new file mode 100644 index 000000000..492fe2304 --- /dev/null +++ b/src/useValueBehavior.ts @@ -0,0 +1,25 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useEffect } from "react"; +import { BehaviorSubject } from "rxjs"; + +import { type Behavior } from "./state/Behavior"; +import { useInitial } from "./useInitial"; + +/** + * React hook which mirrors a value from the render cycle into a behavior, so + * that a prop or memoised value can be handed to a view model. The behavior + * takes each new value once the render that produced it has committed. + */ +export function useValueBehavior(value: T): Behavior { + const value$ = useInitial(() => new BehaviorSubject(value)); + useEffect(() => { + value$.next(value); + }, [value$, value]); + return value$; +}