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$) =>
observeTrackReference$( room$.pipe(
room$.pipe(map(([room]) => room.localParticipant)), switchMap(([room]) =>
Track.Source.Camera, observeTrackReference$(
).pipe( room.localParticipant,
Track.Source.Camera,
),
),
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,50 +880,46 @@ 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( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ b: new Map([
// Carol has no displayname - So userId is used. [carolId, carol.userId],
a: new Map([[carolId, carol.userId]]), [aliceId, alice.rawDisplayName],
b: new Map([ ]),
[carolId, carol.userId], // The second alice joins.
[aliceId, alice.rawDisplayName], c: new Map([
]), [carolId, carol.userId],
// The second alice joins. [aliceId, "Alice (@alice:example.org)"],
c: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], ]),
[aliceId, "Alice (@alice:example.org)"], // Bob also joins
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], d: new Map([
]), [carolId, carol.userId],
// Bob also joins [aliceId, "Alice (@alice:example.org)"],
d: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], [bobId, bob.rawDisplayName],
[aliceId, "Alice (@alice:example.org)"], ]),
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], // Alice leaves, and the displayname should reset.
[bobId, bob.rawDisplayName], e: new Map([
]), [carolId, carol.userId],
// Alice leaves, and the displayname should reset. [aliceDoppelgangerId, "Alice"],
e: new Map([ [bobId, bob.rawDisplayName],
[carolId, carol.userId], ]),
[aliceDoppelgangerId, "Alice"], });
[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,36 +927,32 @@ 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( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Bobs join, and should handle zero width hacks.
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Bobs join, and should handle zero width hacks. [bobId, `Bob (${bob.userId})`],
b: new Map([ [
[carolId, carol.userId], bobZeroWidthSpaceId,
[bobId, `Bob (${bob.userId})`], `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
[ ],
bobZeroWidthSpaceId, ]),
`${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,22 +960,18 @@ 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( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Dave's join. Since after stripping
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Dave's join. Since after stripping // Not disambiguated
b: new Map([ [daveId, "Dave"],
[carolId, carol.userId], // This one is, since it's using RTL.
// Not disambiguated [daveRTLId, `evaD (${daveRTL.userId})`],
[daveId, "Dave"], ]),
// This one is, since it's using RTL. });
[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,

File diff suppressed because it is too large Load Diff

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,35 +94,36 @@ 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(
switchMap((withNames) => usingNames$.pipe(
withNames switchMap((withNames) =>
? // It might be that there is already a media stream running somewhere, withNames
// and so we can do without requesting a second one. Only switch to the ? // It might be that there is already a media stream running somewhere,
// device observer that explicitly requests the names if we see that // and so we can do without requesting a second one. Only switch to the
// names are in fact missing from the initial device enumeration. // device observer that explicitly requests the names if we see that
devices$.pipe( // names are in fact missing from the initial device enumeration.
switchWhen( devices$.pipe(
(devices, i) => i === 0 && devices.every((d) => !d.label), switchWhen(
devicesWithNames$, (devices, i) => i === 0 && devices.every((d) => !d.label),
), devicesWithNames$,
) ),
: devices$, )
: devices$,
),
), ),
startWith([]), [],
scope.state(),
); );
} }
@@ -161,34 +162,33 @@ 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$, map((id) =>
).pipe( id === undefined
map((id) => ? undefined
id === undefined : {
? undefined id,
: { // We can identify when the hardware device has changed by watching for
id, // changes in the group ID
// We can identify when the hardware device has changed by watching for hardwareDeviceChange$: this.availableRaw$.pipe(
// changes in the group ID map(
hardwareDeviceChange$: this.availableRaw$.pipe( (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,51 +208,45 @@ 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$, map((availableRaw) => {
this.scope, const available: Map<string, AudioOutputDeviceLabel> =
).pipe( buildDeviceMap(availableRaw);
map((availableRaw) => { // Create a virtual default audio output for browsers that don't have one.
const available: Map<string, AudioOutputDeviceLabel> = // Its device ID must be the empty string because that's what setSinkId
buildDeviceMap(availableRaw); // recognizes.
// Create a virtual default audio output for browsers that don't have one. if (available.size && !available.has("") && !available.has("default"))
// Its device ID must be the empty string because that's what setSinkId available.set("", {
// recognizes. type: "default",
if (available.size && !available.has("") && !available.has("default")) name: availableRaw[0]?.label || null,
available.set("", { });
type: "default", // Note: creating virtual default input devices would be another problem
name: availableRaw[0]?.label || null, // entirely, because requesting a media stream from deviceId "" won't
}); // automatically track the default device.
// Note: creating virtual default input devices would be another problem return available;
// entirely, because requesting a media stream from deviceId "" won't }),
// automatically track the default device.
return available;
}),
this.scope.state(),
);
public readonly selected$ = selectDevice$(
this.available$,
audioOutputSetting.value$,
).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
virtualEarpiece: false,
},
), ),
this.scope.state(),
); );
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, audioOutputSetting.value$).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
virtualEarpiece: false,
},
),
),
);
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,30 +267,32 @@ class ControlledAudioOutput
this.scope, this.scope,
); );
public readonly available$ = combineLatest( public readonly available$ = this.scope.behavior(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$], combineLatest(
(availableRaw, iosDeviceMenu) => { [controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
const available = new Map<string, AudioOutputDeviceLabel>( (availableRaw, iosDeviceMenu) => {
availableRaw.map( const available = new Map<string, AudioOutputDeviceLabel>(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => { availableRaw.map(
let deviceLabel: AudioOutputDeviceLabel; ({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
// if (isExternalHeadset) // Do we want this? let deviceLabel: AudioOutputDeviceLabel;
if (isEarpiece) deviceLabel = { type: "earpiece" }; // if (isExternalHeadset) // Do we want this?
else if (isSpeaker) deviceLabel = { type: "speaker" }; if (isEarpiece) deviceLabel = { type: "earpiece" };
else deviceLabel = { type: "name", name }; else if (isSpeaker) deviceLabel = { type: "speaker" };
return [id, deviceLabel]; else deviceLabel = { type: "name", name };
}, return [id, deviceLabel];
), },
); ),
);
// Create a virtual earpiece device in case a non-earpiece device is // Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose // designated for this purpose
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available; return available;
}, },
).pipe(this.scope.state()); ),
);
private readonly deviceSelection$ = new Subject<string>(); private readonly deviceSelection$ = new Subject<string>();
@@ -304,24 +300,26 @@ class ControlledAudioOutput
this.deviceSelection$.next(id); this.deviceSelection$.next(id);
} }
public readonly selected$ = combineLatest( public readonly selected$ = this.scope.behavior(
[ combineLatest(
this.available$, [
merge( this.available$,
controlledOutputSelection$.pipe(startWith(undefined)), merge(
this.deviceSelection$, controlledOutputSelection$.pipe(startWith(undefined)),
), this.deviceSelection$,
], ),
(available, preferredId) => { ],
const id = preferredId ?? available.keys().next().value; (available, preferredId) => {
return id === undefined const id = preferredId ?? available.keys().next().value;
? undefined return id === undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; ? undefined
}, : { 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,26 +51,19 @@ 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) => { map(() => ({
if (p) { participant: participant,
return observeParticipantMedia(p).pipe( publication: participant.getTrackPublication(source),
map(() => ({ source,
participant: p, })),
publication: p.getTrackPublication(source), distinctUntilKeyChanged("publication"),
source,
})),
distinctUntilKeyChanged("publication"),
);
} else {
return of(undefined);
}
}),
); );
} }
@@ -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,84 +262,85 @@ 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(),
);
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(),
);
this.unencryptedWarning$ = combineLatest(
[audio$, this.video$],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false),
).pipe(this.scope.state());
this.encryptionStatus$ = this.participant$.pipe( const audio$ = this.observeTrackReference$(audioSource);
switchMap((participant): Observable<EncryptionStatus> => { this.video$ = this.observeTrackReference$(videoSource);
if (!participant) {
return of(EncryptionStatus.Connecting); this.unencryptedWarning$ = this.scope.behavior(
} else if ( combineLatest(
participant.isLocal || [audio$, this.video$],
encryptionSystem.kind === E2eeType.NONE (a, v) =>
) { encryptionSystem.kind !== E2eeType.NONE &&
return of(EncryptionStatus.Okay); (a?.publication?.isEncrypted === false ||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { v?.publication?.isEncrypted === false),
return combineLatest([ ),
encryptionErrorObservable$( );
livekitRoom,
participant, this.encryptionStatus$ = this.scope.behavior(
encryptionSystem, this.participant$.pipe(
"MissingKey", switchMap((participant): Observable<EncryptionStatus> => {
), if (!participant) {
encryptionErrorObservable$( return of(EncryptionStatus.Connecting);
livekitRoom, } else if (
participant, participant.isLocal ||
encryptionSystem, encryptionSystem.kind === E2eeType.NONE
"InvalidKey", ) {
), return of(EncryptionStatus.Okay);
observeRemoteTrackReceivingOkay$(participant, audioSource), } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
observeRemoteTrackReceivingOkay$(participant, videoSource), return combineLatest([
]).pipe( encryptionErrorObservable$(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { livekitRoom,
if (keyMissing) return EncryptionStatus.KeyMissing; participant,
if (keyInvalid) return EncryptionStatus.KeyInvalid; encryptionSystem,
if (audioOkay || videoOkay) return EncryptionStatus.Okay; "MissingKey",
return undefined; // no change ),
}), encryptionErrorObservable$(
filter((x) => !!x), livekitRoom,
startWith(EncryptionStatus.Connecting), participant,
); encryptionSystem,
} else { "InvalidKey",
return combineLatest([ ),
encryptionErrorObservable$( observeRemoteTrackReceivingOkay$(participant, audioSource),
livekitRoom, observeRemoteTrackReceivingOkay$(participant, videoSource),
participant, ]).pipe(
encryptionSystem, map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
"InvalidKey", if (keyMissing) return EncryptionStatus.KeyMissing;
), if (keyInvalid) return EncryptionStatus.KeyInvalid;
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
| EncryptionStatus
| undefined => {
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay; if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change return undefined; // no change
}, }),
), filter((x) => !!x),
filter((x) => !!x), startWith(EncryptionStatus.Connecting),
startWith(EncryptionStatus.Connecting), );
); } else {
} return combineLatest([
}), encryptionErrorObservable$(
this.scope.state(), livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
| EncryptionStatus
| undefined => {
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change
},
),
filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
);
}
}),
),
); );
} }
} }
@@ -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(
switchMap((p) => this.participant$.pipe(
p switchMap((p) =>
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( p
map((p) => p.isSpeaking), ? observeParticipantEvents(
) p,
: of(false), ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking))
: 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(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), participant$.pipe(
this.scope.state(), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
),
); );
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,18 +445,19 @@ 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(
switchMap((v) => { this.video$.pipe(
const track = v?.publication?.track; switchMap((v) => {
if (!(track instanceof LocalTrack)) return of(false); const track = v?.publication?.track;
// Watch for track restarts, because they indicate a camera switch if (!(track instanceof LocalTrack)) return of(false);
return fromEvent(track, TrackEvent.Restarted).pipe( // Watch for track restarts, because they indicate a camera switch
startWith(null), return fromEvent(track, TrackEvent.Restarted).pipe(
// Mirror only front-facing cameras (those that face the user) startWith(null),
map(() => facingModeFromLocalTrack(track).facingMode === "user"), // Mirror only front-facing cameras (those that face the 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,42 +522,42 @@ 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>(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), merge(
this.localVolumeAdjustment$, this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeCommit$.pipe(map(() => "commit" as const)), this.localVolumeAdjustment$,
).pipe( this.localVolumeCommit$.pipe(map(() => "commit" as const)),
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { ).pipe(
switch (event) { accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
case "toggle mute": switch (event) {
return { case "toggle mute":
...state, return {
volume: state.volume === 0 ? state.committedVolume : 0, ...state,
}; volume: state.volume === 0 ? state.committedVolume : 0,
case "commit": };
// Dragging the slider to zero should have the same effect as case "commit":
// muting: keep the original committed volume, as if it were never // Dragging the slider to zero should have the same effect as
// dragged // muting: keep the original committed volume, as if it were never
return { // dragged
...state, return {
committedVolume: ...state,
state.volume === 0 ? state.committedVolume : state.volume, committedVolume:
}; state.volume === 0 ? state.committedVolume : state.volume,
default: };
// Volume adjustment default:
return { ...state, volume: event }; // Volume adjustment
} return { ...state, volume: event };
}), }
map(({ volume }) => volume), }),
this.scope.state(), map(({ volume }) => volume),
),
); );
/** /**
* 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(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], combineLatest(
(outputEnabled, settingsMute) => !outputEnabled || settingsMute, [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
(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;