Replace many usages of useObservableEagerState with useBehavior

This hook is simpler in its implementation (therefore hopefully more correct & performant) and enforces a type-level distinction between raw Observables and Behaviors.
This commit is contained in:
Robin
2025-06-18 18:33:35 -04:00
parent 35ed313577
commit b3863748dc
26 changed files with 251 additions and 212 deletions

View File

@@ -24,8 +24,6 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames"; import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
import { useReactionsSender } from "../reactions/useReactionsSender"; import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css"; import styles from "./ReactionToggleButton.module.css";
@@ -36,6 +34,7 @@ import {
} from "../reactions"; } from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean; raised: boolean;
@@ -180,12 +179,8 @@ export function ReactionToggleButton({
const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const isHandRaised = useObservableState( const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
vm.handsRaised$.pipe(map((v) => !!v[identifier])), const canReact = !useBehavior(vm.reactions$)[identifier];
);
const canReact = useObservableState(
vm.reactions$.pipe(map((v) => !v[identifier])),
);
useEffect(() => { useEffect(() => {
// Clear whenever the reactions menu state changes. // Clear whenever the reactions menu state changes.

View File

@@ -24,16 +24,16 @@ import {
createContext, createContext,
memo, memo,
use, use,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { fromEvent, map, startWith } from "rxjs";
import styles from "./Grid.module.css"; import styles from "./Grid.module.css";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
@@ -155,11 +155,6 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
); );
} }
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null),
map(() => window.innerHeight),
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> { export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref?: Ref<R>; ref?: Ref<R>;
model: LayoutModel; model: LayoutModel;
@@ -261,7 +256,13 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null); const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2); const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useObservableEagerState(windowHeightObservable$); const windowHeight = useSyncExternalStore(
useCallback((onChange) => {
window.addEventListener("resize", onChange);
return (): void => window.removeEventListener("resize", onChange);
}, []),
useCallback(() => window.innerHeight, []),
);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] = const [visibleTilesCallback, setVisibleTilesCallback] =

View File

