Merge pull request #3346 from element-hq/robin/behaviors

Create a type-level distinction between raw Observables and Behaviors
This commit is contained in:
Robin
2025-07-17 19:14:35 -04:00
committed by GitHub
30 changed files with 1258 additions and 1079 deletions

View File

@@ -44,7 +44,7 @@ module.exports = {
], ],
// To encourage good usage of RxJS: // To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error", "rxjs/no-exposed-subjects": "error",
"rxjs/finnish": "error", "rxjs/finnish": ["error", { names: { "^this$": false } }],
}, },
settings: { settings: {
react: { react: {

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

@@ -157,10 +157,13 @@ export function useLivekit(
useObservableEagerState( useObservableEagerState(
useObservable( useObservable(
(room$) => (room$) =>
room$.pipe(
switchMap(([room]) =>
observeTrackReference$( observeTrackReference$(
room$.pipe(map(([room]) => room.localParticipant)), room.localParticipant,
Track.Source.Camera, Track.Source.Camera,
).pipe( ),
),
map((trackRef) => { map((trackRef) => {
const track = trackRef?.publication?.track; const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;

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

@@ -16,7 +16,6 @@ import {
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -43,6 +42,7 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
vi.mock("../soundUtils"); vi.mock("../soundUtils");
vi.mock("../useAudioContext"); vi.mock("../useAudioContext");
@@ -141,7 +141,7 @@ function createGroupCallView(
room, room,
localRtcMember, localRtcMember,
[], [],
).withMemberships(of([])); ).withMemberships(constant([]));
rtcSession.joined = joined; rtcSession.joined = joined;
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },

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

26
src/state/Behavior.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
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 { BehaviorSubject } from "rxjs";
/**
* A stateful, read-only reactive value. As an Observable, it is "hot" and
* always replays the current value upon subscription.
*
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
* provide a way to imperatively set new values. For more info on the
* distinction between Behaviors and Observables, see
* https://monoid.dk/post/behaviors-and-streams-why-both/.
*/
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
/**
* Creates a Behavior which never changes in value.
*/
export function constant<T>(value: T): Behavior<T> {
return new BehaviorSubject(value);
}

View File

@@ -12,9 +12,9 @@ import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
map, map,
NEVER,
type Observable, type Observable,
of, of,
skip,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -75,10 +75,18 @@ 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 { type Behavior, constant } from "./Behavior";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams })); vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()),
// Disable interval Observables for the following tests since the test
// scheduler will loop on them forever and never call the test 'done'
interval: (): Observable<number> => NEVER,
}));
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD"); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
@@ -157,9 +165,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 +187,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),
@@ -212,8 +222,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
} }
function withCallViewModel( function withCallViewModel(
remoteParticipants$: Observable<RemoteParticipant[]>, remoteParticipants$: Behavior<RemoteParticipant[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>, rtcMembers$: Behavior<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>, connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
@@ -291,7 +301,7 @@ function withCallViewModel(
} }
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3
const participantInputMarbles = "a-ba"; const participantInputMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3 // Start switching focus on frame 1 and reconnect on frame 3
@@ -300,12 +310,12 @@ test("participants are retained during a focus switch", () => {
const expectedLayoutMarbles = " a"; const expectedLayoutMarbles = " a";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
hot(connectionInputMarbles, { behavior(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
}), }),
@@ -328,7 +338,7 @@ test("participants are retained during a focus switch", () => {
}); });
test("screen sharing activates spotlight layout", () => { test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens, // Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit // then return to no screen shares, then have just Alice share for a bit
const participantInputMarbles = " abcda-ba"; const participantInputMarbles = " abcda-ba";
@@ -341,13 +351,13 @@ test("screen sharing activates spotlight layout", () => {
const expectedLayoutMarbles = " abcdaefeg"; const expectedLayoutMarbles = " abcdaefeg";
const expectedShowSpeakingMarbles = "y----nyny"; const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant], b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen], d: [aliceParticipant, bobSharingScreen],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -413,7 +423,7 @@ test("screen sharing activates spotlight layout", () => {
}); });
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
const visibilityInputMarbles = "a"; const visibilityInputMarbles = "a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
@@ -426,13 +436,22 @@ test("participants stay in the same order unless to appear/disappear", () => {
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -472,7 +491,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
}); });
test("participants adjust order when space becomes constrained", () => { test("participants adjust order when space becomes constrained", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with all tiles on screen then shrink to 3 // Start with all tiles on screen then shrink to 3
const visibilityInputMarbles = "a-b"; const visibilityInputMarbles = "a-b";
// Bob and Dave speak // Bob and Dave speak
@@ -484,12 +503,18 @@ test("participants adjust order when space becomes constrained", () => {
const expectedLayoutMarbles = " a-b"; const expectedLayoutMarbles = " a-b";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], [
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -523,7 +548,7 @@ test("participants adjust order when space becomes constrained", () => {
}); });
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test
const modeInputMarbles = " s"; const modeInputMarbles = " s";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
@@ -537,13 +562,22 @@ test("spotlight speakers swap places", () => {
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -587,8 +621,8 @@ test("layout enters picture-in-picture mode when requested", () => {
const expectedLayoutMarbles = " aba"; const expectedLayoutMarbles = " aba";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -629,8 +663,8 @@ test("spotlight remembers whether it's expanded", () => {
const expectedLayoutMarbles = "abcbada"; const expectedLayoutMarbles = "abcbada";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -678,7 +712,7 @@ test("spotlight remembers whether it's expanded", () => {
}); });
test("participants must have a MatrixRTCSession to be visible", () => { test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships // iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership // Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec"; const scenarioInputMarbles = " abcdec";
@@ -686,14 +720,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
const expectedLayoutMarbles = "a-bc-b"; const expectedLayoutMarbles = "a-bc-b";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobParticipant], b: [bobParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant], d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen], e: [aliceParticipant, daveParticipant, bobSharingScreen],
}), }),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [], b: [],
c: [aliceRtcMember], c: [aliceRtcMember],
@@ -734,17 +768,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
try { try {
// enable the setting: // enable the setting:
showNonMemberTiles.setValue(true); showNonMemberTiles.setValue(true);
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = " abc"; const scenarioInputMarbles = " abc";
const expectedLayoutMarbles = "abc"; const expectedLayoutMarbles = "abc";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceParticipant], b: [aliceParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
}), }),
of([]), // No one joins the MatrixRTC session constant([]), // No one joins the MatrixRTC session
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -779,15 +813,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
}); });
it("should show at least one tile per MatrixRTCSession", () => { it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships // iterate through some combinations of MatrixRTC memberships
const scenarioInputMarbles = " abcd"; const scenarioInputMarbles = " abcd";
// There should always be one tile for each MatrixRTCSession // There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, daveRtcMember], c: [aliceRtcMember, daveRtcMember],
@@ -829,13 +863,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
}); });
test("should disambiguate users with the same displayname", () => { test("should disambiguate users with the same displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "abcde"; const scenarioInputMarbles = "abcde";
const expectedLayoutMarbles = "abcde"; const expectedLayoutMarbles = "abcde";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, aliceDoppelgangerRtcMember], c: [aliceRtcMember, aliceDoppelgangerRtcMember],
@@ -846,10 +880,7 @@ test("should disambiguate users with the same displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
expectedLayoutMarbles,
{
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
a: new Map([[carolId, carol.userId]]), a: new Map([[carolId, carol.userId]]),
b: new Map([ b: new Map([
@@ -875,21 +906,20 @@ test("should disambiguate users with the same displayname", () => {
[aliceDoppelgangerId, "Alice"], [aliceDoppelgangerId, "Alice"],
[bobId, bob.rawDisplayName], [bobId, bob.rawDisplayName],
]), ]),
}, });
);
}, },
); );
}); });
}); });
test("should disambiguate users with invisible characters", () => { test("should disambiguate users with invisible characters", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobRtcMember, bobZeroWidthSpaceRtcMember], b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
}), }),
@@ -897,10 +927,7 @@ test("should disambiguate users with invisible characters", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
expectedLayoutMarbles,
{
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
a: new Map([[carolId, carol.userId]]), a: new Map([[carolId, carol.userId]]),
// Both Bobs join, and should handle zero width hacks. // Both Bobs join, and should handle zero width hacks.
@@ -912,21 +939,20 @@ test("should disambiguate users with invisible characters", () => {
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`, `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
], ],
]), ]),
}, });
);
}, },
); );
}); });
}); });
test("should strip RTL characters from displayname", () => { test("should strip RTL characters from displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [daveRtcMember, daveRTLRtcMember], b: [daveRtcMember, daveRTLRtcMember],
}), }),
@@ -934,10 +960,7 @@ test("should strip RTL characters from displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
expectedLayoutMarbles,
{
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
a: new Map([[carolId, carol.userId]]), a: new Map([[carolId, carol.userId]]),
// Both Dave's join. Since after stripping // Both Dave's join. Since after stripping
@@ -948,8 +971,7 @@ test("should strip RTL characters from displayname", () => {
// This one is, since it's using RTL. // This one is, since it's using RTL.
[daveRTLId, `evaD (${daveRTL.userId})`], [daveRTLId, `evaD (${daveRTL.userId})`],
]), ]),
}, });
);
}, },
); );
}); });
@@ -961,8 +983,8 @@ it("should rank raised hands above video feeds and below speakers and presenters
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -1036,8 +1058,8 @@ test("audio output changes when toggling earpiece mode", () => {
const expectedTargetStateMarbles = " sese"; const expectedTargetStateMarbles = " sese";
withCallViewModel( withCallViewModel(
of([]), constant([]),
of([]), constant([]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
devices, devices,

View File

@@ -94,6 +94,7 @@ import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
// list again // list again
@@ -250,8 +251,8 @@ class UserMedia {
LocalParticipant | RemoteParticipant | undefined LocalParticipant | RemoteParticipant | undefined
>; >;
public readonly speaker$: Observable<boolean>; public readonly speaker$: Behavior<boolean>;
public readonly presenter$: Observable<boolean>; public readonly presenter$: Behavior<boolean>;
public constructor( public constructor(
public readonly id: string, public readonly id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
@@ -268,12 +269,12 @@ class UserMedia {
this.vm = new LocalUserMediaViewModel( this.vm = new LocalUserMediaViewModel(
this.id, this.id,
member, member,
this.participant$.asObservable() as Observable<LocalParticipant>, this.participant$ as Behavior<LocalParticipant>,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, this.scope.behavior(displayname$),
handRaised$, this.scope.behavior(handRaised$),
reaction$, this.scope.behavior(reaction$),
); );
} else { } else {
this.vm = new RemoteUserMediaViewModel( this.vm = new RemoteUserMediaViewModel(
@@ -284,15 +285,16 @@ class UserMedia {
>, >,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, this.scope.behavior(displayname$),
handRaised$, this.scope.behavior(handRaised$),
reaction$, this.scope.behavior(reaction$),
); );
} }
this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); this.speaker$ = this.scope.behavior(observeSpeaker$(this.vm.speaking$));
this.presenter$ = this.participant$.pipe( this.presenter$ = this.scope.behavior(
this.participant$.pipe(
switchMap( switchMap(
(p) => (p) =>
(p && (p &&
@@ -305,7 +307,7 @@ class UserMedia {
).pipe(map((p) => p.isScreenShareEnabled))) ?? ).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false), of(false),
), ),
this.scope.state(), ),
); );
} }
@@ -325,6 +327,7 @@ class UserMedia {
} }
class ScreenShare { class ScreenShare {
private readonly scope = new ObservableScope();
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private readonly participant$: BehaviorSubject< private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant LocalParticipant | RemoteParticipant
@@ -336,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);
@@ -346,12 +349,13 @@ class ScreenShare {
this.participant$.asObservable(), this.participant$.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
displayname$, this.scope.behavior(displayName$),
participant.isLocal, participant.isLocal,
); );
} }
public destroy(): void { public destroy(): void {
this.scope.end();
this.vm.destroy(); this.vm.destroy();
} }
} }
@@ -382,28 +386,32 @@ function getRoomMemberFromRtcMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here // TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
public readonly localVideo$: Observable<LocalVideoTrack | null> = public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
observeTrackReference$( observeTrackReference$(
of(this.livekitRoom.localParticipant), this.livekitRoom.localParticipant,
Track.Source.Camera, Track.Source.Camera,
).pipe( ).pipe(
map((trackRef) => { map((trackRef) => {
const track = trackRef?.publication?.track; const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
),
); );
/** /**
* The raw list of RemoteParticipants as reported by LiveKit * The raw list of RemoteParticipants as reported by LiveKit
*/ */
private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> = private readonly rawRemoteParticipants$ = this.scope.behavior<
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); RemoteParticipant[]
>(connectedParticipantsObserver(this.livekitRoom), []);
/** /**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left * they've left
*/ */
private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> = private readonly remoteParticipantHolds$ = this.scope.behavior<
RemoteParticipant[][]
>(
this.connectionState$.pipe( this.connectionState$.pipe(
withLatestFrom(this.rawRemoteParticipants$), withLatestFrom(this.rawRemoteParticipants$),
mergeMap(([s, ps]) => { mergeMap(([s, ps]) => {
@@ -435,12 +443,15 @@ export class CallViewModel extends ViewModel {
? [instruction.hold, ...holds] ? [instruction.hold, ...holds]
: holds.filter((h) => h !== instruction.unhold), : holds.filter((h) => h !== instruction.unhold),
), ),
),
); );
/** /**
* The RemoteParticipants including those that are being "held" on the screen * The RemoteParticipants including those that are being "held" on the screen
*/ */
private readonly remoteParticipants$: Observable<RemoteParticipant[]> = private readonly remoteParticipants$ = this.scope.behavior<
RemoteParticipant[]
>(
combineLatest( combineLatest(
[this.rawRemoteParticipants$, this.remoteParticipantHolds$], [this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => { (raw, holds) => {
@@ -459,6 +470,7 @@ export class CallViewModel extends ViewModel {
return result; return result;
}, },
),
); );
/** /**
@@ -466,9 +478,13 @@ export class CallViewModel extends ViewModel {
* any displaynames that clashes with another member. Only members * any displaynames that clashes with another member. Only members
* joined to the call are considered here. * joined to the call are considered here.
*/ */
public readonly memberDisplaynames$ = merge( public readonly memberDisplaynames$ = this.scope.behavior(
merge(
// Handle call membership changes. // Handle call membership changes.
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
),
// Handle room membership changes (and displayname updates) // Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe( ).pipe(
@@ -482,7 +498,10 @@ export class CallViewModel extends ViewModel {
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
const { member } = getRoomMemberFromRtcMember(rtcMember, room); const { member } = getRoomMemberFromRtcMember(rtcMember, room);
if (!member) { if (!member) {
logger.error("Could not find member for media id:", matrixIdentifier); logger.error(
"Could not find member for media id:",
matrixIdentifier,
);
continue; continue;
} }
const disambiguate = shouldDisambiguate(member, memberships, room); const disambiguate = shouldDisambiguate(member, memberships, room);
@@ -494,15 +513,31 @@ export class CallViewModel extends ViewModel {
return displaynameMap; return displaynameMap;
}), }),
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we share() the result so that we // than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieve through the state() operator: // don't do this work more times than we need to. This is achieved by converting to a behavior:
this.scope.state(), ),
);
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
public readonly reactions$ = this.scope.behavior(
this.reactionsSubject$.pipe(
map((v) =>
Object.fromEntries(
Object.entries(v).map(([a, { reactionOption }]) => [
a,
reactionOption,
]),
),
),
),
); );
/** /**
* List of MediaItems that we want to display * List of MediaItems that we want to display
*/ */
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
combineLatest([
this.remoteParticipants$, this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value$, duplicateTiles.value$,
@@ -644,7 +679,9 @@ export class CallViewModel extends ViewModel {
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
this.memberDisplaynames$.pipe( this.memberDisplaynames$.pipe(
map((m) => m.get(participant.identity) ?? "[👻]"), map(
(m) => m.get(participant.identity) ?? "[👻]",
),
), ),
of(null), of(null),
of(null), of(null),
@@ -665,7 +702,8 @@ export class CallViewModel extends ViewModel {
...newItems.entries(), ...newItems.entries(),
]); ]);
for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); for (const [id, t] of prevItems)
if (!combinedNew.has(id)) t.destroy();
return combinedNew; return combinedNew;
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
@@ -674,16 +712,18 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
this.scope.state(), ),
); );
/** /**
* List of MediaItems that we want to display, that are of type UserMedia * List of MediaItems that we want to display, that are of type UserMedia
*/ */
private readonly userMedia$: Observable<UserMedia[]> = this.mediaItems$.pipe( private readonly userMedia$ = this.scope.behavior<UserMedia[]>(
this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
), ),
),
); );
public readonly memberChanges$ = this.userMedia$ public readonly memberChanges$ = this.userMedia$
@@ -702,15 +742,16 @@ export class CallViewModel extends ViewModel {
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */
private readonly screenShares$: Observable<ScreenShare[]> = private readonly screenShares$ = this.scope.behavior<ScreenShare[]>(
this.mediaItems$.pipe( this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
), ),
this.scope.state(), ),
); );
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> = private readonly spotlightSpeaker$ =
this.scope.behavior<UserMediaViewModel | null>(
this.userMedia$.pipe( this.userMedia$.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
@@ -743,10 +784,10 @@ export class CallViewModel extends ViewModel {
null, null,
), ),
map((speaker) => speaker?.vm ?? null), map((speaker) => speaker?.vm ?? null),
this.scope.state(), ),
); );
private readonly grid$: Observable<UserMediaViewModel[]> = private readonly grid$ = this.scope.behavior<UserMediaViewModel[]>(
this.userMedia$.pipe( this.userMedia$.pipe(
switchMap((mediaItems) => { switchMap((mediaItems) => {
const bins = mediaItems.map((m) => const bins = mediaItems.map((m) =>
@@ -784,10 +825,10 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
this.scope.state(), ),
); );
private readonly spotlight$: Observable<MediaViewModel[]> = private readonly spotlight$ = this.scope.behavior<MediaViewModel[]>(
this.screenShares$.pipe( this.screenShares$.pipe(
switchMap((screenShares) => { switchMap((screenShares) => {
if (screenShares.length > 0) { if (screenShares.length > 0) {
@@ -799,10 +840,11 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
this.scope.state(), ),
); );
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([ private readonly pip$ = this.scope.behavior<UserMediaViewModel | null>(
combineLatest([
this.screenShares$, this.screenShares$,
this.spotlightSpeaker$, this.spotlightSpeaker$,
this.mediaItems$, this.mediaItems$,
@@ -836,7 +878,7 @@ export class CallViewModel extends ViewModel {
}), }),
); );
}), }),
this.scope.state(), ),
); );
private readonly hasRemoteScreenShares$: Observable<boolean> = private readonly hasRemoteScreenShares$: Observable<boolean> =
@@ -847,14 +889,10 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.pipe( private readonly pipEnabled$ = this.scope.behavior(setPipEnabled$, false);
startWith(false),
);
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent( private readonly naturalWindowMode$ = this.scope.behavior<WindowMode>(
window, fromEvent(window, "resize").pipe(
"resize",
).pipe(
startWith(null), startWith(null),
map(() => { map(() => {
const height = window.innerHeight; const height = window.innerHeight;
@@ -867,36 +905,44 @@ export class CallViewModel extends ViewModel {
if (width <= 600) return "narrow"; if (width <= 600) return "narrow";
return "normal"; return "normal";
}), }),
this.scope.state(), ),
); );
/** /**
* The general shape of the window. * The general shape of the window.
*/ */
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe( public readonly windowMode$ = this.scope.behavior<WindowMode>(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)), this.pipEnabled$.pipe(
switchMap((pip) =>
pip ? of<WindowMode>("pip") : this.naturalWindowMode$,
),
),
); );
private readonly spotlightExpandedToggle$ = new Subject<void>(); private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded$: Observable<boolean> = public readonly spotlightExpanded$ = this.scope.behavior<boolean>(
this.spotlightExpandedToggle$.pipe( this.spotlightExpandedToggle$.pipe(
accumulate(false, (expanded) => !expanded), accumulate(false, (expanded) => !expanded),
this.scope.state(), ),
); );
private readonly gridModeUserSelection$ = new Subject<GridMode>(); private readonly gridModeUserSelection$ = new Subject<GridMode>();
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode$: Observable<GridMode> = public readonly gridMode$ =
// If the user hasn't selected spotlight and somebody starts screen sharing, // If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends // automatically switch to spotlight mode and reset when screen sharing ends
this.scope.behavior<GridMode>(
this.gridModeUserSelection$.pipe( this.gridModeUserSelection$.pipe(
startWith(null), startWith(null),
switchMap((userSelection) => switchMap((userSelection) =>
(userSelection === "spotlight" (userSelection === "spotlight"
? EMPTY ? EMPTY
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( : combineLatest([
this.hasRemoteScreenShares$,
this.windowMode$,
]).pipe(
skip(userSelection === null ? 0 : 1), skip(userSelection === null ? 0 : 1),
map( map(
([hasScreenShares, windowMode]): GridMode => ([hasScreenShares, windowMode]): GridMode =>
@@ -907,7 +953,7 @@ export class CallViewModel extends ViewModel {
) )
).pipe(startWith(userSelection ?? "grid")), ).pipe(startWith(userSelection ?? "grid")),
), ),
this.scope.state(), ),
); );
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
@@ -969,7 +1015,7 @@ export class CallViewModel extends ViewModel {
/** /**
* The media to be used to produce a layout. * The media to be used to produce a layout.
*/ */
private readonly layoutMedia$: Observable<LayoutMedia> = private readonly layoutMedia$ = this.scope.behavior<LayoutMedia>(
this.windowMode$.pipe( this.windowMode$.pipe(
switchMap((windowMode) => { switchMap((windowMode) => {
switch (windowMode) { switch (windowMode) {
@@ -1032,7 +1078,7 @@ export class CallViewModel extends ViewModel {
return this.pipLayoutMedia$; return this.pipLayoutMedia$;
} }
}), }),
this.scope.state(), ),
); );
// There is a cyclical dependency here: the layout algorithms want to know // There is a cyclical dependency here: the layout algorithms want to know
@@ -1043,9 +1089,10 @@ export class CallViewModel extends ViewModel {
private readonly setVisibleTiles = (value: number): void => private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles$.next(value); this.visibleTiles$.next(value);
public readonly layoutInternals$: Observable< private readonly layoutInternals$ = this.scope.behavior<
LayoutScanState & { layout: Layout } LayoutScanState & { layout: Layout }
> = combineLatest([ >(
combineLatest([
this.layoutMedia$, this.layoutMedia$,
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
]).pipe( ]).pipe(
@@ -1083,32 +1130,29 @@ export class CallViewModel extends ViewModel {
}, },
{ layout: null, tiles: TileStore.empty() }, { layout: null, tiles: TileStore.empty() },
), ),
this.scope.state(), ),
); );
/** /**
* The layout of tiles in the call interface. * The layout of tiles in the call interface.
*/ */
public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe( public readonly layout$ = this.scope.behavior<Layout>(
map(({ layout }) => layout), this.layoutInternals$.pipe(map(({ layout }) => layout)),
this.scope.state(),
); );
/** /**
* The current generation of the tile store, exposed for debugging purposes. * The current generation of the tile store, exposed for debugging purposes.
*/ */
public readonly tileStoreGeneration$: Observable<number> = public readonly tileStoreGeneration$ = this.scope.behavior<number>(
this.layoutInternals$.pipe( this.layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
map(({ tiles }) => tiles.generation),
this.scope.state(),
); );
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe( public showSpotlightIndicators$ = this.scope.behavior<boolean>(
map((l) => l.type !== "grid"), this.layout$.pipe(map((l) => l.type !== "grid")),
this.scope.state(),
); );
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe( public showSpeakingIndicators$ = this.scope.behavior<boolean>(
this.layout$.pipe(
switchMap((l) => { switchMap((l) => {
switch (l.type) { switch (l.type) {
case "spotlight-landscape": case "spotlight-landscape":
@@ -1132,10 +1176,12 @@ export class CallViewModel extends ViewModel {
return of(true); return of(true);
} }
}), }),
this.scope.state(), ),
); );
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = public readonly toggleSpotlightExpanded$ = this.scope.behavior<
(() => void) | null
>(
this.windowMode$.pipe( this.windowMode$.pipe(
switchMap((mode) => switchMap((mode) =>
mode === "normal" mode === "normal"
@@ -1152,7 +1198,7 @@ export class CallViewModel extends ViewModel {
map((enabled) => map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle$.next() : null, enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
), ),
this.scope.state(), ),
); );
private readonly screenTap$ = new Subject<void>(); private readonly screenTap$ = new Subject<void>();
@@ -1188,12 +1234,12 @@ export class CallViewModel extends ViewModel {
this.screenUnhover$.next(); this.screenUnhover$.next();
} }
public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe( public readonly showHeader$ = this.scope.behavior<boolean>(
map((mode) => mode !== "pip" && mode !== "flat"), this.windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
this.scope.state(),
); );
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe( public readonly showFooter$ = this.scope.behavior<boolean>(
this.windowMode$.pipe(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":
@@ -1244,20 +1290,23 @@ export class CallViewModel extends ViewModel {
); );
} }
}), }),
this.scope.state(), ),
); );
/** /**
* 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$ = this.scope.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 &&
).pipe(this.scope.state()); available.get(selected.id)?.type === "earpiece",
),
);
/** /**
* Callback to toggle between the earpiece and the loudspeaker. * Callback to toggle between the earpiece and the loudspeaker.
@@ -1265,10 +1314,11 @@ 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$ = this.scope.behavior<{
targetOutput: "earpiece" | "speaker"; targetOutput: "earpiece" | "speaker";
switch: () => void; switch: () => void;
} | null> = combineLatest( } | null>(
combineLatest(
[ [
this.mediaDevices.audioOutput.available$, this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$, this.mediaDevices.audioOutput.selected$,
@@ -1277,7 +1327,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 speaker switch to speaker. // If we are in any output mode other than speaker 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,
@@ -1287,25 +1337,17 @@ export class CallViewModel extends ViewModel {
const [id] = newSelection; const [id] = newSelection;
return { return {
targetOutput: newSelectionType, targetOutput: newSelectionType,
switch: () => this.mediaDevices.audioOutput.select(id), switch: (): void => this.mediaDevices.audioOutput.select(id),
}; };
}, },
);
public readonly reactions$ = this.reactionsSubject$.pipe(
map((v) =>
Object.fromEntries(
Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]),
),
), ),
); );
public readonly handsRaised$ = this.handsRaisedSubject$.pipe();
/** /**
* Emits an array of reactions that should be visible on the screen. * Emits an array of reactions that should be visible on the screen.
*/ */
public readonly visibleReactions$ = showReactions.value$.pipe( public readonly visibleReactions$ = this.scope.behavior(
showReactions.value$.pipe(
switchMap((show) => (show ? this.reactions$ : of({}))), switchMap((show) => (show ? this.reactions$ : of({}))),
scan< scan<
Record<string, ReactionOption>, Record<string, ReactionOption>,
@@ -1320,6 +1362,7 @@ export class CallViewModel extends ViewModel {
} }
return newSet; return newSet;
}, []), }, []),
),
); );
/** /**

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,19 +94,20 @@ 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 scope.behavior(
usingNames$.pipe(
switchMap((withNames) => switchMap((withNames) =>
withNames withNames
? // It might be that there is already a media stream running somewhere, ? // It might be that there is already a media stream running somewhere,
@@ -121,8 +122,8 @@ function availableRawDevices$(
) )
: devices$, : devices$,
), ),
startWith([]), ),
scope.state(), [],
); );
} }
@@ -161,18 +162,15 @@ 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.scope.behavior(
map(buildDeviceMap), this.availableRaw$.pipe(map(buildDeviceMap)),
this.scope.state(),
); );
public readonly selected$ = selectDevice$( public readonly selected$ = this.scope.behavior(
this.available$, selectDevice$(this.available$, audioInputSetting.value$).pipe(
audioInputSetting.value$,
).pipe(
map((id) => map((id) =>
id === undefined id === undefined
? undefined ? undefined
@@ -181,14 +179,16 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
// We can identify when the hardware device has changed by watching for // We can identify when the hardware device has changed by watching for
// changes in the group ID // changes in the group ID
hardwareDeviceChange$: this.availableRaw$.pipe( hardwareDeviceChange$: this.availableRaw$.pipe(
map((devices) => devices.find((d) => d.deviceId === id)?.groupId), map(
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
),
pairwise(), pairwise(),
filter(([before, after]) => before !== after), filter(([before, after]) => before !== after),
map(() => undefined), map(() => undefined),
), ),
}, },
), ),
this.scope.state(), ),
); );
public select(id: string): void { public select(id: string): void {
@@ -196,7 +196,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
} }
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) => {
@@ -208,11 +208,8 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
class AudioOutput class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice> implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{ {
public readonly available$ = availableRawDevices$( public readonly available$ = this.scope.behavior(
"audiooutput", availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
this.usingNames$,
this.scope,
).pipe(
map((availableRaw) => { map((availableRaw) => {
const available: Map<string, AudioOutputDeviceLabel> = const available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw); buildDeviceMap(availableRaw);
@@ -229,13 +226,11 @@ class AudioOutput
// automatically track the default device. // automatically track the default device.
return available; return available;
}), }),
this.scope.state(), ),
); );
public readonly selected$ = selectDevice$( public readonly selected$ = this.scope.behavior(
this.available$, selectDevice$(this.available$, audioOutputSetting.value$).pipe(
audioOutputSetting.value$,
).pipe(
map((id) => map((id) =>
id === undefined id === undefined
? undefined ? undefined
@@ -244,15 +239,14 @@ class AudioOutput
virtualEarpiece: false, virtualEarpiece: false,
}, },
), ),
this.scope.state(), ),
); );
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) => {
@@ -273,7 +267,8 @@ class ControlledAudioOutput
this.scope, this.scope,
); );
public readonly available$ = combineLatest( public readonly available$ = this.scope.behavior(
combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$], [controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => { (availableRaw, iosDeviceMenu) => {
const available = new Map<string, AudioOutputDeviceLabel>( const available = new Map<string, AudioOutputDeviceLabel>(
@@ -296,7 +291,8 @@ class ControlledAudioOutput
return available; return available;
}, },
).pipe(this.scope.state()); ),
);
private readonly deviceSelection$ = new Subject<string>(); private readonly deviceSelection$ = new Subject<string>();
@@ -304,7 +300,8 @@ class ControlledAudioOutput
this.deviceSelection$.next(id); this.deviceSelection$.next(id);
} }
public readonly selected$ = combineLatest( public readonly selected$ = this.scope.behavior(
combineLatest(
[ [
this.available$, this.available$,
merge( merge(
@@ -318,10 +315,11 @@ class ControlledAudioOutput
? undefined ? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
}, },
).pipe(this.scope.state()); ),
);
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
this.selected$.subscribe((device) => { this.selected$.subscribe((device) => {
@@ -346,26 +344,22 @@ class ControlledAudioOutput
} }
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> { class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
public readonly available$ = availableRawDevices$( public readonly available$ = this.scope.behavior(
"videoinput", availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
this.usingNames$, map(buildDeviceMap),
this.scope, ),
).pipe(map(buildDeviceMap)); );
public readonly selected$ = this.scope.behavior(
public readonly selected$ = selectDevice$( selectDevice$(this.available$, videoInputSetting.value$).pipe(
this.available$, map((id) => (id === undefined ? undefined : { id })),
videoInputSetting.value$, ),
).pipe(
map((id) => (id === undefined ? undefined : { id })),
this.scope.state(),
); );
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
@@ -393,12 +387,10 @@ 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.scope.behavior(
map(() => true), this.deviceNamesRequest$.pipe(map(() => true)),
startWith(false), false,
this.scope.state(),
); );
public readonly audioInput: MediaDevice< public readonly audioInput: MediaDevice<
DeviceLabel, DeviceLabel,
SelectedAudioInputDevice SelectedAudioInputDevice

View File

@@ -51,27 +51,20 @@ import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type Behavior } from "./Behavior";
export function observeTrackReference$( export function observeTrackReference$(
participant$: Observable<Participant | undefined>, participant: Participant,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder | undefined> { ): Observable<TrackReferenceOrPlaceholder> {
return participant$.pipe( return observeParticipantMedia(participant).pipe(
switchMap((p) => {
if (p) {
return observeParticipantMedia(p).pipe(
map(() => ({ map(() => ({
participant: p, participant: participant,
publication: p.getTrackPublication(source), publication: participant.getTrackPublication(source),
source, source,
})), })),
distinctUntilKeyChanged("publication"), distinctUntilKeyChanged("publication"),
); );
} else {
return of(undefined);
}
}),
);
} }
export function observeRtpStreamStats$( export function observeRtpStreamStats$(
@@ -82,7 +75,7 @@ export function observeRtpStreamStats$(
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
> { > {
return combineLatest([ return combineLatest([
observeTrackReference$(of(participant), source), observeTrackReference$(participant, source),
interval(1000).pipe(startWith(0)), interval(1000).pipe(startWith(0)),
]).pipe( ]).pipe(
switchMap(async ([trackReference]) => { switchMap(async ([trackReference]) => {
@@ -223,19 +216,31 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>; public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning$: Observable<boolean>; public readonly unencryptedWarning$: Behavior<boolean>;
public readonly encryptionStatus$: Observable<EncryptionStatus>; public readonly encryptionStatus$: Behavior<EncryptionStatus>;
/** /**
* Whether this media corresponds to the local participant. * Whether this media corresponds to the local participant.
*/ */
public abstract readonly local: boolean; public abstract readonly local: boolean;
private observeTrackReference$(
source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | undefined> {
return this.scope.behavior(
this.participant$.pipe(
switchMap((p) =>
p === undefined ? of(undefined) : observeTrackReference$(p, source),
),
),
);
}
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -257,24 +262,25 @@ 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).pipe(
this.scope.state(), const audio$ = this.observeTrackReference$(audioSource);
); this.video$ = this.observeTrackReference$(videoSource);
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(), this.unencryptedWarning$ = this.scope.behavior(
); combineLatest(
this.unencryptedWarning$ = combineLatest(
[audio$, this.video$], [audio$, this.video$],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe(this.scope.state()); ),
);
this.encryptionStatus$ = this.participant$.pipe( this.encryptionStatus$ = this.scope.behavior(
this.participant$.pipe(
switchMap((participant): Observable<EncryptionStatus> => { switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) { if (!participant) {
return of(EncryptionStatus.Connecting); return of(EncryptionStatus.Connecting);
@@ -334,7 +340,7 @@ abstract class BaseMediaViewModel extends ViewModel {
); );
} }
}), }),
this.scope.state(), ),
); );
} }
} }
@@ -354,31 +360,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking$ = this.participant$.pipe( public readonly speaking$ = this.scope.behavior(
this.participant$.pipe(
switchMap((p) => switchMap((p) =>
p p
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( ? observeParticipantEvents(
map((p) => p.isSpeaking), p,
) ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking))
: of(false), : of(false),
), ),
this.scope.state(), ),
); );
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
public readonly audioEnabled$: Observable<boolean>; public readonly audioEnabled$: Behavior<boolean>;
/** /**
* Whether this participant is sending video. * Whether this participant is sending video.
*/ */
public readonly videoEnabled$: Observable<boolean>; public readonly videoEnabled$: Behavior<boolean>;
private readonly _cropVideo$ = new BehaviorSubject(true); private readonly _cropVideo$ = new BehaviorSubject(true);
/** /**
* Whether the tile video should be contained inside the tile or be cropped to fit. * Whether the tile video should be contained inside the tile or be cropped to fit.
*/ */
public readonly cropVideo$: Observable<boolean> = this._cropVideo$; public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
public constructor( public constructor(
id: string, id: string,
@@ -386,9 +394,9 @@ 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$: Observable<Date | null>, public readonly handRaised$: Behavior<Date | null>,
public readonly reaction$: Observable<ReactionOption | null>, public readonly reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -398,18 +406,19 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom, livekitRoom,
displayname$, displayName$,
); );
const media$ = participant$.pipe( const media$ = this.scope.behavior(
participant$.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(), ),
); );
this.audioEnabled$ = media$.pipe( this.audioEnabled$ = this.scope.behavior(
map((m) => m?.microphoneTrack?.isMuted === false), media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
); );
this.videoEnabled$ = media$.pipe( this.videoEnabled$ = this.scope.behavior(
map((m) => m?.cameraTrack?.isMuted === false), media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
); );
} }
@@ -436,7 +445,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror$ = this.video$.pipe( public readonly mirror$ = this.scope.behavior(
this.video$.pipe(
switchMap((v) => { switchMap((v) => {
const track = v?.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
@@ -447,7 +457,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
map(() => facingModeFromLocalTrack(track).facingMode === "user"), map(() => facingModeFromLocalTrack(track).facingMode === "user"),
); );
}), }),
this.scope.state(), ),
); );
/** /**
@@ -460,12 +470,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant$: Observable<LocalParticipant | undefined>, participant$: Behavior<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayName$: Behavior<string>,
handRaised$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -473,7 +483,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayName$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -512,7 +522,8 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
* The volume to which this participant's audio is set, as a scalar * The volume to which this participant's audio is set, as a scalar
* multiplier. * multiplier.
*/ */
public readonly localVolume$: Observable<number> = merge( public readonly localVolume$ = this.scope.behavior<number>(
merge(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment$, this.localVolumeAdjustment$,
this.localVolumeCommit$.pipe(map(() => "commit" as const)), this.localVolumeCommit$.pipe(map(() => "commit" as const)),
@@ -539,15 +550,14 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
} }
}), }),
map(({ volume }) => volume), map(({ volume }) => volume),
this.scope.state(), ),
); );
/** /**
* Whether this participant's audio is disabled. * Whether this participant's audio is disabled.
*/ */
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe( public readonly locallyMuted$ = this.scope.behavior<boolean>(
map((volume) => volume === 0), this.localVolume$.pipe(map((volume) => volume === 0)),
this.scope.state(),
); );
public constructor( public constructor(
@@ -556,9 +566,9 @@ 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$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -621,7 +631,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

@@ -26,11 +26,9 @@ test("muteAllAudio$", () => {
muteAllAudio.unsubscribe(); muteAllAudio.unsubscribe();
expect(valueMock).toHaveBeenCalledTimes(6); expect(valueMock).toHaveBeenCalledTimes(4);
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]); expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false); expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true); expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false); expect(valueMock).toHaveBeenNthCalledWith(4, true); // muteAllAudioSetting.setValue(true);
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
}); });

