diff --git a/.eslintrc.cjs b/.eslintrc.cjs index cada6b46..b734c520 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,7 +44,7 @@ module.exports = { ], // To encourage good usage of RxJS: "rxjs/no-exposed-subjects": "error", - "rxjs/finnish": "error", + "rxjs/finnish": ["error", { names: { "^this$": false } }], }, settings: { react: { diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index f1a6f0aa..69673293 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -24,8 +24,6 @@ import { import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import classNames from "classnames"; -import { useObservableState } from "observable-hooks"; -import { map } from "rxjs"; import { useReactionsSender } from "../reactions/useReactionsSender"; import styles from "./ReactionToggleButton.module.css"; @@ -36,6 +34,7 @@ import { } from "../reactions"; import { Modal } from "../Modal"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -180,12 +179,8 @@ export function ReactionToggleButton({ const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = useObservableState( - vm.handsRaised$.pipe(map((v) => !!v[identifier])), - ); - const canReact = useObservableState( - vm.reactions$.pipe(map((v) => !v[identifier])), - ); + const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier]; + const canReact = !useBehavior(vm.reactions$)[identifier]; useEffect(() => { // Clear whenever the reactions menu state changes. diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index d0588fb6..6c85b8af 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -24,16 +24,16 @@ import { createContext, memo, use, + useCallback, useEffect, useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import useMeasure from "react-use-measure"; import classNames from "classnames"; 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 { 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 { ref?: Ref; model: LayoutModel; @@ -261,7 +256,13 @@ export function Grid< const [gridRoot, gridRef2] = useState(null); const gridRef = useMergedRefs(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(null); const [generation, setGeneration] = useState(null); const [visibleTilesCallback, setVisibleTilesCallback] = diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 8e1bffbe..675e4d0a 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod import { type CallLayout, arrangeTiles } from "./CallLayout"; import styles from "./OneOnOneLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "one-on-one" layout, in which the remote participant @@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout = ({ scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index 88271752..9dd2a109 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useCallback } from "react"; -import { useObservableEagerState } from "observable-hooks"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type CallLayout } from "./CallLayout"; import { type DragCallback, useUpdateLayout } from "./Grid"; import styles from "./SpotlightExpandedLayout.module.css"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 3b4de6a1..ad11ed11 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightPortraitLayout.module.css"; import { useUpdateLayout, useVisibleTiles } from "./Grid"; +import { useBehavior } from "../useBehavior"; interface GridCSSProperties extends CSSProperties { "--grid-gap": string; @@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout< width, model.grid.length, ); - const withIndicators = - useObservableEagerState(model.spotlight.media$).length > 1; + const withIndicators = useBehavior(model.spotlight.media$).length > 1; return (
- observeTrackReference$( - room$.pipe(map(([room]) => room.localParticipant)), - Track.Source.Camera, - ).pipe( + room$.pipe( + switchMap(([room]) => + observeTrackReference$( + room.localParticipant, + Track.Source.Camera, + ), + ), map((trackRef) => { const track = trackRef?.publication?.track; return track instanceof LocalVideoTrack ? track : null; diff --git a/src/reactions/useReactionsSender.tsx b/src/reactions/useReactionsSender.tsx index 30804d4b..5f509a0c 100644 --- a/src/reactions/useReactionsSender.tsx +++ b/src/reactions/useReactionsSender.tsx @@ -16,12 +16,12 @@ import { } from "react"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { logger } from "matrix-js-sdk/lib/logger"; -import { useObservableEagerState } from "observable-hooks"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useClientState } from "../ClientContext"; import { ElementCallReactionEventType, type ReactionOption } from "."; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; interface ReactionsSenderContextType { supportsReactions: boolean; @@ -70,7 +70,7 @@ export const ReactionsSenderProvider = ({ [memberships, myUserId, myDeviceId], ); - const reactions = useObservableEagerState(vm.reactions$); + const reactions = useBehavior(vm.reactions$); const myReaction = useMemo( () => myMembershipIdentifier !== undefined @@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({ [myMembershipIdentifier, reactions], ); - const handsRaised = useObservableEagerState(vm.handsRaised$); + const handsRaised = useBehavior(vm.handsRaised$); const myRaisedHand = useMemo( () => myMembershipIdentifier !== undefined diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 3a290cc7..4eb32af0 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -16,7 +16,6 @@ import { import { render, waitFor, screen } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; -import { of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; 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 { MediaDevicesContext } from "../MediaDevicesContext"; import { HeaderStyle } from "../UrlParams"; +import { constant } from "../state/Behavior"; vi.mock("../soundUtils"); vi.mock("../useAudioContext"); @@ -141,7 +141,7 @@ function createGroupCallView( room, localRtcMember, [], - ).withMemberships(of([])); + ).withMemberships(constant([])); rtcSession.joined = joined; const muteState = { audio: { enabled: false }, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 5b9b1f02..4af599bb 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -24,7 +24,6 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import { useObservableEagerState } from "observable-hooks"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { @@ -72,6 +71,7 @@ import { import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; declare global { interface Window { @@ -110,7 +110,7 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const muteAllAudio = useObservableEagerState(muteAllAudio$); + const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( useAudioContext({ sounds: callEventAudioSounds, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 452e8572..74b738d9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,7 @@ import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; 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 { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -112,6 +112,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership import { useMediaDevices } from "../MediaDevicesContext.ts"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -251,7 +252,7 @@ export const InCallView: FC = ({ 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? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -302,15 +303,15 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const windowMode = useObservableEagerState(vm.windowMode$); - const layout = useObservableEagerState(vm.layout$); - const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); + const windowMode = useBehavior(vm.windowMode$); + const layout = useBehavior(vm.layout$); + const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useObservableEagerState(vm.gridMode$); - const showHeader = useObservableEagerState(vm.showHeader$); - const showFooter = useObservableEagerState(vm.showFooter$); - const earpieceMode = useObservableEagerState(vm.earpieceMode$); - const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); + const gridMode = useBehavior(vm.gridMode$); + const showHeader = useBehavior(vm.showHeader$); + const showFooter = useBehavior(vm.showFooter$); + const earpieceMode = useBehavior(vm.earpieceMode$); + const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking @@ -527,16 +528,12 @@ export const InCallView: FC = ({ targetHeight, model, }: TileProps): ReactNode { - const spotlightExpanded = useObservableEagerState( - vm.spotlightExpanded$, - ); - const onToggleExpanded = useObservableEagerState( - vm.toggleSpotlightExpanded$, - ); - const showSpeakingIndicatorsValue = useObservableEagerState( + const spotlightExpanded = useBehavior(vm.spotlightExpanded$); + const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); + const showSpeakingIndicatorsValue = useBehavior( vm.showSpeakingIndicators$, ); - const showSpotlightIndicatorsValue = useObservableEagerState( + const showSpotlightIndicatorsValue = useBehavior( vm.showSpotlightIndicators$, ); diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 9bf7ab66..f3dff848 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode } from "react"; -import { useObservableState } from "observable-hooks"; import styles from "./ReactionsOverlay.module.css"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { - const reactionsIcons = useObservableState(vm.visibleReactions$); + const reactionsIcons = useBehavior(vm.visibleReactions$); return (
- {reactionsIcons?.map(({ sender, emoji, startX }) => ( + {reactionsIcons.map(({ sender, emoji, startX }) => ( = ({ // rather than the input section. const { controlledAudioDevices } = useUrlParams(); // 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 = { key: "audio", diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 50e70671..7c7f1250 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { logger } from "matrix-js-sdk/lib/logger"; -import { BehaviorSubject, type Observable } from "rxjs"; -import { useObservableEagerState } from "observable-hooks"; +import { BehaviorSubject } from "rxjs"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; +import { type Behavior } from "../state/Behavior"; +import { useBehavior } from "../useBehavior"; export class Setting { public constructor( @@ -38,7 +39,7 @@ export class Setting { private readonly key: string; private readonly _value$: BehaviorSubject; - public readonly value$: Observable; + public readonly value$: Behavior; public readonly setValue = (value: T): void => { this._value$.next(value); @@ -53,7 +54,7 @@ export class Setting { * React hook that returns a settings's current value and a setter. */ export function useSetting(setting: Setting): [T, (value: T) => void] { - return [useObservableEagerState(setting.value$), setting.setValue]; + return [useBehavior(setting.value$), setting.setValue]; } // null = undecided diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts new file mode 100644 index 00000000..3c88dc00 --- /dev/null +++ b/src/state/Behavior.ts @@ -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 = Omit, "next" | "observers">; + +/** + * Creates a Behavior which never changes in value. + */ +export function constant(value: T): Behavior { + return new BehaviorSubject(value); +} diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 42b04079..0d09aa31 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -12,9 +12,9 @@ import { debounceTime, distinctUntilChanged, map, + NEVER, type Observable, of, - skip, switchMap, } from "rxjs"; import { type MatrixClient } from "matrix-js-sdk"; @@ -75,10 +75,18 @@ import { import { ObservableScope } from "./ObservableScope"; import { MediaDevices } from "./MediaDevices"; import { getValue } from "../utils/observable"; +import { type Behavior, constant } from "./Behavior"; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); 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 => NEVER, +})); + vi.mock("@livekit/components-core"); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD"); @@ -157,9 +165,10 @@ function summarizeLayout$(l$: Observable): Observable { case "grid": return combineLatest( [ - l.spotlight?.media$ ?? of(undefined), + l.spotlight?.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], + // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight?.map((vm) => vm.id), @@ -178,7 +187,8 @@ function summarizeLayout$(l$: Observable): Observable { ); case "spotlight-expanded": 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) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -212,8 +222,8 @@ function summarizeLayout$(l$: Observable): Observable { } function withCallViewModel( - remoteParticipants$: Observable, - rtcMembers$: Observable[]>, + remoteParticipants$: Behavior, + rtcMembers$: Behavior[]>, connectionState$: Observable, speaking: Map>, mediaDevices: MediaDevices, @@ -291,7 +301,7 @@ function withCallViewModel( } 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 const participantInputMarbles = "a-ba"; // 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"; withCallViewModel( - hot(participantInputMarbles, { + behavior(participantInputMarbles, { a: [aliceParticipant, bobParticipant], b: [], }), - of([aliceRtcMember, bobRtcMember]), - hot(connectionInputMarbles, { + constant([aliceRtcMember, bobRtcMember]), + behavior(connectionInputMarbles, { c: ConnectionState.Connected, s: ECAddonConnectionState.ECSwitchingFocus, }), @@ -328,7 +338,7 @@ test("participants are retained during a focus switch", () => { }); 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, // then return to no screen shares, then have just Alice share for a bit const participantInputMarbles = " abcda-ba"; @@ -341,13 +351,13 @@ test("screen sharing activates spotlight layout", () => { const expectedLayoutMarbles = " abcdaefeg"; const expectedShowSpeakingMarbles = "y----nyny"; withCallViewModel( - hot(participantInputMarbles, { + behavior(participantInputMarbles, { a: [aliceParticipant, bobParticipant], b: [aliceSharingScreen, bobParticipant], c: [aliceSharingScreen, bobSharingScreen], d: [aliceParticipant, bobSharingScreen], }), - of([aliceRtcMember, bobRtcMember]), + constant([aliceRtcMember, bobRtcMember]), of(ConnectionState.Connected), new Map(), mockMediaDevices({}), @@ -413,7 +423,7 @@ test("screen sharing activates spotlight layout", () => { }); test("participants stay in the same order unless to appear/disappear", () => { - withTestScheduler(({ hot, schedule, expectObservable }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { const visibilityInputMarbles = "a"; // First Bob speaks, then Dave, then Alice 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"; withCallViewModel( - of([aliceParticipant, bobParticipant, daveParticipant]), - of([aliceRtcMember, bobRtcMember, daveRtcMember]), + constant([aliceParticipant, bobParticipant, daveParticipant]), + constant([aliceRtcMember, bobRtcMember, daveRtcMember]), of(ConnectionState.Connected), new Map([ - [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], - [bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], - [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], + [ + aliceParticipant, + behavior(aSpeakingInputMarbles, { y: true, n: false }), + ], + [ + bobParticipant, + behavior(bSpeakingInputMarbles, { y: true, n: false }), + ], + [ + daveParticipant, + behavior(dSpeakingInputMarbles, { y: true, n: false }), + ], ]), mockMediaDevices({}), (vm) => { @@ -472,7 +491,7 @@ test("participants stay in the same order unless to appear/disappear", () => { }); 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 const visibilityInputMarbles = "a-b"; // Bob and Dave speak @@ -484,12 +503,18 @@ test("participants adjust order when space becomes constrained", () => { const expectedLayoutMarbles = " a-b"; withCallViewModel( - of([aliceParticipant, bobParticipant, daveParticipant]), - of([aliceRtcMember, bobRtcMember, daveRtcMember]), + constant([aliceParticipant, bobParticipant, daveParticipant]), + constant([aliceRtcMember, bobRtcMember, daveRtcMember]), of(ConnectionState.Connected), 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({}), (vm) => { @@ -523,7 +548,7 @@ test("participants adjust order when space becomes constrained", () => { }); test("spotlight speakers swap places", () => { - withTestScheduler(({ hot, schedule, expectObservable }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { // Go immediately into spotlight mode for the test const modeInputMarbles = " s"; // First Bob speaks, then Dave, then Alice @@ -537,13 +562,22 @@ test("spotlight speakers swap places", () => { const expectedLayoutMarbles = "abcd"; withCallViewModel( - of([aliceParticipant, bobParticipant, daveParticipant]), - of([aliceRtcMember, bobRtcMember, daveRtcMember]), + constant([aliceParticipant, bobParticipant, daveParticipant]), + constant([aliceRtcMember, bobRtcMember, daveRtcMember]), of(ConnectionState.Connected), new Map([ - [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], - [bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], - [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], + [ + aliceParticipant, + behavior(aSpeakingInputMarbles, { y: true, n: false }), + ], + [ + bobParticipant, + behavior(bSpeakingInputMarbles, { y: true, n: false }), + ], + [ + daveParticipant, + behavior(dSpeakingInputMarbles, { y: true, n: false }), + ], ]), mockMediaDevices({}), (vm) => { @@ -587,8 +621,8 @@ test("layout enters picture-in-picture mode when requested", () => { const expectedLayoutMarbles = " aba"; withCallViewModel( - of([aliceParticipant, bobParticipant]), - of([aliceRtcMember, bobRtcMember]), + constant([aliceParticipant, bobParticipant]), + constant([aliceRtcMember, bobRtcMember]), of(ConnectionState.Connected), new Map(), mockMediaDevices({}), @@ -629,8 +663,8 @@ test("spotlight remembers whether it's expanded", () => { const expectedLayoutMarbles = "abcbada"; withCallViewModel( - of([aliceParticipant, bobParticipant]), - of([aliceRtcMember, bobRtcMember]), + constant([aliceParticipant, bobParticipant]), + constant([aliceRtcMember, bobRtcMember]), of(ConnectionState.Connected), new Map(), mockMediaDevices({}), @@ -678,7 +712,7 @@ test("spotlight remembers whether it's expanded", () => { }); 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 // Bob never has an MatrixRTC membership const scenarioInputMarbles = " abcdec"; @@ -686,14 +720,14 @@ test("participants must have a MatrixRTCSession to be visible", () => { const expectedLayoutMarbles = "a-bc-b"; withCallViewModel( - hot(scenarioInputMarbles, { + behavior(scenarioInputMarbles, { a: [], b: [bobParticipant], c: [aliceParticipant, bobParticipant], d: [aliceParticipant, daveParticipant, bobParticipant], e: [aliceParticipant, daveParticipant, bobSharingScreen], }), - hot(scenarioInputMarbles, { + behavior(scenarioInputMarbles, { a: [], b: [], c: [aliceRtcMember], @@ -734,17 +768,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", () try { // enable the setting: showNonMemberTiles.setValue(true); - withTestScheduler(({ hot, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const scenarioInputMarbles = " abc"; const expectedLayoutMarbles = "abc"; withCallViewModel( - hot(scenarioInputMarbles, { + behavior(scenarioInputMarbles, { a: [], b: [aliceParticipant], c: [aliceParticipant, bobParticipant], }), - of([]), // No one joins the MatrixRTC session + constant([]), // No one joins the MatrixRTC session of(ConnectionState.Connected), new Map(), mockMediaDevices({}), @@ -779,15 +813,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", () }); it("should show at least one tile per MatrixRTCSession", () => { - withTestScheduler(({ hot, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { // iterate through some combinations of MatrixRTC memberships const scenarioInputMarbles = " abcd"; // There should always be one tile for each MatrixRTCSession const expectedLayoutMarbles = "abcd"; withCallViewModel( - of([]), - hot(scenarioInputMarbles, { + constant([]), + behavior(scenarioInputMarbles, { a: [], b: [aliceRtcMember], c: [aliceRtcMember, daveRtcMember], @@ -829,13 +863,13 @@ it("should show at least one tile per MatrixRTCSession", () => { }); test("should disambiguate users with the same displayname", () => { - withTestScheduler(({ hot, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const scenarioInputMarbles = "abcde"; const expectedLayoutMarbles = "abcde"; withCallViewModel( - of([]), - hot(scenarioInputMarbles, { + constant([]), + behavior(scenarioInputMarbles, { a: [], b: [aliceRtcMember], c: [aliceRtcMember, aliceDoppelgangerRtcMember], @@ -846,50 +880,46 @@ test("should disambiguate users with the same displayname", () => { new Map(), mockMediaDevices({}), (vm) => { - // Skip the null state. - expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( - expectedLayoutMarbles, - { - // Carol has no displayname - So userId is used. - a: new Map([[carolId, carol.userId]]), - b: new Map([ - [carolId, carol.userId], - [aliceId, alice.rawDisplayName], - ]), - // The second alice joins. - c: new Map([ - [carolId, carol.userId], - [aliceId, "Alice (@alice:example.org)"], - [aliceDoppelgangerId, "Alice (@alice2:example.org)"], - ]), - // Bob also joins - d: new Map([ - [carolId, carol.userId], - [aliceId, "Alice (@alice:example.org)"], - [aliceDoppelgangerId, "Alice (@alice2:example.org)"], - [bobId, bob.rawDisplayName], - ]), - // Alice leaves, and the displayname should reset. - e: new Map([ - [carolId, carol.userId], - [aliceDoppelgangerId, "Alice"], - [bobId, bob.rawDisplayName], - ]), - }, - ); + expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { + // Carol has no displayname - So userId is used. + a: new Map([[carolId, carol.userId]]), + b: new Map([ + [carolId, carol.userId], + [aliceId, alice.rawDisplayName], + ]), + // The second alice joins. + c: new Map([ + [carolId, carol.userId], + [aliceId, "Alice (@alice:example.org)"], + [aliceDoppelgangerId, "Alice (@alice2:example.org)"], + ]), + // Bob also joins + d: new Map([ + [carolId, carol.userId], + [aliceId, "Alice (@alice:example.org)"], + [aliceDoppelgangerId, "Alice (@alice2:example.org)"], + [bobId, bob.rawDisplayName], + ]), + // Alice leaves, and the displayname should reset. + e: new Map([ + [carolId, carol.userId], + [aliceDoppelgangerId, "Alice"], + [bobId, bob.rawDisplayName], + ]), + }); }, ); }); }); test("should disambiguate users with invisible characters", () => { - withTestScheduler(({ hot, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const scenarioInputMarbles = "ab"; const expectedLayoutMarbles = "ab"; withCallViewModel( - of([]), - hot(scenarioInputMarbles, { + constant([]), + behavior(scenarioInputMarbles, { a: [], b: [bobRtcMember, bobZeroWidthSpaceRtcMember], }), @@ -897,36 +927,32 @@ test("should disambiguate users with invisible characters", () => { new Map(), mockMediaDevices({}), (vm) => { - // Skip the null state. - expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( - expectedLayoutMarbles, - { - // Carol has no displayname - So userId is used. - a: new Map([[carolId, carol.userId]]), - // Both Bobs join, and should handle zero width hacks. - b: new Map([ - [carolId, carol.userId], - [bobId, `Bob (${bob.userId})`], - [ - bobZeroWidthSpaceId, - `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`, - ], - ]), - }, - ); + expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { + // Carol has no displayname - So userId is used. + a: new Map([[carolId, carol.userId]]), + // Both Bobs join, and should handle zero width hacks. + b: new Map([ + [carolId, carol.userId], + [bobId, `Bob (${bob.userId})`], + [ + bobZeroWidthSpaceId, + `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`, + ], + ]), + }); }, ); }); }); test("should strip RTL characters from displayname", () => { - withTestScheduler(({ hot, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const scenarioInputMarbles = "ab"; const expectedLayoutMarbles = "ab"; withCallViewModel( - of([]), - hot(scenarioInputMarbles, { + constant([]), + behavior(scenarioInputMarbles, { a: [], b: [daveRtcMember, daveRTLRtcMember], }), @@ -934,22 +960,18 @@ test("should strip RTL characters from displayname", () => { new Map(), mockMediaDevices({}), (vm) => { - // Skip the null state. - expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( - expectedLayoutMarbles, - { - // Carol has no displayname - So userId is used. - a: new Map([[carolId, carol.userId]]), - // Both Dave's join. Since after stripping - b: new Map([ - [carolId, carol.userId], - // Not disambiguated - [daveId, "Dave"], - // This one is, since it's using RTL. - [daveRTLId, `evaD (${daveRTL.userId})`], - ]), - }, - ); + expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { + // Carol has no displayname - So userId is used. + a: new Map([[carolId, carol.userId]]), + // Both Dave's join. Since after stripping + b: new Map([ + [carolId, carol.userId], + // Not disambiguated + [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"; withCallViewModel( - of([aliceParticipant, bobParticipant]), - of([aliceRtcMember, bobRtcMember]), + constant([aliceParticipant, bobParticipant]), + constant([aliceRtcMember, bobRtcMember]), of(ConnectionState.Connected), new Map(), mockMediaDevices({}), @@ -1036,8 +1058,8 @@ test("audio output changes when toggling earpiece mode", () => { const expectedTargetStateMarbles = " sese"; withCallViewModel( - of([]), - of([]), + constant([]), + constant([]), of(ConnectionState.Connected), new Map(), devices, diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 5b35c1bb..bd2a7607 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -94,6 +94,7 @@ import { observeSpeaker$ } from "./observeSpeaker"; import { shallowEquals } from "../utils/array"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; +import { type Behavior } from "./Behavior"; // How long we wait after a focus switch before showing the real participant // list again @@ -250,8 +251,8 @@ class UserMedia { LocalParticipant | RemoteParticipant | undefined >; - public readonly speaker$: Observable; - public readonly presenter$: Observable; + public readonly speaker$: Behavior; + public readonly presenter$: Behavior; public constructor( public readonly id: string, member: RoomMember | undefined, @@ -268,12 +269,12 @@ class UserMedia { this.vm = new LocalUserMediaViewModel( this.id, member, - this.participant$.asObservable() as Observable, + this.participant$ as Behavior, encryptionSystem, livekitRoom, - displayname$, - handRaised$, - reaction$, + this.scope.behavior(displayname$), + this.scope.behavior(handRaised$), + this.scope.behavior(reaction$), ); } else { this.vm = new RemoteUserMediaViewModel( @@ -284,28 +285,29 @@ class UserMedia { >, encryptionSystem, livekitRoom, - displayname$, - handRaised$, - reaction$, + this.scope.behavior(displayname$), + this.scope.behavior(handRaised$), + this.scope.behavior(reaction$), ); } - this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); + this.speaker$ = this.scope.behavior(observeSpeaker$(this.vm.speaking$)); - this.presenter$ = this.participant$.pipe( - switchMap( - (p) => - (p && - observeParticipantEvents( - p, - ParticipantEvent.TrackPublished, - ParticipantEvent.TrackUnpublished, - ParticipantEvent.LocalTrackPublished, - ParticipantEvent.LocalTrackUnpublished, - ).pipe(map((p) => p.isScreenShareEnabled))) ?? - of(false), + this.presenter$ = this.scope.behavior( + this.participant$.pipe( + switchMap( + (p) => + (p && + observeParticipantEvents( + p, + ParticipantEvent.TrackPublished, + ParticipantEvent.TrackUnpublished, + ParticipantEvent.LocalTrackPublished, + ParticipantEvent.LocalTrackUnpublished, + ).pipe(map((p) => p.isScreenShareEnabled))) ?? + of(false), + ), ), - this.scope.state(), ); } @@ -325,6 +327,7 @@ class UserMedia { } class ScreenShare { + private readonly scope = new ObservableScope(); public readonly vm: ScreenShareViewModel; private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant @@ -336,7 +339,7 @@ class ScreenShare { participant: LocalParticipant | RemoteParticipant, encryptionSystem: EncryptionSystem, liveKitRoom: LivekitRoom, - displayname$: Observable, + displayName$: Observable, ) { this.participant$ = new BehaviorSubject(participant); @@ -346,12 +349,13 @@ class ScreenShare { this.participant$.asObservable(), encryptionSystem, liveKitRoom, - displayname$, + this.scope.behavior(displayName$), participant.isLocal, ); } public destroy(): void { + this.scope.end(); this.vm.destroy(); } } @@ -382,28 +386,32 @@ function getRoomMemberFromRtcMember( // TODO: Move wayyyy more business logic from the call and lobby views into here export class CallViewModel extends ViewModel { - public readonly localVideo$: Observable = + public readonly localVideo$ = this.scope.behavior( observeTrackReference$( - of(this.livekitRoom.localParticipant), + this.livekitRoom.localParticipant, Track.Source.Camera, ).pipe( map((trackRef) => { const track = trackRef?.publication?.track; return track instanceof LocalVideoTrack ? track : null; }), - ); + ), + ); /** * The raw list of RemoteParticipants as reported by LiveKit */ - private readonly rawRemoteParticipants$: Observable = - connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); + private readonly rawRemoteParticipants$ = this.scope.behavior< + RemoteParticipant[] + >(connectedParticipantsObserver(this.livekitRoom), []); /** * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * they've left */ - private readonly remoteParticipantHolds$: Observable = + private readonly remoteParticipantHolds$ = this.scope.behavior< + RemoteParticipant[][] + >( this.connectionState$.pipe( withLatestFrom(this.rawRemoteParticipants$), mergeMap(([s, ps]) => { @@ -435,12 +443,15 @@ export class CallViewModel extends ViewModel { ? [instruction.hold, ...holds] : holds.filter((h) => h !== instruction.unhold), ), - ); + ), + ); /** * The RemoteParticipants including those that are being "held" on the screen */ - private readonly remoteParticipants$: Observable = + private readonly remoteParticipants$ = this.scope.behavior< + RemoteParticipant[] + >( combineLatest( [this.rawRemoteParticipants$, this.remoteParticipantHolds$], (raw, holds) => { @@ -459,145 +470,146 @@ export class CallViewModel extends ViewModel { return result; }, - ); + ), + ); /** * Displaynames for each member of the call. This will disambiguate * any displaynames that clashes with another member. Only members * joined to the call are considered here. */ - public readonly memberDisplaynames$ = merge( - // Handle call membership changes. - fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), - // Handle room membership changes (and displayname updates) - fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), - ).pipe( - startWith(null), - map(() => { - const displaynameMap = new Map(); - const { room, memberships } = this.matrixRTCSession; + public readonly memberDisplaynames$ = this.scope.behavior( + merge( + // Handle call membership changes. + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.MembershipsChanged, + ), + // Handle room membership changes (and displayname updates) + fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), + ).pipe( + startWith(null), + map(() => { + const displaynameMap = new Map(); + const { room, memberships } = this.matrixRTCSession; - // We only consider RTC members for disambiguation as they are the only visible members. - for (const rtcMember of memberships) { - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - const { member } = getRoomMemberFromRtcMember(rtcMember, room); - if (!member) { - logger.error("Could not find member for media id:", matrixIdentifier); - continue; + // We only consider RTC members for disambiguation as they are the only visible members. + for (const rtcMember of memberships) { + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + const { member } = getRoomMemberFromRtcMember(rtcMember, room); + if (!member) { + logger.error( + "Could not find member for media id:", + matrixIdentifier, + ); + continue; + } + const disambiguate = shouldDisambiguate(member, memberships, room); + displaynameMap.set( + matrixIdentifier, + calculateDisplayName(member, disambiguate), + ); } - const disambiguate = shouldDisambiguate(member, memberships, room); - displaynameMap.set( - matrixIdentifier, - calculateDisplayName(member, disambiguate), - ); - } - return displaynameMap; - }), - // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower - // than on Chrome/Firefox). This means it is important that we share() the result so that we - // don't do this work more times than we need to. This is achieve through the state() operator: - this.scope.state(), + return displaynameMap; + }), + // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower + // than on Chrome/Firefox). This means it is important that we multicast the result so that we + // don't do this work more times than we need to. This is achieved by converting to a behavior: + ), + ); + + public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$); + + public readonly reactions$ = this.scope.behavior( + this.reactionsSubject$.pipe( + map((v) => + Object.fromEntries( + Object.entries(v).map(([a, { reactionOption }]) => [ + a, + reactionOption, + ]), + ), + ), + ), ); /** * List of MediaItems that we want to display */ - private readonly mediaItems$: Observable = combineLatest([ - this.remoteParticipants$, - observeParticipantMedia(this.livekitRoom.localParticipant), - duplicateTiles.value$, - // Also react to changes in the MatrixRTC session list. - // The session list will also be update if a room membership changes. - // No additional RoomState event listener needs to be set up. - fromEvent( - this.matrixRTCSession, - MatrixRTCSessionEvent.MembershipsChanged, - ).pipe(startWith(null)), - showNonMemberTiles.value$, - ]).pipe( - scan( - ( - prevItems, - [ - remoteParticipants, - { participant: localParticipant }, - duplicateTiles, - _membershipsChanged, - showNonMemberTiles, - ], - ) => { - const newItems = new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - const room = this.matrixRTCSession.room; - // m.rtc.members are the basis for calculating what is visible in the call - for (const rtcMember of this.matrixRTCSession.memberships) { - const { member, id: livekitParticipantId } = - getRoomMemberFromRtcMember(rtcMember, room); - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + private readonly mediaItems$ = this.scope.behavior( + combineLatest([ + this.remoteParticipants$, + observeParticipantMedia(this.livekitRoom.localParticipant), + duplicateTiles.value$, + // Also react to changes in the MatrixRTC session list. + // The session list will also be update if a room membership changes. + // No additional RoomState event listener needs to be set up. + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.MembershipsChanged, + ).pipe(startWith(null)), + showNonMemberTiles.value$, + ]).pipe( + scan( + ( + prevItems, + [ + remoteParticipants, + { participant: localParticipant }, + duplicateTiles, + _membershipsChanged, + showNonMemberTiles, + ], + ) => { + const newItems = new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + const room = this.matrixRTCSession.room; + // m.rtc.members are the basis for calculating what is visible in the call + for (const rtcMember of this.matrixRTCSession.memberships) { + const { member, id: livekitParticipantId } = + getRoomMemberFromRtcMember(rtcMember, room); + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - let participant: - | LocalParticipant - | RemoteParticipant - | undefined = undefined; - if (livekitParticipantId === "local") { - participant = localParticipant; - } else { - participant = remoteParticipants.find( - (p) => p.identity === livekitParticipantId, - ); - } - - if (!member) { - logger.error( - "Could not find member for media id: ", - livekitParticipantId, - ); - } - for (let i = 0; i < 1 + duplicateTiles; i++) { - const indexedMediaId = `${livekitParticipantId}:${i}`; - let prevMedia = prevItems.get(indexedMediaId); - if (prevMedia && prevMedia instanceof UserMedia) { - prevMedia.updateParticipant(participant); - if (prevMedia.vm.member === undefined) { - // We have a previous media created because of the `debugShowNonMember` flag. - // In this case we actually replace the media item. - // This "hack" never occurs if we do not use the `debugShowNonMember` debugging - // option and if we always find a room member for each rtc member (which also - // only fails if we have a fundamental problem) - prevMedia = undefined; - } + let participant: + | LocalParticipant + | RemoteParticipant + | undefined = undefined; + if (livekitParticipantId === "local") { + participant = localParticipant; + } else { + participant = remoteParticipants.find( + (p) => p.identity === livekitParticipantId, + ); } - yield [ - indexedMediaId, - // We create UserMedia with or without a participant. - // This will be the initial value of a BehaviourSubject. - // Once a participant appears we will update the BehaviourSubject. (see above) - prevMedia ?? - new UserMedia( - indexedMediaId, - member, - participant, - this.encryptionSystem, - this.livekitRoom, - this.memberDisplaynames$.pipe( - map((m) => m.get(matrixIdentifier) ?? "[👻]"), - ), - this.handsRaised$.pipe( - map((v) => v[matrixIdentifier]?.time ?? null), - ), - this.reactions$.pipe( - map((v) => v[matrixIdentifier] ?? undefined), - ), - ), - ]; - if (participant?.isScreenShareEnabled) { - const screenShareId = `${indexedMediaId}:screen-share`; + if (!member) { + logger.error( + "Could not find member for media id: ", + livekitParticipantId, + ); + } + for (let i = 0; i < 1 + duplicateTiles; i++) { + const indexedMediaId = `${livekitParticipantId}:${i}`; + let prevMedia = prevItems.get(indexedMediaId); + if (prevMedia && prevMedia instanceof UserMedia) { + prevMedia.updateParticipant(participant); + if (prevMedia.vm.member === undefined) { + // We have a previous media created because of the `debugShowNonMember` flag. + // In this case we actually replace the media item. + // This "hack" never occurs if we do not use the `debugShowNonMember` debugging + // option and if we always find a room member for each rtc member (which also + // only fails if we have a fundamental problem) + prevMedia = undefined; + } + } yield [ - screenShareId, - prevItems.get(screenShareId) ?? - new ScreenShare( - screenShareId, + indexedMediaId, + // We create UserMedia with or without a participant. + // This will be the initial value of a BehaviourSubject. + // Once a participant appears we will update the BehaviourSubject. (see above) + prevMedia ?? + new UserMedia( + indexedMediaId, member, participant, this.encryptionSystem, @@ -605,84 +617,112 @@ export class CallViewModel extends ViewModel { this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), ), + this.handsRaised$.pipe( + map((v) => v[matrixIdentifier]?.time ?? null), + ), + this.reactions$.pipe( + map((v) => v[matrixIdentifier] ?? undefined), + ), ), ]; - } - } - } - }.bind(this)(), - ); - // Generate non member items (items without a corresponding MatrixRTC member) - // Those items should not be rendered, they are participants in LiveKit that do not have a corresponding - // MatrixRTC members. This cannot be any good: - // - A malicious user impersonates someone - // - Someone injects abusive content - // - The user cannot have encryption keys so it makes no sense to participate - // We can only trust users that have a MatrixRTC member event. - // - // This is still available as a debug option. This can be useful - // - If one wants to test scalability using the LiveKit CLI. - // - If an experimental project does not yet do the MatrixRTC bits. - // - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive. - const newNonMemberItems = showNonMemberTiles - ? new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - for (const participant of remoteParticipants) { - for (let i = 0; i < 1 + duplicateTiles; i++) { - const maybeNonMemberParticipantId = - participant.identity + ":" + i; - if (!newItems.has(maybeNonMemberParticipantId)) { - const nonMemberId = maybeNonMemberParticipantId; - yield [ - nonMemberId, - prevItems.get(nonMemberId) ?? - new UserMedia( - nonMemberId, - undefined, - participant, - this.encryptionSystem, - this.livekitRoom, - this.memberDisplaynames$.pipe( - map((m) => m.get(participant.identity) ?? "[👻]"), - ), - of(null), - of(null), + if (participant?.isScreenShareEnabled) { + const screenShareId = `${indexedMediaId}:screen-share`; + yield [ + screenShareId, + prevItems.get(screenShareId) ?? + new ScreenShare( + screenShareId, + member, + participant, + this.encryptionSystem, + this.livekitRoom, + this.memberDisplaynames$.pipe( + map((m) => m.get(matrixIdentifier) ?? "[👻]"), ), - ]; - } + ), + ]; } } - }.bind(this)(), - ) - : new Map(); - if (newNonMemberItems.size > 0) { - logger.debug("Added NonMember items: ", newNonMemberItems); - } + } + }.bind(this)(), + ); - const combinedNew = new Map([ - ...newNonMemberItems.entries(), - ...newItems.entries(), - ]); + // Generate non member items (items without a corresponding MatrixRTC member) + // Those items should not be rendered, they are participants in LiveKit that do not have a corresponding + // MatrixRTC members. This cannot be any good: + // - A malicious user impersonates someone + // - Someone injects abusive content + // - The user cannot have encryption keys so it makes no sense to participate + // We can only trust users that have a MatrixRTC member event. + // + // This is still available as a debug option. This can be useful + // - If one wants to test scalability using the LiveKit CLI. + // - If an experimental project does not yet do the MatrixRTC bits. + // - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive. + const newNonMemberItems = showNonMemberTiles + ? new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + for (const participant of remoteParticipants) { + for (let i = 0; i < 1 + duplicateTiles; i++) { + const maybeNonMemberParticipantId = + participant.identity + ":" + i; + if (!newItems.has(maybeNonMemberParticipantId)) { + const nonMemberId = maybeNonMemberParticipantId; + yield [ + nonMemberId, + prevItems.get(nonMemberId) ?? + new UserMedia( + nonMemberId, + undefined, + participant, + this.encryptionSystem, + this.livekitRoom, + this.memberDisplaynames$.pipe( + map( + (m) => m.get(participant.identity) ?? "[👻]", + ), + ), + of(null), + of(null), + ), + ]; + } + } + } + }.bind(this)(), + ) + : new Map(); + if (newNonMemberItems.size > 0) { + logger.debug("Added NonMember items: ", newNonMemberItems); + } - for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); - return combinedNew; - }, - new Map(), + const combinedNew = new Map([ + ...newNonMemberItems.entries(), + ...newItems.entries(), + ]); + + for (const [id, t] of prevItems) + if (!combinedNew.has(id)) t.destroy(); + return combinedNew; + }, + new Map(), + ), + map((mediaItems) => [...mediaItems.values()]), + finalizeValue((ts) => { + for (const t of ts) t.destroy(); + }), ), - map((mediaItems) => [...mediaItems.values()]), - finalizeValue((ts) => { - for (const t of ts) t.destroy(); - }), - this.scope.state(), ); /** * List of MediaItems that we want to display, that are of type UserMedia */ - private readonly userMedia$: Observable = this.mediaItems$.pipe( - map((mediaItems) => - mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), + private readonly userMedia$ = this.scope.behavior( + this.mediaItems$.pipe( + map((mediaItems) => + mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), + ), ), ); @@ -702,51 +742,52 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type ScreenShare */ - private readonly screenShares$: Observable = + private readonly screenShares$ = this.scope.behavior( this.mediaItems$.pipe( map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), - this.scope.state(), - ); + ), + ); - private readonly spotlightSpeaker$: Observable = - this.userMedia$.pipe( - switchMap((mediaItems) => - mediaItems.length === 0 - ? of([]) - : combineLatest( - mediaItems.map((m) => - m.vm.speaking$.pipe(map((s) => [m, s] as const)), + private readonly spotlightSpeaker$ = + this.scope.behavior( + this.userMedia$.pipe( + switchMap((mediaItems) => + mediaItems.length === 0 + ? of([]) + : combineLatest( + mediaItems.map((m) => + m.vm.speaking$.pipe(map((s) => [m, s] as const)), + ), ), - ), + ), + scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>( + (prev, mediaItems) => { + // Only remote users that are still in the call should be sticky + const [stickyMedia, stickySpeaking] = + (!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || []; + // Decide who to spotlight: + // If the previous speaker is still speaking, stick with them rather + // than switching eagerly to someone else + return stickySpeaking + ? stickyMedia! + : // Otherwise, select any remote user who is speaking + (mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ?? + // Otherwise, stick with the person who was last speaking + stickyMedia ?? + // Otherwise, spotlight an arbitrary remote user + mediaItems.find(([m]) => !m.vm.local)?.[0] ?? + // Otherwise, spotlight the local user + mediaItems.find(([m]) => m.vm.local)?.[0]); + }, + null, + ), + map((speaker) => speaker?.vm ?? null), ), - scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>( - (prev, mediaItems) => { - // Only remote users that are still in the call should be sticky - const [stickyMedia, stickySpeaking] = - (!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || []; - // Decide who to spotlight: - // If the previous speaker is still speaking, stick with them rather - // than switching eagerly to someone else - return stickySpeaking - ? stickyMedia! - : // Otherwise, select any remote user who is speaking - (mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ?? - // Otherwise, stick with the person who was last speaking - stickyMedia ?? - // Otherwise, spotlight an arbitrary remote user - mediaItems.find(([m]) => !m.vm.local)?.[0] ?? - // Otherwise, spotlight the local user - mediaItems.find(([m]) => m.vm.local)?.[0]); - }, - null, - ), - map((speaker) => speaker?.vm ?? null), - this.scope.state(), ); - private readonly grid$: Observable = + private readonly grid$ = this.scope.behavior( this.userMedia$.pipe( switchMap((mediaItems) => { const bins = mediaItems.map((m) => @@ -784,10 +825,10 @@ export class CallViewModel extends ViewModel { ); }), distinctUntilChanged(shallowEquals), - this.scope.state(), - ); + ), + ); - private readonly spotlight$: Observable = + private readonly spotlight$ = this.scope.behavior( this.screenShares$.pipe( switchMap((screenShares) => { if (screenShares.length > 0) { @@ -799,44 +840,45 @@ export class CallViewModel extends ViewModel { ); }), distinctUntilChanged(shallowEquals), - this.scope.state(), - ); + ), + ); - private readonly pip$: Observable = combineLatest([ - this.screenShares$, - this.spotlightSpeaker$, - this.mediaItems$, - ]).pipe( - switchMap(([screenShares, spotlight, mediaItems]) => { - if (screenShares.length > 0) { - return this.spotlightSpeaker$; - } - if (!spotlight || spotlight.local) { - return of(null); - } + private readonly pip$ = this.scope.behavior( + combineLatest([ + this.screenShares$, + this.spotlightSpeaker$, + this.mediaItems$, + ]).pipe( + switchMap(([screenShares, spotlight, mediaItems]) => { + if (screenShares.length > 0) { + return this.spotlightSpeaker$; + } + if (!spotlight || spotlight.local) { + return of(null); + } - const localUserMedia = mediaItems.find( - (m) => m.vm instanceof LocalUserMediaViewModel, - ) as UserMedia | undefined; + const localUserMedia = mediaItems.find( + (m) => m.vm instanceof LocalUserMediaViewModel, + ) as UserMedia | undefined; - const localUserMediaViewModel = localUserMedia?.vm as - | LocalUserMediaViewModel - | undefined; + const localUserMediaViewModel = localUserMedia?.vm as + | LocalUserMediaViewModel + | undefined; - if (!localUserMediaViewModel) { - return of(null); - } - return localUserMediaViewModel.alwaysShow$.pipe( - map((alwaysShow) => { - if (alwaysShow) { - return localUserMediaViewModel; - } + if (!localUserMediaViewModel) { + return of(null); + } + return localUserMediaViewModel.alwaysShow$.pipe( + map((alwaysShow) => { + if (alwaysShow) { + return localUserMediaViewModel; + } - return null; - }), - ); - }), - this.scope.state(), + return null; + }), + ); + }), + ), ); private readonly hasRemoteScreenShares$: Observable = @@ -847,67 +889,71 @@ export class CallViewModel extends ViewModel { distinctUntilChanged(), ); - private readonly pipEnabled$: Observable = setPipEnabled$.pipe( - startWith(false), - ); + private readonly pipEnabled$ = this.scope.behavior(setPipEnabled$, false); - private readonly naturalWindowMode$: Observable = fromEvent( - window, - "resize", - ).pipe( - startWith(null), - map(() => { - const height = window.innerHeight; - const width = window.innerWidth; - if (height <= 400 && width <= 340) return "pip"; - // Our layouts for flat windows are better at adapting to a small width - // than our layouts for narrow windows are at adapting to a small height, - // so we give "flat" precedence here - if (height <= 600) return "flat"; - if (width <= 600) return "narrow"; - return "normal"; - }), - this.scope.state(), + private readonly naturalWindowMode$ = this.scope.behavior( + fromEvent(window, "resize").pipe( + startWith(null), + map(() => { + const height = window.innerHeight; + const width = window.innerWidth; + if (height <= 400 && width <= 340) return "pip"; + // Our layouts for flat windows are better at adapting to a small width + // than our layouts for narrow windows are at adapting to a small height, + // so we give "flat" precedence here + if (height <= 600) return "flat"; + if (width <= 600) return "narrow"; + return "normal"; + }), + ), ); /** * The general shape of the window. */ - public readonly windowMode$: Observable = this.pipEnabled$.pipe( - switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode$)), + public readonly windowMode$ = this.scope.behavior( + this.pipEnabled$.pipe( + switchMap((pip) => + pip ? of("pip") : this.naturalWindowMode$, + ), + ), ); private readonly spotlightExpandedToggle$ = new Subject(); - public readonly spotlightExpanded$: Observable = + public readonly spotlightExpanded$ = this.scope.behavior( this.spotlightExpandedToggle$.pipe( accumulate(false, (expanded) => !expanded), - this.scope.state(), - ); + ), + ); private readonly gridModeUserSelection$ = new Subject(); /** * The layout mode of the media tile grid. */ - public readonly gridMode$: Observable = + public readonly gridMode$ = // If the user hasn't selected spotlight and somebody starts screen sharing, // automatically switch to spotlight mode and reset when screen sharing ends - this.gridModeUserSelection$.pipe( - startWith(null), - switchMap((userSelection) => - (userSelection === "spotlight" - ? EMPTY - : combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( - skip(userSelection === null ? 0 : 1), - map( - ([hasScreenShares, windowMode]): GridMode => - hasScreenShares || windowMode === "flat" - ? "spotlight" - : "grid", - ), - ) - ).pipe(startWith(userSelection ?? "grid")), + this.scope.behavior( + this.gridModeUserSelection$.pipe( + startWith(null), + switchMap((userSelection) => + (userSelection === "spotlight" + ? EMPTY + : combineLatest([ + this.hasRemoteScreenShares$, + this.windowMode$, + ]).pipe( + skip(userSelection === null ? 0 : 1), + map( + ([hasScreenShares, windowMode]): GridMode => + hasScreenShares || windowMode === "flat" + ? "spotlight" + : "grid", + ), + ) + ).pipe(startWith(userSelection ?? "grid")), + ), ), - this.scope.state(), ); public setGridMode(value: GridMode): void { @@ -969,7 +1015,7 @@ export class CallViewModel extends ViewModel { /** * The media to be used to produce a layout. */ - private readonly layoutMedia$: Observable = + private readonly layoutMedia$ = this.scope.behavior( this.windowMode$.pipe( switchMap((windowMode) => { switch (windowMode) { @@ -1032,8 +1078,8 @@ export class CallViewModel extends ViewModel { return this.pipLayoutMedia$; } }), - this.scope.state(), - ); + ), + ); // There is a cyclical dependency here: the layout algorithms want to know // which tiles are on screen, but to know which tiles are on screen we have to @@ -1043,99 +1089,99 @@ export class CallViewModel extends ViewModel { private readonly setVisibleTiles = (value: number): void => this.visibleTiles$.next(value); - public readonly layoutInternals$: Observable< + private readonly layoutInternals$ = this.scope.behavior< LayoutScanState & { layout: Layout } - > = combineLatest([ - this.layoutMedia$, - this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), - ]).pipe( - scan< - [LayoutMedia, number], - LayoutScanState & { layout: Layout }, - LayoutScanState - >( - ({ tiles: prevTiles }, [media, visibleTiles]) => { - let layout: Layout; - let newTiles: TileStore; - switch (media.type) { - case "grid": - case "spotlight-landscape": - case "spotlight-portrait": - [layout, newTiles] = gridLikeLayout( - media, - visibleTiles, - this.setVisibleTiles, - prevTiles, - ); - break; - case "spotlight-expanded": - [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); - break; - case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, prevTiles); - break; - case "pip": - [layout, newTiles] = pipLayout(media, prevTiles); - break; - } + >( + combineLatest([ + this.layoutMedia$, + this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), + ]).pipe( + scan< + [LayoutMedia, number], + LayoutScanState & { layout: Layout }, + LayoutScanState + >( + ({ tiles: prevTiles }, [media, visibleTiles]) => { + let layout: Layout; + let newTiles: TileStore; + switch (media.type) { + case "grid": + case "spotlight-landscape": + case "spotlight-portrait": + [layout, newTiles] = gridLikeLayout( + media, + visibleTiles, + this.setVisibleTiles, + prevTiles, + ); + break; + case "spotlight-expanded": + [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); + break; + case "one-on-one": + [layout, newTiles] = oneOnOneLayout(media, prevTiles); + break; + case "pip": + [layout, newTiles] = pipLayout(media, prevTiles); + break; + } - return { layout, tiles: newTiles }; - }, - { layout: null, tiles: TileStore.empty() }, + return { layout, tiles: newTiles }; + }, + { layout: null, tiles: TileStore.empty() }, + ), ), - this.scope.state(), ); /** * The layout of tiles in the call interface. */ - public readonly layout$: Observable = this.layoutInternals$.pipe( - map(({ layout }) => layout), - this.scope.state(), + public readonly layout$ = this.scope.behavior( + this.layoutInternals$.pipe(map(({ layout }) => layout)), ); /** * The current generation of the tile store, exposed for debugging purposes. */ - public readonly tileStoreGeneration$: Observable = - this.layoutInternals$.pipe( - map(({ tiles }) => tiles.generation), - this.scope.state(), - ); - - public showSpotlightIndicators$: Observable = this.layout$.pipe( - map((l) => l.type !== "grid"), - this.scope.state(), + public readonly tileStoreGeneration$ = this.scope.behavior( + this.layoutInternals$.pipe(map(({ tiles }) => tiles.generation)), ); - public showSpeakingIndicators$: Observable = this.layout$.pipe( - switchMap((l) => { - switch (l.type) { - case "spotlight-landscape": - case "spotlight-portrait": - // If the spotlight is showing the active speaker, we can do without - // speaking indicators as they're a redundant visual cue. But if - // screen sharing feeds are in the spotlight we still need them. - return l.spotlight.media$.pipe( - map((models: MediaViewModel[]) => - models.some((m) => m instanceof ScreenShareViewModel), - ), - ); - // In expanded spotlight layout, the active speaker is always shown in - // the picture-in-picture tile so there is no need for speaking - // indicators. And in one-on-one layout there's no question as to who is - // speaking. - case "spotlight-expanded": - case "one-on-one": - return of(false); - default: - return of(true); - } - }), - this.scope.state(), + public showSpotlightIndicators$ = this.scope.behavior( + this.layout$.pipe(map((l) => l.type !== "grid")), ); - public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = + public showSpeakingIndicators$ = this.scope.behavior( + this.layout$.pipe( + switchMap((l) => { + switch (l.type) { + case "spotlight-landscape": + case "spotlight-portrait": + // If the spotlight is showing the active speaker, we can do without + // speaking indicators as they're a redundant visual cue. But if + // screen sharing feeds are in the spotlight we still need them. + return l.spotlight.media$.pipe( + map((models: MediaViewModel[]) => + models.some((m) => m instanceof ScreenShareViewModel), + ), + ); + // In expanded spotlight layout, the active speaker is always shown in + // the picture-in-picture tile so there is no need for speaking + // indicators. And in one-on-one layout there's no question as to who is + // speaking. + case "spotlight-expanded": + case "one-on-one": + return of(false); + default: + return of(true); + } + }), + ), + ); + + public readonly toggleSpotlightExpanded$ = this.scope.behavior< + (() => void) | null + >( this.windowMode$.pipe( switchMap((mode) => mode === "normal" @@ -1152,8 +1198,8 @@ export class CallViewModel extends ViewModel { map((enabled) => enabled ? (): void => this.spotlightExpandedToggle$.next() : null, ), - this.scope.state(), - ); + ), + ); private readonly screenTap$ = new Subject(); private readonly controlsTap$ = new Subject(); @@ -1188,76 +1234,79 @@ export class CallViewModel extends ViewModel { this.screenUnhover$.next(); } - public readonly showHeader$: Observable = this.windowMode$.pipe( - map((mode) => mode !== "pip" && mode !== "flat"), - this.scope.state(), + public readonly showHeader$ = this.scope.behavior( + this.windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")), ); - public readonly showFooter$: Observable = this.windowMode$.pipe( - switchMap((mode) => { - switch (mode) { - case "pip": - return of(false); - case "normal": - case "narrow": - return of(true); - case "flat": - // Sadly Firefox has some layering glitches that prevent the footer - // from appearing properly. They happen less often if we never hide - // the footer. - if (isFirefox()) return of(true); - // Show/hide the footer in response to interactions - return merge( - this.screenTap$.pipe(map(() => "tap screen" as const)), - this.controlsTap$.pipe(map(() => "tap controls" as const)), - this.screenHover$.pipe(map(() => "hover" as const)), - ).pipe( - switchScan((state, interaction) => { - switch (interaction) { - case "tap screen": - return state - ? // Toggle visibility on tap - of(false) - : // Hide after a timeout - timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "tap controls": - // The user is interacting with things, so reset the timeout - return timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "hover": - // Show on hover and hide after a timeout - return race( - timer(showFooterMs), - this.screenUnhover$.pipe(take(1)), - ).pipe( - map(() => false), - startWith(true), - ); - } - }, false), - startWith(false), - ); - } - }), - this.scope.state(), + public readonly showFooter$ = this.scope.behavior( + this.windowMode$.pipe( + switchMap((mode) => { + switch (mode) { + case "pip": + return of(false); + case "normal": + case "narrow": + return of(true); + case "flat": + // Sadly Firefox has some layering glitches that prevent the footer + // from appearing properly. They happen less often if we never hide + // the footer. + if (isFirefox()) return of(true); + // Show/hide the footer in response to interactions + return merge( + this.screenTap$.pipe(map(() => "tap screen" as const)), + this.controlsTap$.pipe(map(() => "tap controls" as const)), + this.screenHover$.pipe(map(() => "hover" as const)), + ).pipe( + switchScan((state, interaction) => { + switch (interaction) { + case "tap screen": + return state + ? // Toggle visibility on tap + of(false) + : // Hide after a timeout + timer(showFooterMs).pipe( + map(() => false), + startWith(true), + ); + case "tap controls": + // The user is interacting with things, so reset the timeout + return timer(showFooterMs).pipe( + map(() => false), + startWith(true), + ); + case "hover": + // Show on hover and hide after a timeout + return race( + timer(showFooterMs), + this.screenUnhover$.pipe(take(1)), + ).pipe( + map(() => false), + startWith(true), + ); + } + }, false), + startWith(false), + ); + } + }), + ), ); /** * Whether audio is currently being output through the earpiece. */ - public readonly earpieceMode$: Observable = combineLatest( - [ - this.mediaDevices.audioOutput.available$, - this.mediaDevices.audioOutput.selected$, - ], - (available, selected) => - selected !== undefined && available.get(selected.id)?.type === "earpiece", - ).pipe(this.scope.state()); + public readonly earpieceMode$ = this.scope.behavior( + combineLatest( + [ + this.mediaDevices.audioOutput.available$, + this.mediaDevices.audioOutput.selected$, + ], + (available, selected) => + selected !== undefined && + available.get(selected.id)?.type === "earpiece", + ), + ); /** * Callback to toggle between the earpiece and the loudspeaker. @@ -1265,61 +1314,55 @@ export class CallViewModel extends ViewModel { * This will be `null` in case the target does not exist in the list * of available audio outputs. */ - public readonly audioOutputSwitcher$: Observable<{ + public readonly audioOutputSwitcher$ = this.scope.behavior<{ targetOutput: "earpiece" | "speaker"; switch: () => void; - } | null> = combineLatest( - [ - this.mediaDevices.audioOutput.available$, - this.mediaDevices.audioOutput.selected$, - ], - (available, selected) => { - const selectionType = selected && available.get(selected.id)?.type; + } | null>( + combineLatest( + [ + this.mediaDevices.audioOutput.available$, + this.mediaDevices.audioOutput.selected$, + ], + (available, selected) => { + const selectionType = selected && available.get(selected.id)?.type; - // If we are in any output mode other than speaker switch to speaker. - const newSelectionType = - selectionType === "speaker" ? "earpiece" : "speaker"; - const newSelection = [...available].find( - ([, d]) => d.type === newSelectionType, - ); - if (newSelection === undefined) return null; + // If we are in any output mode other than speaker switch to speaker. + const newSelectionType: "earpiece" | "speaker" = + selectionType === "speaker" ? "earpiece" : "speaker"; + const newSelection = [...available].find( + ([, d]) => d.type === newSelectionType, + ); + if (newSelection === undefined) return null; - const [id] = newSelection; - return { - targetOutput: newSelectionType, - switch: () => this.mediaDevices.audioOutput.select(id), - }; - }, - ); - - public readonly reactions$ = this.reactionsSubject$.pipe( - map((v) => - Object.fromEntries( - Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]), - ), + const [id] = newSelection; + return { + targetOutput: newSelectionType, + switch: (): void => this.mediaDevices.audioOutput.select(id), + }; + }, ), ); - public readonly handsRaised$ = this.handsRaisedSubject$.pipe(); - /** * Emits an array of reactions that should be visible on the screen. */ - public readonly visibleReactions$ = showReactions.value$.pipe( - switchMap((show) => (show ? this.reactions$ : of({}))), - scan< - Record, - { sender: string; emoji: string; startX: number }[] - >((acc, latest) => { - const newSet: { sender: string; emoji: string; startX: number }[] = []; - for (const [sender, reaction] of Object.entries(latest)) { - const startX = - acc.find((v) => v.sender === sender && v.emoji)?.startX ?? - Math.ceil(Math.random() * 80) + 10; - newSet.push({ sender, emoji: reaction.emoji, startX }); - } - return newSet; - }, []), + public readonly visibleReactions$ = this.scope.behavior( + showReactions.value$.pipe( + switchMap((show) => (show ? this.reactions$ : of({}))), + scan< + Record, + { sender: string; emoji: string; startX: number }[] + >((acc, latest) => { + const newSet: { sender: string; emoji: string; startX: number }[] = []; + for (const [sender, reaction] of Object.entries(latest)) { + const startX = + acc.find((v) => v.sender === sender && v.emoji)?.startX ?? + Math.ceil(Math.random() * 80) + 10; + newSet.push({ sender, emoji: reaction.emoji, startX }); + } + return newSet; + }, []), + ), ); /** diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index bac227a6..3a09bf91 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -10,7 +10,6 @@ import { filter, map, merge, - of, pairwise, startWith, Subject, @@ -34,6 +33,7 @@ import { import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; 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 // the ios swift team. @@ -74,11 +74,11 @@ export interface MediaDevice { /** * A map from available device IDs to labels. */ - available$: Observable>; + available$: Behavior>; /** * The selected device. */ - selected$: Observable; + selected$: Behavior; /** * Selects a new device. */ @@ -94,35 +94,36 @@ export interface MediaDevice { * `availableOutputDevices$.includes((d)=>d.forEarpiece)` */ export const iosDeviceMenu$ = - platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$; + platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$; function availableRawDevices$( kind: MediaDeviceKind, - usingNames$: Observable, + usingNames$: Behavior, scope: ObservableScope, -): Observable { +): Behavior { const logError = (e: Error): void => logger.error("Error creating MediaDeviceObserver", e); const devices$ = createMediaDeviceObserver(kind, logError, false); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); - return usingNames$.pipe( - switchMap((withNames) => - withNames - ? // It might be that there is already a media stream running somewhere, - // and so we can do without requesting a second one. Only switch to the - // device observer that explicitly requests the names if we see that - // names are in fact missing from the initial device enumeration. - devices$.pipe( - switchWhen( - (devices, i) => i === 0 && devices.every((d) => !d.label), - devicesWithNames$, - ), - ) - : devices$, + return scope.behavior( + usingNames$.pipe( + switchMap((withNames) => + withNames + ? // It might be that there is already a media stream running somewhere, + // and so we can do without requesting a second one. Only switch to the + // device observer that explicitly requests the names if we see that + // names are in fact missing from the initial device enumeration. + devices$.pipe( + switchWhen( + (devices, i) => i === 0 && devices.every((d) => !d.label), + devicesWithNames$, + ), + ) + : devices$, + ), ), - startWith([]), - scope.state(), + [], ); } @@ -161,34 +162,33 @@ function selectDevice$