@@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/** /**
* An implementation of the "one-on-one" layout, in which the remote participant * An implementation of the "one-on-one" layout, in which the remote participant
@@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$); const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useBehavior(pipAlignment$);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1), () => arrangeTiles(width, height, 1),
[width, height], [width, height],

View File

@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useCallback } from "react"; import { type ReactNode, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css"; import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
/** /**
* An implementation of the "expanded spotlight" layout, in which the spotlight * An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useBehavior(pipAlignment$);
const onDragPip: DragCallback = useCallback( const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>

View File

@@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css"; import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--grid-gap": string; "--grid-gap": string;
@@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width, width,
model.grid.length, model.grid.length,
); );
const withIndicators = const withIndicators = useBehavior(model.spotlight.media$).length > 1;
useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div <div

View File

@@ -16,12 +16,12 @@ import {
} from "react"; } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext"; import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "."; import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
interface ReactionsSenderContextType { interface ReactionsSenderContextType {
supportsReactions: boolean; supportsReactions: boolean;
@@ -70,7 +70,7 @@ export const ReactionsSenderProvider = ({
[memberships, myUserId, myDeviceId], [memberships, myUserId, myDeviceId],
); );
const reactions = useObservableEagerState(vm.reactions$); const reactions = useBehavior(vm.reactions$);
const myReaction = useMemo( const myReaction = useMemo(
() => () =>
myMembershipIdentifier !== undefined myMembershipIdentifier !== undefined
@@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({
[myMembershipIdentifier, reactions], [myMembershipIdentifier, reactions],
); );
const handsRaised = useObservableEagerState(vm.handsRaised$); const handsRaised = useBehavior(vm.handsRaised$);
const myRaisedHand = useMemo( const myRaisedHand = useMemo(
() => () =>
myMembershipIdentifier !== undefined myMembershipIdentifier !== undefined

View File

@@ -24,7 +24,6 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useObservableEagerState } from "observable-hooks";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { import {
@@ -72,6 +71,7 @@ import {
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx"; import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
declare global { declare global {
interface Window { interface Window {
@@ -110,7 +110,7 @@ export const GroupCallView: FC<Props> = ({
); );
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const muteAllAudio = useObservableEagerState(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest( const leaveSoundContext = useLatest(
useAudioContext({ useAudioContext({
sounds: callEventAudioSounds, sounds: callEventAudioSounds,

View File

@@ -25,7 +25,7 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { import {
@@ -112,6 +112,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership
import { useMediaDevices } from "../MediaDevicesContext.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -251,7 +252,7 @@ export const InCallView: FC<InCallViewProps> = ({
room: livekitRoom, room: livekitRoom,
}); });
const muteAllAudio = useObservableEagerState(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
// This seems like it might be enough logic to use move it into the call view model? // This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
@@ -302,15 +303,15 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const windowMode = useObservableEagerState(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useObservableEagerState(vm.layout$); const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting); const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useObservableEagerState(vm.gridMode$); const gridMode = useBehavior(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$); const showHeader = useBehavior(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const switchCamera = useSwitchCamera(vm.localVideo$); const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking // Ideally we could detect taps by listening for click events and checking
@@ -527,16 +528,12 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight, targetHeight,
model, model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode { }: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState( const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
vm.spotlightExpanded$, const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
); const showSpeakingIndicatorsValue = useBehavior(
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded$,
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators$, vm.showSpeakingIndicators$,
); );
const showSpotlightIndicatorsValue = useObservableEagerState( const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$, vm.showSpotlightIndicators$,
); );

View File

@@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useObservableState } from "observable-hooks";
import styles from "./ReactionsOverlay.module.css"; import styles from "./ReactionsOverlay.module.css";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
const reactionsIcons = useObservableState(vm.visibleReactions$); const reactionsIcons = useBehavior(vm.visibleReactions$);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{reactionsIcons?.map(({ sender, emoji, startX }) => ( {reactionsIcons.map(({ sender, emoji, startX }) => (
<span <span
// Reactions effects are considered presentation elements. The reaction // Reactions effects are considered presentation elements. The reaction
// is also present on the sender's tile, which assistive technology can // is also present on the sender's tile, which assistive technology can

View File

@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { Button, Root as Form, Separator } from "@vector-im/compound-web"; import { Button, Root as Form, Separator } from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom } from "livekit-client";
import { useObservableEagerState } from "observable-hooks";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@@ -34,6 +33,7 @@ import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { useSubmitRageshake } from "./submit-rageshake"; import { useSubmitRageshake } from "./submit-rageshake";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { useBehavior } from "../useBehavior";
type SettingsTab = type SettingsTab =
| "audio" | "audio"
@@ -112,7 +112,7 @@ export const SettingsModal: FC<Props> = ({
// rather than the input section. // rather than the input section.
const { controlledAudioDevices } = useUrlParams(); const { controlledAudioDevices } = useUrlParams();
// If we are on iOS we will show a button to open the native audio device picker. // If we are on iOS we will show a button to open the native audio device picker.
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$); const iosDeviceMenu = useBehavior(iosDeviceMenu$);
const audioTab: Tab<SettingsTab> = { const audioTab: Tab<SettingsTab> = {
key: "audio", key: "audio",

View File

@@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
export class Setting<T> { export class Setting<T> {
public constructor( public constructor(
@@ -38,7 +39,7 @@ export class Setting<T> {
private readonly key: string; private readonly key: string;
private readonly _value$: BehaviorSubject<T>; private readonly _value$: BehaviorSubject<T>;
public readonly value$: Observable<T>; public readonly value$: Behavior<T>;
public readonly setValue = (value: T): void => { public readonly setValue = (value: T): void => {
this._value$.next(value); this._value$.next(value);
@@ -53,7 +54,7 @@ export class Setting<T> {
* React hook that returns a settings's current value and a setter. * React hook that returns a settings's current value and a setter.
*/ */
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] { export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value$), setting.setValue]; return [useBehavior(setting.value$), setting.setValue];
} }
// null = undecided // null = undecided

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 { BehaviorSubject, Observable } from "rxjs"; import { BehaviorSubject, distinctUntilChanged, Observable } from "rxjs";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "./ObservableScope";
@@ -45,7 +45,7 @@ Observable.prototype.behavior = function <T>(
): Behavior<T> { ): Behavior<T> {
const subject$ = new BehaviorSubject<T | typeof nothing>(nothing); const subject$ = new BehaviorSubject<T | typeof nothing>(nothing);
// Push values from the Observable into the BehaviorSubject // Push values from the Observable into the BehaviorSubject
this.pipe(scope.bind()).subscribe(subject$); this.pipe(scope.bind(), distinctUntilChanged()).subscribe(subject$);
if (subject$.value === nothing) if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value"); throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>; return subject$ as Behavior<T>;

View File

@@ -75,6 +75,7 @@ import {
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices"; import { MediaDevices } from "./MediaDevices";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { constant } from "./Behavior";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams })); vi.mock("../UrlParams", () => ({ getUrlParams }));
@@ -157,9 +158,10 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid": case "grid":
return combineLatest( return combineLatest(
[ [
l.spotlight?.media$ ?? of(undefined), l.spotlight?.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$), ...l.grid.map((vm) => vm.media$),
], ],
// eslint-disable-next-line rxjs/finnish -- false positive
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
type: l.type, type: l.type,
spotlight: spotlight?.map((vm) => vm.id), spotlight: spotlight?.map((vm) => vm.id),
@@ -178,7 +180,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
); );
case "spotlight-expanded": case "spotlight-expanded":
return combineLatest( return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)], [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
// eslint-disable-next-line rxjs/finnish -- false positive
(spotlight, pip) => ({ (spotlight, pip) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),

View File

@@ -339,7 +339,7 @@ class ScreenShare {
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom, liveKitRoom: LivekitRoom,
displayname$: Observable<string>, displayName$: Observable<string>,
) { ) {
this.participant$ = new BehaviorSubject(participant); this.participant$ = new BehaviorSubject(participant);
@@ -349,7 +349,7 @@ class ScreenShare {
this.participant$.asObservable(), this.participant$.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
displayname$.behavior(this.scope), displayName$.behavior(this.scope),
participant.isLocal, participant.isLocal,
); );
} }
@@ -1271,14 +1271,14 @@ export class CallViewModel extends ViewModel {
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
*/ */
public readonly earpieceMode$: Observable<boolean> = combineLatest( public readonly earpieceMode$: Behavior<boolean> = combineLatest(
[ [
this.mediaDevices.audioOutput.available$, this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$, this.mediaDevices.audioOutput.selected$,
], ],
(available, selected) => (available, selected) =>
selected !== undefined && available.get(selected.id)?.type === "earpiece", selected !== undefined && available.get(selected.id)?.type === "earpiece",
).pipe(this.scope.state()); ).behavior(this.scope);
/** /**
* Callback to toggle between the earpiece and the loudspeaker. * Callback to toggle between the earpiece and the loudspeaker.
@@ -1286,7 +1286,7 @@ export class CallViewModel extends ViewModel {
* This will be `null` in case the target does not exist in the list * This will be `null` in case the target does not exist in the list
* of available audio outputs. * of available audio outputs.
*/ */
public readonly audioOutputSwitcher$: Observable<{ public readonly audioOutputSwitcher$: Behavior<{
targetOutput: "earpiece" | "speaker"; targetOutput: "earpiece" | "speaker";
switch: () => void; switch: () => void;
} | null> = combineLatest( } | null> = combineLatest(
@@ -1298,7 +1298,7 @@ export class CallViewModel extends ViewModel {
const selectionType = selected && available.get(selected.id)?.type; const selectionType = selected && available.get(selected.id)?.type;
// If we are in any output mode other than spaeker switch to speaker. // If we are in any output mode other than spaeker switch to speaker.
const newSelectionType = const newSelectionType: "earpiece" | "speaker" =
selectionType === "speaker" ? "earpiece" : "speaker"; selectionType === "speaker" ? "earpiece" : "speaker";
const newSelection = [...available].find( const newSelection = [...available].find(
([, d]) => d.type === newSelectionType, ([, d]) => d.type === newSelectionType,
@@ -1311,7 +1311,7 @@ export class CallViewModel extends ViewModel {
switch: () => this.mediaDevices.audioOutput.select(id), switch: () => this.mediaDevices.audioOutput.select(id),
}; };
}, },
); ).behavior(this.scope);
public readonly reactions$ = this.reactionsSubject$ public readonly reactions$ = this.reactionsSubject$
.pipe( .pipe(

View File

@@ -10,7 +10,6 @@ import {
filter, filter,
map, map,
merge, merge,
of,
pairwise, pairwise,
startWith, startWith,
Subject, Subject,
@@ -34,6 +33,7 @@ import {
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { switchWhen } from "../utils/observable"; import { switchWhen } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
// This hardcoded id is used in EX ios! It can only be changed in coordination with // This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team. // the ios swift team.
@@ -74,11 +74,11 @@ export interface MediaDevice<Label, Selected> {
/** /**
* A map from available device IDs to labels. * A map from available device IDs to labels.
*/ */
available$: Observable<Map<string, Label>>; available$: Behavior<Map<string, Label>>;
/** /**
* The selected device. * The selected device.
*/ */
selected$: Observable<Selected | undefined>; selected$: Behavior<Selected | undefined>;
/** /**
* Selects a new device. * Selects a new device.
*/ */
@@ -94,36 +94,37 @@ export interface MediaDevice<Label, Selected> {
* `availableOutputDevices$.includes((d)=>d.forEarpiece)` * `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/ */
export const iosDeviceMenu$ = export const iosDeviceMenu$ =
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$; platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$( function availableRawDevices$(
kind: MediaDeviceKind, kind: MediaDeviceKind,
usingNames$: Observable<boolean>, usingNames$: Behavior<boolean>,
scope: ObservableScope, scope: ObservableScope,
): Observable<MediaDeviceInfo[]> { ): Behavior<MediaDeviceInfo[]> {
const logError = (e: Error): void => const logError = (e: Error): void =>
logger.error("Error creating MediaDeviceObserver", e); logger.error("Error creating MediaDeviceObserver", e);
const devices$ = createMediaDeviceObserver(kind, logError, false); const devices$ = createMediaDeviceObserver(kind, logError, false);
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
return usingNames$.pipe( return usingNames$
switchMap((withNames) => .pipe(
withNames switchMap((withNames) =>
? // It might be that there is already a media stream running somewhere, withNames
// and so we can do without requesting a second one. Only switch to the ? // It might be that there is already a media stream running somewhere,
// device observer that explicitly requests the names if we see that // and so we can do without requesting a second one. Only switch to the
// names are in fact missing from the initial device enumeration. // device observer that explicitly requests the names if we see that
devices$.pipe( // names are in fact missing from the initial device enumeration.
switchWhen( devices$.pipe(
(devices, i) => i === 0 && devices.every((d) => !d.label), switchWhen(
devicesWithNames$, (devices, i) => i === 0 && devices.every((d) => !d.label),
), devicesWithNames$,
) ),
: devices$, )
), : devices$,
startWith([]), ),
scope.state(), startWith([]),
); )
.behavior(scope);
} }
function buildDeviceMap( function buildDeviceMap(
@@ -161,42 +162,44 @@ function selectDevice$<Label>(
} }
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> { class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly availableRaw$: Observable<MediaDeviceInfo[]> = private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
availableRawDevices$("audioinput", this.usingNames$, this.scope); availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.availableRaw$.pipe( public readonly available$ = this.availableRaw$
map(buildDeviceMap), .pipe(map(buildDeviceMap))
this.scope.state(), .behavior(this.scope);
);
public readonly selected$ = selectDevice$( public readonly selected$ = selectDevice$(
this.available$, this.available$,
audioInputSetting.value$, audioInputSetting.value$,
).pipe( )
map((id) => .pipe(
id === undefined map((id) =>
? undefined id === undefined
: { ? undefined
id, : {
// We can identify when the hardware device has changed by watching for id,
// changes in the group ID // We can identify when the hardware device has changed by watching for
hardwareDeviceChange$: this.availableRaw$.pipe( // changes in the group ID
map((devices) => devices.find((d) => d.deviceId === id)?.groupId), hardwareDeviceChange$: this.availableRaw$.pipe(
pairwise(), map(
filter(([before, after]) => before !== after), (devices) => devices.find((d) => d.deviceId === id)?.groupId,
map(() => undefined), ),
), pairwise(),
}, filter(([before, after]) => before !== after),
), map(() => undefined),
this.scope.state(), ),
); },
),
)
.behavior(this.scope);
public select(id: string): void { public select(id: string): void {
audioInputSetting.setValue(id); audioInputSetting.setValue(id);
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
@@ -212,47 +215,48 @@ class AudioOutput
"audiooutput", "audiooutput",
this.usingNames$, this.usingNames$,
this.scope, this.scope,
).pipe( )
map((availableRaw) => { .pipe(
const available: Map<string, AudioOutputDeviceLabel> = map((availableRaw) => {
buildDeviceMap(availableRaw); const available: Map<string, AudioOutputDeviceLabel> =
// Create a virtual default audio output for browsers that don't have one. buildDeviceMap(availableRaw);
// Its device ID must be the empty string because that's what setSinkId // Create a virtual default audio output for browsers that don't have one.
// recognizes. // Its device ID must be the empty string because that's what setSinkId
if (available.size && !available.has("") && !available.has("default")) // recognizes.
available.set("", { if (available.size && !available.has("") && !available.has("default"))
type: "default", available.set("", {
name: availableRaw[0]?.label || null, type: "default",
}); name: availableRaw[0]?.label || null,
// Note: creating virtual default input devices would be another problem });
// entirely, because requesting a media stream from deviceId "" won't // Note: creating virtual default input devices would be another problem
// automatically track the default device. // entirely, because requesting a media stream from deviceId "" won't
return available; // automatically track the default device.
}), return available;
this.scope.state(), }),
); )
.behavior(this.scope);
public readonly selected$ = selectDevice$( public readonly selected$ = selectDevice$(
this.available$, this.available$,
audioOutputSetting.value$, audioOutputSetting.value$,
).pipe( )
map((id) => .pipe(
id === undefined map((id) =>
? undefined id === undefined
: { ? undefined
id, : {
virtualEarpiece: false, id,
}, virtualEarpiece: false,
), },
this.scope.state(), ),
); )
.behavior(this.scope);
public select(id: string): void { public select(id: string): void {
audioOutputSetting.setValue(id); audioOutputSetting.setValue(id);
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
@@ -287,7 +291,7 @@ class ControlledAudioOutput
return available; return available;
}, },
).pipe(this.scope.state()); ).behavior(this.scope);
private readonly deviceSelection$ = new Subject<string>(); private readonly deviceSelection$ = new Subject<string>();
@@ -309,7 +313,7 @@ class ControlledAudioOutput
? undefined ? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
}, },
).pipe(this.scope.state()); ).behavior(this.scope);
public constructor(private readonly scope: ObservableScope) { public constructor(private readonly scope: ObservableScope) {
this.selected$.subscribe((device) => { this.selected$.subscribe((device) => {
@@ -335,22 +339,21 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
"videoinput", "videoinput",
this.usingNames$, this.usingNames$,
this.scope, this.scope,
).pipe(map(buildDeviceMap)); )
.pipe(map(buildDeviceMap))
.behavior(this.scope);
public readonly selected$ = selectDevice$( public readonly selected$ = selectDevice$(
this.available$, this.available$,
videoInputSetting.value$, videoInputSetting.value$,
).pipe( )
map((id) => (id === undefined ? undefined : { id })), .pipe(map((id) => (id === undefined ? undefined : { id })))
this.scope.state(), .behavior(this.scope);
);
public select(id: string): void { public select(id: string): void {
videoInputSetting.setValue(id); videoInputSetting.setValue(id);
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
// This also has the purpose of subscribing to the available devices // This also has the purpose of subscribing to the available devices
@@ -378,12 +381,12 @@ export class MediaDevices {
// you to do to receive device names in lieu of a more explicit permissions // you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted // API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page. // the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.deviceNamesRequest$.pipe( private readonly usingNames$ = this.deviceNamesRequest$
map(() => true), .pipe(
startWith(false), map(() => true),
this.scope.state(), startWith(false),
); )
.behavior(this.scope);
public readonly audioInput: MediaDevice< public readonly audioInput: MediaDevice<
DeviceLabel, DeviceLabel,
SelectedAudioInputDevice SelectedAudioInputDevice

View File

@@ -258,7 +258,7 @@ abstract class BaseMediaViewModel extends ViewModel {
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly displayname$: Observable<string>, public readonly displayName$: Behavior<string>,
) { ) {
super(); super();
const audio$ = observeTrackReference$(participant$, audioSource).behavior( const audio$ = observeTrackReference$(participant$, audioSource).behavior(
@@ -390,7 +390,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>, participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayName$: Behavior<string>,
public readonly handRaised$: Behavior<Date | null>, public readonly handRaised$: Behavior<Date | null>,
public readonly reaction$: Behavior<ReactionOption | null>, public readonly reaction$: Behavior<ReactionOption | null>,
) { ) {
@@ -402,7 +402,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom, livekitRoom,
displayname$, displayName$,
); );
const media$ = participant$ const media$ = participant$
@@ -469,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant$: Observable<LocalParticipant | undefined>, participant$: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayName$: Behavior<string>,
handRaised$: Behavior<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
@@ -479,7 +479,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayName$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -562,7 +562,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
participant$: Observable<RemoteParticipant | undefined>, participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Behavior<string>,
handRaised$: Behavior<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
@@ -627,7 +627,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
participant$: Observable<LocalParticipant | RemoteParticipant>, participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Behavior<string>,
public readonly local: boolean, public readonly local: boolean,
) { ) {
super( super(

View File

@@ -9,6 +9,8 @@ import { combineLatest, startWith } from "rxjs";
import { setAudioEnabled$ } from "../controls"; import { setAudioEnabled$ } from "../controls";
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings"; import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
import { globalScope } from "./ObservableScope";
import "../state/Behavior"; // Patches in the Observable.behavior method
/** /**
* This can transition into sth more complete: `GroupCallViewModel.ts` * This can transition into sth more complete: `GroupCallViewModel.ts`
@@ -16,4 +18,4 @@ import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
export const muteAllAudio$ = combineLatest( export const muteAllAudio$ = combineLatest(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
(outputEnabled, settingsMute) => !outputEnabled || settingsMute, (outputEnabled, settingsMute) => !outputEnabled || settingsMute,
); ).behavior(globalScope);

View File

@@ -33,3 +33,8 @@ export class ObservableScope {
this.ended$.complete(); this.ended$.complete();
} }
} }
/**
* The global scope, a scope which never ends.
*/
export const globalScope = new ObservableScope();

View File

@@ -5,10 +5,9 @@ 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 Observable } from "rxjs";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
import { type Behavior } from "./Behavior";
let nextId = 0; let nextId = 0;
function createId(): string { function createId(): string {
@@ -18,15 +17,15 @@ function createId(): string {
export class GridTileViewModel extends ViewModel { export class GridTileViewModel extends ViewModel {
public readonly id = createId(); public readonly id = createId();
public constructor(public readonly media$: Observable<UserMediaViewModel>) { public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
super(); super();
} }
} }
export class SpotlightTileViewModel extends ViewModel { export class SpotlightTileViewModel extends ViewModel {
public constructor( public constructor(
public readonly media$: Observable<MediaViewModel[]>, public readonly media$: Behavior<MediaViewModel[]>,
public readonly maximised$: Observable<boolean>, public readonly maximised$: Behavior<boolean>,
) { ) {
super(); super();
} }

View File

@@ -9,7 +9,6 @@ import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest"; import { test, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import { of } from "rxjs";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
@@ -17,6 +16,7 @@ import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -53,13 +53,13 @@ test("GridTile is accessible", async () => {
memberships: [], memberships: [],
} as unknown as MatrixRTCSession; } as unknown as MatrixRTCSession;
const cVm = { const cVm = {
reactions$: of({}), reactions$: constant({}),
handsRaised$: of({}), handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel; } as Partial<CallViewModel> as CallViewModel;
const { container } = render( const { container } = render(
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}> <ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
<GridTile <GridTile
vm={new GridTileViewModel(of(vm))} vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}} onOpenProfile={() => {}}
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}

View File

@@ -35,7 +35,7 @@ import {
ToggleMenuItem, ToggleMenuItem,
Menu, Menu,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useObservableEagerState, useObservableState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css"; import styles from "./GridTile.module.css";
import { import {
@@ -49,6 +49,7 @@ import { useLatest } from "../useLatest";
import { type GridTileViewModel } from "../state/TileViewModel"; import { type GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender"; import { useReactionsSender } from "../reactions/useReactionsSender";
import { useBehavior } from "../useBehavior";
interface TileProps { interface TileProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -81,19 +82,19 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}) => { }) => {
const { toggleRaisedHand } = useReactionsSender(); const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation(); const { t } = useTranslation();
const video = useObservableEagerState(vm.video$); const video = useBehavior(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); const encryptionStatus = useBehavior(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState< const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$); >(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState< const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$); >(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$); const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$); const speaking = useBehavior(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$); const cropVideo = useBehavior(vm.cropVideo$);
const onSelectFitContain = useCallback( const onSelectFitContain = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -101,8 +102,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}, },
[vm], [vm],
); );
const handRaised = useObservableState(vm.handRaised$); const handRaised = useBehavior(vm.handRaised$);
const reaction = useObservableState(vm.reaction$); const reaction = useBehavior(vm.reaction$);
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
@@ -205,8 +206,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$); const mirror = useBehavior(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$); const alwaysShow = useBehavior(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow); const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback( const onSelectAlwaysShow = useCallback(
(e: Event) => { (e: Event) => {
@@ -256,8 +257,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$); const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$); const localVolume = useBehavior(vm.localVolume$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -328,8 +329,8 @@ export const GridTile: FC<GridTileProps> = ({
}) => { }) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$); const media = useBehavior(vm.media$);
const displayName = useObservableEagerState(media.displayname$); const displayName = useBehavior(media.displayName$);
if (media instanceof LocalUserMediaViewModel) { if (media instanceof LocalUserMediaViewModel) {
return ( return (

View File

@@ -9,7 +9,6 @@ import { test, expect, vi } from "vitest";
import { isInaccessible, render, screen } from "@testing-library/react"; import { isInaccessible, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { of } from "rxjs";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { import {
@@ -18,6 +17,7 @@ import {
withRemoteMedia, withRemoteMedia,
} from "../utils/test"; } from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -44,7 +44,12 @@ test("SpotlightTile is accessible", async () => {
const toggleExpanded = vi.fn(); const toggleExpanded = vi.fn();
const { container } = render( const { container } = render(
<SpotlightTile <SpotlightTile
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))} vm={
new SpotlightTileViewModel(
constant([vm1, vm2]),
constant(false),
)
}
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}
expanded={false} expanded={false}

View File

@@ -23,7 +23,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs"; import { type Observable, map } from "rxjs";
import { useObservableEagerState, useObservableRef } from "observable-hooks"; import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
@@ -43,6 +43,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { type SpotlightTileViewModel } from "../state/TileViewModel";
import { useBehavior } from "../useBehavior";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -73,7 +74,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
vm, vm,
...props ...props
}) => { }) => {
const mirror = useObservableEagerState(vm.mirror$); const mirror = useBehavior(vm.mirror$);
return <MediaView mirror={mirror} {...props} />; return <MediaView mirror={mirror} {...props} />;
}; };
@@ -87,8 +88,8 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm, vm,
...props ...props
}) => { }) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$); const cropVideo = useBehavior(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps & const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = { RefAttributes<HTMLDivElement> = {
@@ -130,10 +131,10 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}) => { }) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const displayName = useObservableEagerState(vm.displayname$); const displayName = useBehavior(vm.displayName$);
const video = useObservableEagerState(vm.video$); const video = useBehavior(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); const encryptionStatus = useBehavior(vm.encryptionStatus$);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
@@ -200,8 +201,8 @@ export const SpotlightTile: FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null); const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised$); const maximised = useBehavior(vm.maximised$);
const media = useObservableEagerState(vm.media$); const media = useBehavior(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id); const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const latestMedia = useLatest(media); const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);

View File

@@ -10,12 +10,12 @@ import { type FC } from "react";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event"; import userEvent, { type UserEvent } from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { of } from "rxjs";
import { MediaDevicesContext } from "./MediaDevicesContext"; import { MediaDevicesContext } from "./MediaDevicesContext";
import { useAudioContext } from "./useAudioContext"; import { useAudioContext } from "./useAudioContext";
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings"; import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
import { mockMediaDevices } from "./utils/test"; import { mockMediaDevices } from "./utils/test";
import { constant } from "./state/Behavior";
const staticSounds = Promise.resolve({ const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0), aSound: new ArrayBuffer(0),
@@ -128,8 +128,8 @@ test("will use the correct device", () => {
<MediaDevicesContext <MediaDevicesContext
value={mockMediaDevices({ value={mockMediaDevices({
audioOutput: { audioOutput: {
available$: of(new Map<never, never>()), available$: constant(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: false }), selected$: constant({ id: "chosen-device", virtualEarpiece: false }),
select: () => {}, select: () => {},
}, },
})} })}
@@ -161,8 +161,8 @@ test("will use the pan if earpiece is selected", async () => {
<MediaDevicesContext <MediaDevicesContext
value={mockMediaDevices({ value={mockMediaDevices({
audioOutput: { audioOutput: {
available$: of(new Map<never, never>()), available$: constant(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: true }), selected$: constant({ id: "chosen-device", virtualEarpiece: true }),
select: () => {}, select: () => {},
}, },
})} })}

25
src/useBehavior.ts Normal file
View File

@@ -0,0 +1,25 @@
/*
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 { useCallback, useSyncExternalStore } from "react";
import { type Behavior } from "./state/Behavior";
/**
* React hook which reactively reads the value of a behavior.
*/
export function useBehavior<T>(behavior: Behavior<T>): T {
const subscribe = useCallback(
(onChange: () => void) => {
const s = behavior.subscribe(onChange);
return (): void => s.unsubscribe();
},
[behavior],
);
const getValue = useCallback(() => behavior.value, [behavior]);
return useSyncExternalStore(subscribe, getValue);
}

View File

@@ -217,7 +217,7 @@ export async function withLocalMedia(
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({ localParticipant }), mockLivekitRoom({ localParticipant }),
of(roomMember.rawDisplayName ?? "nodisplayname"), constant(roomMember.rawDisplayName ?? "nodisplayname"),
constant(null), constant(null),
constant(null), constant(null),
); );
@@ -256,7 +256,7 @@ export async function withRemoteMedia(
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
of(roomMember.rawDisplayName ?? "nodisplayname"), constant(roomMember.rawDisplayName ?? "nodisplayname"),
constant(null), constant(null),
constant(null), constant(null),
); );