View File

@@ -9,11 +9,14 @@ 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";
/** /**
* This can transition into sth more complete: `GroupCallViewModel.ts` * This can transition into sth more complete: `GroupCallViewModel.ts`
*/ */
export const muteAllAudio$ = combineLatest( export const muteAllAudio$ = globalScope.behavior(
combineLatest(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
(outputEnabled, settingsMute) => !outputEnabled || settingsMute, (outputEnabled, settingsMute) => !outputEnabled || settingsMute,
),
); );

View File

@@ -6,15 +6,19 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
BehaviorSubject,
distinctUntilChanged, distinctUntilChanged,
type Observable, type Observable,
shareReplay,
Subject, Subject,
takeUntil, takeUntil,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
const nothing = Symbol("nothing");
/** /**
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
@@ -31,20 +35,31 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
private readonly stateImpl: MonoTypeOperator = (o$) =>
o$.pipe(
this.bind(),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);
/** /**
* Transforms an Observable into a hot state Observable which replays its * Converts an Observable to a Behavior. If no initial value is specified, the
* latest value upon subscription, skips updates with identical values, and * Observable must synchronously emit an initial value.
* is bound to this scope.
*/ */
public state(): MonoTypeOperator { public behavior<T>(
return this.stateImpl; setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing,
): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject.
// BehaviorSubjects have an undesirable feature where if you call 'complete',
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next(value) {
subject$.next(value);
},
error(err: unknown) {
subject$.error(err);
},
});
if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>;
} }
/** /**
@@ -55,3 +70,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

@@ -47,6 +47,8 @@ import {
} from "../config/ConfigOptions"; } from "../config/ConfigOptions";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -67,6 +69,11 @@ export interface OurRunHelpers extends RunHelpers {
* diagram. * diagram.
*/ */
schedule: (marbles: string, actions: Record<string, () => void>) => void; schedule: (marbles: string, actions: Record<string, () => void>) => void;
behavior<T = string>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
} }
interface TestRunnerGlobal { interface TestRunnerGlobal {
@@ -82,6 +89,7 @@ export function withTestScheduler(
const scheduler = new TestScheduler((actual, expected) => { const scheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equals(expected); expect(actual).deep.equals(expected);
}); });
const scope = new ObservableScope();
// we set the test scheduler as a global so that you can watch it in a debugger // we set the test scheduler as a global so that you can watch it in a debugger
// and get the frame number. e.g. `rxjsTestScheduler?.now()` // and get the frame number. e.g. `rxjsTestScheduler?.now()`
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler; (global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
@@ -98,8 +106,36 @@ export function withTestScheduler(
// Run the actions and verify that none of them error // Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable$).toBe(marbles, results); helpers.expectObservable(actionsObservable$).toBe(marbles, results);
}, },
behavior<T>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
) {
// Generate a hot Observable with helpers.hot and use it as a Behavior.
// To do this, we need to ensure that the initial value emits
// synchronously upon subscription. The issue is that helpers.hot emits
// frame 0 of the marble diagram *asynchronously*, only once we return
// from the continuation, so we need to splice out the initial marble
// and turn it into a proper initial value.
const initialMarbleIndex = marbles.search(/[^ ]/);
if (initialMarbleIndex === -1)
throw new Error("Behavior must have an initial value");
const initialMarble = marbles[initialMarbleIndex];
const initialValue =
values === undefined ? (initialMarble as T) : values[initialMarble];
// The remainder of the marble diagram should start on frame 1
return scope.behavior(
helpers.hot(
`-${marbles.slice(initialMarbleIndex + 1)}`,
values,
error,
),
initialValue,
);
},
}), }),
); );
scope.end();
} }
interface EmitterMock<T> { interface EmitterMock<T> {
@@ -211,14 +247,14 @@ export async function withLocalMedia(
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMatrixRoomMember(localRtcMember, roomMember), mockMatrixRoomMember(localRtcMember, roomMember),
of(localParticipant), constant(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({ localParticipant }), mockLivekitRoom({ localParticipant }),
of(roomMember.rawDisplayName ?? "nodisplayname"), constant(roomMember.rawDisplayName ?? "nodisplayname"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -255,9 +291,9 @@ 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"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -299,7 +335,7 @@ export class MockRTCSession extends TypedEventEmitter<
} }
public withMemberships( public withMemberships(
rtcMembers$: Observable<Partial<CallMembership>[]>, rtcMembers$: Behavior<Partial<CallMembership>[]>,
): MockRTCSession { ): MockRTCSession {
rtcMembers$.subscribe((m) => { rtcMembers$.subscribe((m) => {
const old = this.memberships; const old = this.memberships;