diff --git a/.eslintrc.cjs b/.eslintrc.cjs index db5f3fd9..f338dac7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,6 +44,7 @@ module.exports = { ], // To encourage good usage of RxJS: "rxjs/no-exposed-subjects": "error", + "rxjs/finnish": "error", }, settings: { react: { diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 5124e711..2817f78c 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -415,7 +415,7 @@ export class PosthogAnalytics { // * When the user changes their preferences on this device // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // won't be called (i.e. this.anonymity will be left as the default, until the setting changes) - optInAnalytics.value.subscribe((optIn) => { + optInAnalytics.value$.subscribe((optIn) => { this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled); this.maybeIdentifyUser().catch(() => logger.log("Could not identify user"), diff --git a/src/controls.ts b/src/controls.ts index 2e8ea7d3..fe8cc4de 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -13,18 +13,18 @@ export interface Controls { disablePip: () => void; } -export const setPipEnabled = new Subject(); +export const setPipEnabled$ = new Subject(); window.controls = { canEnterPip(): boolean { - return setPipEnabled.observed; + return setPipEnabled$.observed; }, enablePip(): void { - if (!setPipEnabled.observed) throw new Error("No call is running"); - setPipEnabled.next(true); + if (!setPipEnabled$.observed) throw new Error("No call is running"); + setPipEnabled$.next(true); }, disablePip(): void { - if (!setPipEnabled.observed) throw new Error("No call is running"); - setPipEnabled.next(false); + if (!setPipEnabled$.observed) throw new Error("No call is running"); + setPipEnabled$.next(false); }, }; diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index e05cd025..0e64481a 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -31,15 +31,15 @@ export interface CallLayoutInputs { /** * The minimum bounds of the layout area. */ - minBounds: Observable; + minBounds$: Observable; /** * The alignment of the floating spotlight tile, if present. */ - spotlightAlignment: BehaviorSubject; + spotlightAlignment$: BehaviorSubject; /** * The alignment of the small picture-in-picture tile, if present. */ - pipAlignment: BehaviorSubject; + pipAlignment$: BehaviorSubject; } export interface CallLayoutOutputs { diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index 268d4352..031a73b5 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -156,7 +156,7 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void { ); } -const windowHeightObservable = fromEvent(window, "resize").pipe( +const windowHeightObservable$ = fromEvent(window, "resize").pipe( startWith(null), map(() => window.innerHeight), ); @@ -262,7 +262,7 @@ export function Grid< const [gridRoot, gridRef2] = useState(null); const gridRef = useMergedRefs(gridRef1, gridRef2); - const windowHeight = useObservableEagerState(windowHeightObservable); + const windowHeight = useObservableEagerState(windowHeightObservable$); const [layoutRoot, setLayoutRoot] = useState(null); const [generation, setGeneration] = useState(null); const [visibleTilesCallback, setVisibleTilesCallback] = diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index f4a29379..fd26c6ee 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -26,8 +26,8 @@ interface GridCSSProperties extends CSSProperties { * together in a scrolling grid. */ export const makeGridLayout: CallLayout = ({ - minBounds, - spotlightAlignment, + minBounds$, + spotlightAlignment$, }) => ({ scrollingOnTop: false, @@ -37,7 +37,7 @@ export const makeGridLayout: CallLayout = ({ useUpdateLayout(); const alignment = useObservableEagerState( useInitial(() => - spotlightAlignment.pipe( + spotlightAlignment$.pipe( distinctUntilChanged( (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, ), @@ -47,7 +47,7 @@ export const makeGridLayout: CallLayout = ({ const onDragSpotlight: DragCallback = useCallback( ({ xRatio, yRatio }) => - spotlightAlignment.next({ + spotlightAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), @@ -74,7 +74,7 @@ export const makeGridLayout: CallLayout = ({ scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - const { width, height: minHeight } = useObservableEagerState(minBounds); + const { width, height: minHeight } = useObservableEagerState(minBounds$); const { gap, tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, minHeight, model.grid.length), [width, minHeight, model.grid.length], diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index fb0af714..9d49ae90 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -19,8 +19,8 @@ import { type DragCallback, useUpdateLayout } from "./Grid"; * is shown at maximum size, overlaid by a small view of the local participant. */ export const makeOneOnOneLayout: CallLayout = ({ - minBounds, - pipAlignment, + minBounds$, + pipAlignment$, }) => ({ scrollingOnTop: false, @@ -31,8 +31,8 @@ export const makeOneOnOneLayout: CallLayout = ({ scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { useUpdateLayout(); - const { width, height } = useObservableEagerState(minBounds); - const pipAlignmentValue = useObservableEagerState(pipAlignment); + const { width, height } = useObservableEagerState(minBounds$); + const pipAlignmentValue = useObservableEagerState(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -40,7 +40,7 @@ export const makeOneOnOneLayout: CallLayout = ({ const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment.next({ + pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index d4ce9af3..a50cecb9 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -19,7 +19,7 @@ import styles from "./SpotlightExpandedLayout.module.css"; */ export const makeSpotlightExpandedLayout: CallLayout< SpotlightExpandedLayoutModel -> = ({ pipAlignment }) => ({ +> = ({ pipAlignment$ }) => ({ scrollingOnTop: true, fixed: forwardRef(function SpotlightExpandedLayoutFixed( @@ -44,11 +44,11 @@ export const makeSpotlightExpandedLayout: CallLayout< ref, ) { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment); + const pipAlignmentValue = useObservableEagerState(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment.next({ + pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 3b80a166..a80cb6fa 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -21,7 +21,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid"; */ export const makeSpotlightLandscapeLayout: CallLayout< SpotlightLandscapeLayoutModel -> = ({ minBounds }) => ({ +> = ({ minBounds$ }) => ({ scrollingOnTop: false, fixed: forwardRef(function SpotlightLandscapeLayoutFixed( @@ -29,7 +29,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< ref, ) { useUpdateLayout(); - useObservableEagerState(minBounds); + useObservableEagerState(minBounds$); return (
@@ -51,9 +51,9 @@ export const makeSpotlightLandscapeLayout: CallLayout< ) { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - useObservableEagerState(minBounds); + useObservableEagerState(minBounds$); const withIndicators = - useObservableEagerState(model.spotlight.media).length > 1; + useObservableEagerState(model.spotlight.media$).length > 1; return (
diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 6af0fa39..9f62a520 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -27,7 +27,7 @@ interface GridCSSProperties extends CSSProperties { */ export const makeSpotlightPortraitLayout: CallLayout< SpotlightPortraitLayoutModel -> = ({ minBounds }) => ({ +> = ({ minBounds$ }) => ({ scrollingOnTop: false, fixed: forwardRef(function SpotlightPortraitLayoutFixed( @@ -55,7 +55,7 @@ export const makeSpotlightPortraitLayout: CallLayout< ) { useUpdateLayout(); useVisibleTiles(model.setVisibleTiles); - const { width } = useObservableEagerState(minBounds); + const { width } = useObservableEagerState(minBounds$); const { gap, tileWidth, tileHeight } = arrangeTiles( width, // TODO: We pretend that the minimum height is the width, because the @@ -64,7 +64,7 @@ export const makeSpotlightPortraitLayout: CallLayout< model.grid.length, ); const withIndicators = - useObservableEagerState(model.spotlight.media).length > 1; + useObservableEagerState(model.spotlight.media$).length > 1; return (
createMediaDeviceObserver( kind, @@ -86,7 +86,7 @@ function useMediaDevice( const available = useObservableEagerState( useMemo( () => - deviceObserver.pipe( + deviceObserver$.pipe( map((availableRaw) => { // Sometimes browsers (particularly Firefox) can return multiple device // entries for the exact same device ID; using a map deduplicates them @@ -117,7 +117,7 @@ function useMediaDevice( return available; }), ), - [kind, deviceObserver], + [kind, deviceObserver$], ), ); @@ -140,13 +140,13 @@ function useMediaDevice( const selectedGroupId = useObservableEagerState( useMemo( () => - deviceObserver.pipe( + deviceObserver$.pipe( map( (availableRaw) => availableRaw.find((d) => d.deviceId === selectedId)?.groupId, ), ), - [deviceObserver, selectedId], + [deviceObserver$, selectedId], ), ); diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index e8d22704..6868de49 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -100,13 +100,13 @@ function getMockEnv( ): { vm: CallViewModel; session: MockRTCSession; - remoteRtcMemberships: BehaviorSubject; + remoteRtcMemberships$: BehaviorSubject; } { const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); - const remoteParticipants = of([aliceParticipant]); + const remoteParticipants$ = of([aliceParticipant]); const liveKitRoom = mockLivekitRoom( { localParticipant }, - { remoteParticipants }, + { remoteParticipants$ }, ); const matrixRoom = mockMatrixRoom({ client: { @@ -118,14 +118,14 @@ function getMockEnv( getMember: (userId) => matrixRoomMembers.get(userId) ?? null, }); - const remoteRtcMemberships = new BehaviorSubject( + const remoteRtcMemberships$ = new BehaviorSubject( initialRemoteRtcMemberships, ); const session = new MockRTCSession( matrixRoom, localRtcMember, - ).withMemberships(remoteRtcMemberships); + ).withMemberships(remoteRtcMemberships$); const vm = new CallViewModel( session as unknown as MatrixRTCSession, @@ -135,7 +135,7 @@ function getMockEnv( }, of(ConnectionState.Connected), ); - return { vm, session, remoteRtcMemberships }; + return { vm, session, remoteRtcMemberships$ }; } /** @@ -146,33 +146,33 @@ function getMockEnv( * a noise every time. */ test("plays one sound when entering a call", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); // Joining a call usually means remote participants are added later. act(() => { - remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); + remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); }); expect(playSound).toHaveBeenCalledOnce(); }); // TODO: Same test? test("plays a sound when a user joins", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); act(() => { - remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); + remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); }); // Play a sound when joining a call. expect(playSound).toBeCalledWith("join"); }); test("plays a sound when a user leaves", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); act(() => { - remoteRtcMemberships.next([]); + remoteRtcMemberships$.next([]); }); expect(playSound).toBeCalledWith("left"); }); @@ -185,7 +185,7 @@ test("plays no sound when the participant list is more than the maximum size", ( ); } - const { session, vm, remoteRtcMemberships } = getMockEnv( + const { session, vm, remoteRtcMemberships$ } = getMockEnv( [local, alice], mockRtcMemberships, ); @@ -193,7 +193,7 @@ test("plays no sound when the participant list is more than the maximum size", ( render(); expect(playSound).not.toBeCalled(); act(() => { - remoteRtcMemberships.next( + remoteRtcMemberships$.next( mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1), ); }); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index a363c6f5..a2a0a7f1 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -65,7 +65,7 @@ export function CallEventAudioRenderer({ }, [audioEngineRef, previousRaisedHandCount, raisedHandCount]); useEffect(() => { - const joinSub = vm.memberChanges + const joinSub = vm.memberChanges$ .pipe( filter( ({ joined, ids }) => @@ -77,7 +77,7 @@ export function CallEventAudioRenderer({ void audioEngineRef.current?.playSound("join"); }); - const leftSub = vm.memberChanges + const leftSub = vm.memberChanges$ .pipe( filter( ({ ids, left }) => diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 976e4e94..dde64104 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -110,8 +110,8 @@ export const ActiveCall: FC = (props) => { sfuConfig, props.e2eeSystem, ); - const connStateObservable = useObservable( - (inputs) => inputs.pipe(map(([connState]) => connState)), + const connStateObservable$ = useObservable( + (inputs$) => inputs$.pipe(map(([connState]) => connState)), [connState], ); const [vm, setVm] = useState(null); @@ -131,12 +131,12 @@ export const ActiveCall: FC = (props) => { props.rtcSession, livekitRoom, props.e2eeSystem, - connStateObservable, + connStateObservable$, ); setVm(vm); return (): void => vm.destroy(); } - }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]); + }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]); if (livekitRoom === undefined || vm === null) return null; @@ -225,14 +225,14 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const windowMode = useObservableEagerState(vm.windowMode); - const layout = useObservableEagerState(vm.layout); - const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration); + const windowMode = useObservableEagerState(vm.windowMode$); + const layout = useObservableEagerState(vm.layout$); + const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useObservableEagerState(vm.gridMode); - const showHeader = useObservableEagerState(vm.showHeader); - const showFooter = useObservableEagerState(vm.showFooter); - const switchCamera = useSwitchCamera(vm.localVideo); + const gridMode = useObservableEagerState(vm.gridMode$); + const showHeader = useObservableEagerState(vm.showHeader$); + const showFooter = useObservableEagerState(vm.showFooter$); + const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported @@ -317,15 +317,15 @@ export const InCallView: FC = ({ windowMode, ], ); - const gridBoundsObservable = useObservable( - (inputs) => inputs.pipe(map(([gridBounds]) => gridBounds)), + const gridBoundsObservable$ = useObservable( + (inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)), [gridBounds], ); - const spotlightAlignment = useInitial( + const spotlightAlignment$ = useInitial( () => new BehaviorSubject(defaultSpotlightAlignment), ); - const pipAlignment = useInitial( + const pipAlignment$ = useInitial( () => new BehaviorSubject(defaultPipAlignment), ); @@ -383,15 +383,17 @@ export const InCallView: FC = ({ { className, style, targetWidth, targetHeight, model }, ref, ) { - const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded); + const spotlightExpanded = useObservableEagerState( + vm.spotlightExpanded$, + ); const onToggleExpanded = useObservableEagerState( - vm.toggleSpotlightExpanded, + vm.toggleSpotlightExpanded$, ); const showSpeakingIndicatorsValue = useObservableEagerState( - vm.showSpeakingIndicators, + vm.showSpeakingIndicators$, ); const showSpotlightIndicatorsValue = useObservableEagerState( - vm.showSpotlightIndicators, + vm.showSpotlightIndicators$, ); return model instanceof GridTileViewModel ? ( @@ -424,9 +426,9 @@ export const InCallView: FC = ({ const layouts = useMemo(() => { const inputs = { - minBounds: gridBoundsObservable, - spotlightAlignment, - pipAlignment, + minBounds$: gridBoundsObservable$, + spotlightAlignment$, + pipAlignment$, }; return { grid: makeGridLayout(inputs), @@ -435,7 +437,7 @@ export const InCallView: FC = ({ "spotlight-expanded": makeSpotlightExpandedLayout(inputs), "one-on-one": makeOneOnOneLayout(inputs), }; - }, [gridBoundsObservable, spotlightAlignment, pipAlignment]); + }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); const renderContent = (): JSX.Element => { if (layout.type === "pip") { diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index e7dfe3c5..4622cd3e 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -148,7 +148,7 @@ export const LobbyView: FC = ({ const switchCamera = useSwitchCamera( useObservable( - (inputs) => inputs.pipe(map(([video]) => video)), + (inputs$) => inputs$.pipe(map(([video]) => video)), [videoTrack], ), ); diff --git a/src/room/useSwitchCamera.ts b/src/room/useSwitchCamera.ts index 8bbfc92c..1cf5d29d 100644 --- a/src/room/useSwitchCamera.ts +++ b/src/room/useSwitchCamera.ts @@ -31,17 +31,17 @@ import { useLatest } from "../useLatest"; * producing a callback if so. */ export function useSwitchCamera( - video: Observable, + video$: Observable, ): (() => void) | null { const mediaDevices = useMediaDevices(); const setVideoInput = useLatest(mediaDevices.videoInput.select); // Produce an observable like the input 'video' observable, except make it // emit whenever the track is muted or the device changes - const videoTrack: Observable = useObservable( - (inputs) => - inputs.pipe( - switchMap(([video]) => video), + const videoTrack$: Observable = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([video$]) => video$), switchMap((video) => { if (video === null) return of(null); return merge( @@ -53,15 +53,15 @@ export function useSwitchCamera( ); }), ), - [video], + [video$], ); - const switchCamera: Observable<(() => void) | null> = useObservable( - (inputs) => + const switchCamera$: Observable<(() => void) | null> = useObservable( + (inputs$) => platform === "desktop" ? of(null) - : inputs.pipe( - switchMap(([track]) => track), + : inputs$.pipe( + switchMap(([track$]) => track$), map((track) => { if (track === null) return null; const facingMode = facingModeFromLocalTrack(track).facingMode; @@ -86,8 +86,8 @@ export function useSwitchCamera( ); }), ), - [videoTrack], + [videoTrack$], ); - return useObservableEagerState(switchCamera); + return useObservableEagerState(switchCamera$); } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a902f9ab..ebb5dffc 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -31,17 +31,17 @@ export class Setting { } } - this._value = new BehaviorSubject(initialValue); - this.value = this._value; + this._value$ = new BehaviorSubject(initialValue); + this.value$ = this._value$; } private readonly key: string; - private readonly _value: BehaviorSubject; - public readonly value: Observable; + private readonly _value$: BehaviorSubject; + public readonly value$: Observable; public readonly setValue = (value: T): void => { - this._value.next(value); + this._value$.next(value); localStorage.setItem(this.key, JSON.stringify(value)); }; } @@ -50,7 +50,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 [useObservableEagerState(setting.value$), setting.setValue]; } // null = undecided diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index d5b84d49..c9072006 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -124,15 +124,15 @@ export type LayoutSummary = | OneOnOneLayoutSummary | PipLayoutSummary; -function summarizeLayout(l: Observable): Observable { - return l.pipe( +function summarizeLayout$(l$: Observable): Observable { + return l$.pipe( switchMap((l) => { switch (l.type) { case "grid": return combineLatest( [ - l.spotlight?.media ?? of(undefined), - ...l.grid.map((vm) => vm.media), + l.spotlight?.media$ ?? of(undefined), + ...l.grid.map((vm) => vm.media$), ], (spotlight, ...grid) => ({ type: l.type, @@ -143,7 +143,7 @@ function summarizeLayout(l: Observable): Observable { case "spotlight-landscape": case "spotlight-portrait": return combineLatest( - [l.spotlight.media, ...l.grid.map((vm) => vm.media)], + [l.spotlight.media$, ...l.grid.map((vm) => vm.media$)], (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -152,7 +152,7 @@ function summarizeLayout(l: Observable): Observable { ); case "spotlight-expanded": return combineLatest( - [l.spotlight.media, l.pip?.media ?? of(undefined)], + [l.spotlight.media$, l.pip?.media$ ?? of(undefined)], (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -161,7 +161,7 @@ function summarizeLayout(l: Observable): Observable { ); case "one-on-one": return combineLatest( - [l.local.media, l.remote.media], + [l.local.media$, l.remote.media$], (local, remote) => ({ type: l.type, local: local.id, @@ -169,7 +169,7 @@ function summarizeLayout(l: Observable): Observable { }), ); case "pip": - return l.spotlight.media.pipe( + return l.spotlight.media$.pipe( map((spotlight) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -186,9 +186,9 @@ function summarizeLayout(l: Observable): Observable { } function withCallViewModel( - remoteParticipants: Observable, - rtcMembers: Observable[]>, - connectionState: Observable, + remoteParticipants$: Observable, + rtcMembers$: Observable[]>, + connectionState$: Observable, speaking: Map>, continuation: (vm: CallViewModel) => void, ): void { @@ -203,10 +203,10 @@ function withCallViewModel( room, localRtcMember, [], - ).withMemberships(rtcMembers); + ).withMemberships(rtcMembers$); const participantsSpy = vi .spyOn(ComponentsCore, "connectedParticipantsObserver") - .mockReturnValue(remoteParticipants); + .mockReturnValue(remoteParticipants$); const mediaSpy = vi .spyOn(ComponentsCore, "observeParticipantMedia") .mockImplementation((p) => @@ -232,7 +232,7 @@ function withCallViewModel( const liveKitRoom = mockLivekitRoom( { localParticipant }, - { remoteParticipants }, + { remoteParticipants$ }, ); const vm = new CallViewModel( @@ -241,7 +241,7 @@ function withCallViewModel( { kind: E2eeType.PER_PARTICIPANT, }, - connectionState, + connectionState$, ); onTestFinished(() => { @@ -276,7 +276,7 @@ test("participants are retained during a focus switch", () => { }), new Map(), (vm) => { - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -320,7 +320,7 @@ test("screen sharing activates spotlight layout", () => { g: () => vm.setGridMode("grid"), }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -363,7 +363,7 @@ test("screen sharing activates spotlight layout", () => { }, }, ); - expectObservable(vm.showSpeakingIndicators).toBe( + expectObservable(vm.showSpeakingIndicators$).toBe( expectedShowSpeakingMarbles, { y: true, @@ -402,13 +402,13 @@ test("participants stay in the same order unless to appear/disappear", () => { a: () => { // We imagine that only three tiles (the first three) will be visible // on screen at a time - vm.layout.subscribe((layout) => { + vm.layout$.subscribe((layout) => { if (layout.type === "grid") layout.setVisibleTiles(3); }); }, }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -455,7 +455,7 @@ test("participants adjust order when space becomes constrained", () => { ]), (vm) => { let setVisibleTiles: ((value: number) => void) | null = null; - vm.layout.subscribe((layout) => { + vm.layout$.subscribe((layout) => { if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles; }); schedule(visibilityInputMarbles, { @@ -463,7 +463,7 @@ test("participants adjust order when space becomes constrained", () => { b: () => setVisibleTiles!(3), }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -509,7 +509,7 @@ test("spotlight speakers swap places", () => { (vm) => { schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -557,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => { d: () => window.controls.disablePip(), }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -600,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => { schedule(expandInputMarbles, { a: () => { let toggle: () => void; - vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!)); + vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!)); toggle!(); }, }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -662,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => { new Map(), (vm) => { vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -706,7 +706,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", () new Map(), (vm) => { vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -753,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => { new Map(), (vm) => { vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 8e080fdc..36cbbac8 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -62,7 +62,7 @@ import { import { LocalUserMediaViewModel, type MediaViewModel, - observeTrackReference, + observeTrackReference$, RemoteUserMediaViewModel, ScreenShareViewModel, type UserMediaViewModel, @@ -71,7 +71,7 @@ import { accumulate, finalizeValue } from "../utils/observable"; import { ObservableScope } from "./ObservableScope"; import { duplicateTiles, showNonMemberTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; -import { setPipEnabled } from "../controls"; +import { setPipEnabled$ } from "../controls"; import { type GridTileViewModel, type SpotlightTileViewModel, @@ -82,7 +82,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout"; import { oneOnOneLayout } from "./OneOnOneLayout"; import { pipLayout } from "./PipLayout"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; -import { observeSpeaker } from "./observeSpeaker"; +import { observeSpeaker$ } from "./observeSpeaker"; import { shallowEquals } from "../utils/array"; // How long we wait after a focus switch before showing the real participant @@ -232,12 +232,12 @@ interface LayoutScanState { class UserMedia { private readonly scope = new ObservableScope(); public readonly vm: UserMediaViewModel; - private readonly participant: BehaviorSubject< + private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant | undefined >; - public readonly speaker: Observable; - public readonly presenter: Observable; + public readonly speaker$: Observable; + public readonly presenter$: Observable; public constructor( public readonly id: string, member: RoomMember | undefined, @@ -245,13 +245,13 @@ class UserMedia { encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - this.participant = new BehaviorSubject(participant); + this.participant$ = new BehaviorSubject(participant); if (participant?.isLocal) { this.vm = new LocalUserMediaViewModel( this.id, member, - this.participant.asObservable() as Observable, + this.participant$.asObservable() as Observable, encryptionSystem, livekitRoom, ); @@ -259,7 +259,7 @@ class UserMedia { this.vm = new RemoteUserMediaViewModel( id, member, - this.participant.asObservable() as Observable< + this.participant$.asObservable() as Observable< RemoteParticipant | undefined >, encryptionSystem, @@ -267,9 +267,9 @@ class UserMedia { ); } - this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state()); + this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); - this.presenter = this.participant.pipe( + this.presenter$ = this.participant$.pipe( switchMap( (p) => (p && @@ -289,9 +289,9 @@ class UserMedia { public updateParticipant( newParticipant: LocalParticipant | RemoteParticipant | undefined, ): void { - if (this.participant.value !== newParticipant) { + if (this.participant$.value !== newParticipant) { // Update the BehaviourSubject in the UserMedia. - this.participant.next(newParticipant); + this.participant$.next(newParticipant); } } @@ -303,7 +303,7 @@ class UserMedia { class ScreenShare { public readonly vm: ScreenShareViewModel; - private readonly participant: BehaviorSubject< + private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant >; @@ -314,12 +314,12 @@ class ScreenShare { encryptionSystem: EncryptionSystem, liveKitRoom: LivekitRoom, ) { - this.participant = new BehaviorSubject(participant); + this.participant$ = new BehaviorSubject(participant); this.vm = new ScreenShareViewModel( id, member, - this.participant.asObservable(), + this.participant$.asObservable(), encryptionSystem, liveKitRoom, participant.isLocal, @@ -357,8 +357,8 @@ function findMatrixRoomMember( // TODO: Move wayyyy more business logic from the call and lobby views into here export class CallViewModel extends ViewModel { - public readonly localVideo: Observable = - observeTrackReference( + public readonly localVideo$: Observable = + observeTrackReference$( of(this.livekitRoom.localParticipant), Track.Source.Camera, ).pipe( @@ -371,16 +371,16 @@ export class CallViewModel extends ViewModel { /** * The raw list of RemoteParticipants as reported by LiveKit */ - private readonly rawRemoteParticipants: Observable = + private readonly rawRemoteParticipants$: Observable = connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); /** * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * they've left */ - private readonly remoteParticipantHolds: Observable = - this.connectionState.pipe( - withLatestFrom(this.rawRemoteParticipants), + private readonly remoteParticipantHolds$: Observable = + this.connectionState$.pipe( + withLatestFrom(this.rawRemoteParticipants$), mergeMap(([s, ps]) => { // Whenever we switch focuses, we should retain all the previous // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to @@ -392,7 +392,7 @@ export class CallViewModel extends ViewModel { // Wait for time to pass and the connection state to have changed forkJoin([ timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), - this.connectionState.pipe( + this.connectionState$.pipe( filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus), take(1), ), @@ -415,9 +415,9 @@ export class CallViewModel extends ViewModel { /** * The RemoteParticipants including those that are being "held" on the screen */ - private readonly remoteParticipants: Observable = + private readonly remoteParticipants$: Observable = combineLatest( - [this.rawRemoteParticipants, this.remoteParticipantHolds], + [this.rawRemoteParticipants$, this.remoteParticipantHolds$], (raw, holds) => { const result = [...raw]; const resultIds = new Set(result.map((p) => p.identity)); @@ -439,10 +439,10 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display */ - private readonly mediaItems: Observable = combineLatest([ - this.remoteParticipants, + private readonly mediaItems$: Observable = combineLatest([ + this.remoteParticipants$, observeParticipantMedia(this.livekitRoom.localParticipant), - duplicateTiles.value, + 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. @@ -450,7 +450,7 @@ export class CallViewModel extends ViewModel { this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged, ).pipe(startWith(null)), - showNonMemberTiles.value, + showNonMemberTiles.value$, ]).pipe( scan( ( @@ -606,13 +606,13 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type UserMedia */ - private readonly userMedia: Observable = this.mediaItems.pipe( + private readonly userMedia$: Observable = this.mediaItems$.pipe( map((mediaItems) => mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), ), ); - public readonly memberChanges = this.userMedia + public readonly memberChanges$ = this.userMedia$ .pipe(map((mediaItems) => mediaItems.map((m) => m.id))) .pipe( scan( @@ -628,22 +628,22 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type ScreenShare */ - private readonly screenShares: Observable = - this.mediaItems.pipe( + private readonly screenShares$: Observable = + this.mediaItems$.pipe( map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), this.scope.state(), ); - private readonly spotlightSpeaker: Observable = - this.userMedia.pipe( + 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)), + m.vm.speaking$.pipe(map((s) => [m, s] as const)), ), ), ), @@ -672,52 +672,53 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly grid: Observable = this.userMedia.pipe( - switchMap((mediaItems) => { - const bins = mediaItems.map((m) => - combineLatest( - [ - m.speaker, - m.presenter, - m.vm.videoEnabled, - m.vm instanceof LocalUserMediaViewModel - ? m.vm.alwaysShow - : of(false), - ], - (speaker, presenter, video, alwaysShow) => { - let bin: SortingBin; - if (m.vm.local) - bin = alwaysShow - ? SortingBin.SelfAlwaysShown - : SortingBin.SelfNotAlwaysShown; - else if (presenter) bin = SortingBin.Presenters; - else if (speaker) bin = SortingBin.Speakers; - else if (video) bin = SortingBin.Video; - else bin = SortingBin.NoVideo; + private readonly grid$: Observable = + this.userMedia$.pipe( + switchMap((mediaItems) => { + const bins = mediaItems.map((m) => + combineLatest( + [ + m.speaker$, + m.presenter$, + m.vm.videoEnabled$, + m.vm instanceof LocalUserMediaViewModel + ? m.vm.alwaysShow$ + : of(false), + ], + (speaker, presenter, video, alwaysShow) => { + let bin: SortingBin; + if (m.vm.local) + bin = alwaysShow + ? SortingBin.SelfAlwaysShown + : SortingBin.SelfNotAlwaysShown; + else if (presenter) bin = SortingBin.Presenters; + else if (speaker) bin = SortingBin.Speakers; + else if (video) bin = SortingBin.Video; + else bin = SortingBin.NoVideo; - return [m, bin] as const; - }, - ), - ); - // Sort the media by bin order and generate a tile for each one - return bins.length === 0 - ? of([]) - : combineLatest(bins, (...bins) => - bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), - ); - }), - distinctUntilChanged(shallowEquals), - this.scope.state(), - ); + return [m, bin] as const; + }, + ), + ); + // Sort the media by bin order and generate a tile for each one + return bins.length === 0 + ? of([]) + : combineLatest(bins, (...bins) => + bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), + ); + }), + distinctUntilChanged(shallowEquals), + this.scope.state(), + ); - private readonly spotlight: Observable = - this.screenShares.pipe( + private readonly spotlight$: Observable = + this.screenShares$.pipe( switchMap((screenShares) => { if (screenShares.length > 0) { return of(screenShares.map((m) => m.vm)); } - return this.spotlightSpeaker.pipe( + return this.spotlightSpeaker$.pipe( map((speaker) => (speaker ? [speaker] : [])), ); }), @@ -725,14 +726,14 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly pip: Observable = combineLatest([ - this.screenShares, - this.spotlightSpeaker, - this.mediaItems, + private readonly pip$: Observable = combineLatest([ + this.screenShares$, + this.spotlightSpeaker$, + this.mediaItems$, ]).pipe( switchMap(([screenShares, spotlight, mediaItems]) => { if (screenShares.length > 0) { - return this.spotlightSpeaker; + return this.spotlightSpeaker$; } if (!spotlight || spotlight.local) { return of(null); @@ -749,7 +750,7 @@ export class CallViewModel extends ViewModel { if (!localUserMediaViewModel) { return of(null); } - return localUserMediaViewModel.alwaysShow.pipe( + return localUserMediaViewModel.alwaysShow$.pipe( map((alwaysShow) => { if (alwaysShow) { return localUserMediaViewModel; @@ -762,19 +763,19 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly hasRemoteScreenShares: Observable = - this.spotlight.pipe( + private readonly hasRemoteScreenShares$: Observable = + this.spotlight$.pipe( map((spotlight) => spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel), ), distinctUntilChanged(), ); - private readonly pipEnabled: Observable = setPipEnabled.pipe( + private readonly pipEnabled$: Observable = setPipEnabled$.pipe( startWith(false), ); - private readonly naturalWindowMode: Observable = fromEvent( + private readonly naturalWindowMode$: Observable = fromEvent( window, "resize", ).pipe( @@ -796,30 +797,30 @@ export class CallViewModel extends ViewModel { /** * The general shape of the window. */ - public readonly windowMode: Observable = this.pipEnabled.pipe( - switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode)), + public readonly windowMode$: Observable = this.pipEnabled$.pipe( + switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode$)), ); - private readonly spotlightExpandedToggle = new Subject(); - public readonly spotlightExpanded: Observable = - this.spotlightExpandedToggle.pipe( + private readonly spotlightExpandedToggle$ = new Subject(); + public readonly spotlightExpanded$: Observable = + this.spotlightExpandedToggle$.pipe( accumulate(false, (expanded) => !expanded), this.scope.state(), ); - private readonly gridModeUserSelection = new Subject(); + private readonly gridModeUserSelection$ = new Subject(); /** * The layout mode of the media tile grid. */ - public readonly gridMode: Observable = + public readonly gridMode$: Observable = // 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( + this.gridModeUserSelection$.pipe( startWith(null), switchMap((userSelection) => (userSelection === "spotlight" ? EMPTY - : combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe( + : combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( skip(userSelection === null ? 0 : 1), map( ([hasScreenShares, windowMode]): GridMode => @@ -834,43 +835,41 @@ export class CallViewModel extends ViewModel { ); public setGridMode(value: GridMode): void { - this.gridModeUserSelection.next(value); + this.gridModeUserSelection$.next(value); } - private readonly gridLayoutMedia: Observable = combineLatest( - [this.grid, this.spotlight], - (grid, spotlight) => ({ + private readonly gridLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "grid", spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel) ? spotlight : undefined, grid, - }), - ); + })); - private readonly spotlightLandscapeLayoutMedia: Observable = - combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ + private readonly spotlightLandscapeLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid, })); - private readonly spotlightPortraitLayoutMedia: Observable = - combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ + private readonly spotlightPortraitLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid, })); - private readonly spotlightExpandedLayoutMedia: Observable = - combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({ + private readonly spotlightExpandedLayoutMedia$: Observable = + combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({ type: "spotlight-expanded", spotlight, pip: pip ?? undefined, })); - private readonly oneOnOneLayoutMedia: Observable = - this.mediaItems.pipe( + private readonly oneOnOneLayoutMedia$: Observable = + this.mediaItems$.pipe( map((mediaItems) => { if (mediaItems.length !== 2) return null; const local = mediaItems.find((vm) => vm.vm.local)?.vm as @@ -888,86 +887,91 @@ export class CallViewModel extends ViewModel { }), ); - private readonly pipLayoutMedia: Observable = - this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight }))); + private readonly pipLayoutMedia$: Observable = + this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight }))); /** * The media to be used to produce a layout. */ - private readonly layoutMedia: Observable = this.windowMode.pipe( - switchMap((windowMode) => { - switch (windowMode) { - case "normal": - return this.gridMode.pipe( - switchMap((gridMode) => { - switch (gridMode) { - case "grid": - return this.oneOnOneLayoutMedia.pipe( - switchMap((oneOnOne) => - oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne), - ), - ); - case "spotlight": - return this.spotlightExpanded.pipe( - switchMap((expanded) => - expanded - ? this.spotlightExpandedLayoutMedia - : this.spotlightLandscapeLayoutMedia, - ), - ); - } - }), - ); - case "narrow": - return this.oneOnOneLayoutMedia.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? combineLatest( - [this.grid, this.spotlight], - (grid, spotlight) => - grid.length > smallMobileCallThreshold || - spotlight.some((vm) => vm instanceof ScreenShareViewModel) - ? this.spotlightPortraitLayoutMedia - : this.gridLayoutMedia, - ).pipe(switchAll()) - : // The expanded spotlight layout makes for a better one-on-one - // experience in narrow windows - this.spotlightExpandedLayoutMedia, - ), - ); - case "flat": - return this.gridMode.pipe( - switchMap((gridMode) => { - switch (gridMode) { - case "grid": - // Yes, grid mode actually gets you a "spotlight" layout in - // this window mode. - return this.spotlightLandscapeLayoutMedia; - case "spotlight": - return this.spotlightExpandedLayoutMedia; - } - }), - ); - case "pip": - return this.pipLayoutMedia; - } - }), - this.scope.state(), - ); + private readonly layoutMedia$: Observable = + this.windowMode$.pipe( + switchMap((windowMode) => { + switch (windowMode) { + case "normal": + return this.gridMode$.pipe( + switchMap((gridMode) => { + switch (gridMode) { + case "grid": + return this.oneOnOneLayoutMedia$.pipe( + switchMap((oneOnOne) => + oneOnOne === null + ? this.gridLayoutMedia$ + : of(oneOnOne), + ), + ); + case "spotlight": + return this.spotlightExpanded$.pipe( + switchMap((expanded) => + expanded + ? this.spotlightExpandedLayoutMedia$ + : this.spotlightLandscapeLayoutMedia$, + ), + ); + } + }), + ); + case "narrow": + return this.oneOnOneLayoutMedia$.pipe( + switchMap((oneOnOne) => + oneOnOne === null + ? combineLatest( + [this.grid$, this.spotlight$], + (grid, spotlight) => + grid.length > smallMobileCallThreshold || + spotlight.some( + (vm) => vm instanceof ScreenShareViewModel, + ) + ? this.spotlightPortraitLayoutMedia$ + : this.gridLayoutMedia$, + ).pipe(switchAll()) + : // The expanded spotlight layout makes for a better one-on-one + // experience in narrow windows + this.spotlightExpandedLayoutMedia$, + ), + ); + case "flat": + return this.gridMode$.pipe( + switchMap((gridMode) => { + switch (gridMode) { + case "grid": + // Yes, grid mode actually gets you a "spotlight" layout in + // this window mode. + return this.spotlightLandscapeLayoutMedia$; + case "spotlight": + return this.spotlightExpandedLayoutMedia$; + } + }), + ); + case "pip": + 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 // first render a layout. To deal with this we assume initially that no tiles // are visible, and loop the data back into the layouts with a Subject. - private readonly visibleTiles = new Subject(); + private readonly visibleTiles$ = new Subject(); private readonly setVisibleTiles = (value: number): void => - this.visibleTiles.next(value); + this.visibleTiles$.next(value); - public readonly layoutInternals: Observable< + public readonly layoutInternals$: Observable< LayoutScanState & { layout: Layout } > = combineLatest([ - this.layoutMedia, - this.visibleTiles.pipe(startWith(0), distinctUntilChanged()), + this.layoutMedia$, + this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), ]).pipe( scan< [LayoutMedia, number], @@ -1009,7 +1013,7 @@ export class CallViewModel extends ViewModel { /** * The layout of tiles in the call interface. */ - public readonly layout: Observable = this.layoutInternals.pipe( + public readonly layout$: Observable = this.layoutInternals$.pipe( map(({ layout }) => layout), this.scope.state(), ); @@ -1017,18 +1021,18 @@ export class CallViewModel extends ViewModel { /** * The current generation of the tile store, exposed for debugging purposes. */ - public readonly tileStoreGeneration: Observable = - this.layoutInternals.pipe( + public readonly tileStoreGeneration$: Observable = + this.layoutInternals$.pipe( map(({ tiles }) => tiles.generation), this.scope.state(), ); - public showSpotlightIndicators: Observable = this.layout.pipe( + public showSpotlightIndicators$: Observable = this.layout$.pipe( map((l) => l.type !== "grid"), this.scope.state(), ); - public showSpeakingIndicators: Observable = this.layout.pipe( + public showSpeakingIndicators$: Observable = this.layout$.pipe( switchMap((l) => { switch (l.type) { case "spotlight-landscape": @@ -1036,7 +1040,7 @@ export class CallViewModel extends ViewModel { // 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( + return l.spotlight.media$.pipe( map((models: MediaViewModel[]) => models.some((m) => m instanceof ScreenShareViewModel), ), @@ -1055,11 +1059,11 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - public readonly toggleSpotlightExpanded: Observable<(() => void) | null> = - this.windowMode.pipe( + public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = + this.windowMode$.pipe( switchMap((mode) => mode === "normal" - ? this.layout.pipe( + ? this.layout$.pipe( map( (l) => l.type === "spotlight-landscape" || @@ -1070,50 +1074,50 @@ export class CallViewModel extends ViewModel { ), distinctUntilChanged(), map((enabled) => - enabled ? (): void => this.spotlightExpandedToggle.next() : null, + enabled ? (): void => this.spotlightExpandedToggle$.next() : null, ), this.scope.state(), ); - private readonly screenTap = new Subject(); - private readonly controlsTap = new Subject(); - private readonly screenHover = new Subject(); - private readonly screenUnhover = new Subject(); + private readonly screenTap$ = new Subject(); + private readonly controlsTap$ = new Subject(); + private readonly screenHover$ = new Subject(); + private readonly screenUnhover$ = new Subject(); /** * Callback for when the user taps the call view. */ public tapScreen(): void { - this.screenTap.next(); + this.screenTap$.next(); } /** * Callback for when the user taps the call's controls. */ public tapControls(): void { - this.controlsTap.next(); + this.controlsTap$.next(); } /** * Callback for when the user hovers over the call view. */ public hoverScreen(): void { - this.screenHover.next(); + this.screenHover$.next(); } /** * Callback for when the user stops hovering over the call view. */ public unhoverScreen(): void { - this.screenUnhover.next(); + this.screenUnhover$.next(); } - public readonly showHeader: Observable = this.windowMode.pipe( + public readonly showHeader$: Observable = this.windowMode$.pipe( map((mode) => mode !== "pip" && mode !== "flat"), this.scope.state(), ); - public readonly showFooter: Observable = this.windowMode.pipe( + public readonly showFooter$: Observable = this.windowMode$.pipe( switchMap((mode) => { switch (mode) { case "pip": @@ -1128,9 +1132,9 @@ export class CallViewModel extends ViewModel { 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)), + 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) { @@ -1153,7 +1157,7 @@ export class CallViewModel extends ViewModel { // Show on hover and hide after a timeout return race( timer(showFooterMs), - this.screenUnhover.pipe(take(1)), + this.screenUnhover$.pipe(take(1)), ).pipe( map(() => false), startWith(true), @@ -1172,7 +1176,7 @@ export class CallViewModel extends ViewModel { private readonly matrixRTCSession: MatrixRTCSession, private readonly livekitRoom: LivekitRoom, private readonly encryptionSystem: EncryptionSystem, - private readonly connectionState: Observable, + private readonly connectionState$: Observable, ) { super(); } diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts index c4e0bee6..18fa13b6 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/MediaViewModel.test.ts @@ -49,7 +49,7 @@ test("control a participant's volume", async () => { expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); }, }); - expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", { + expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", { a: 1, b: 0, c: 0.6, @@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => { a: () => vm.toggleFitContain(), b: () => vm.toggleFitContain(), }); - expectObservable(vm.cropVideo).toBe("abc", { + expectObservable(vm.cropVideo$).toBe("abc", { a: true, b: false, c: true, @@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => { await withLocalMedia(rtcMembership, {}, (vm) => withTestScheduler(({ expectObservable, schedule }) => { schedule("-a|", { a: () => vm.setAlwaysShow(false) }); - expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false }); + expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false }); }), ); // Next local media should start out *not* always shown @@ -93,7 +93,7 @@ test("local media remembers whether it should always be shown", async () => { (vm) => withTestScheduler(({ expectObservable, schedule }) => { schedule("-a|", { a: () => vm.setAlwaysShow(true) }); - expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); + expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true }); }), ); }); diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index ea015eb8..8100a50d 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string { return displayName; } -export function observeTrackReference( - participant: Observable, +export function observeTrackReference$( + participant$: Observable, source: Track.Source, ): Observable { - return participant.pipe( + return participant$.pipe( switchMap((p) => { if (p) { return observeParticipantMedia(p).pipe( @@ -96,7 +96,7 @@ export function observeTrackReference( ); } -function observeRemoteTrackReceivingOkay( +function observeRemoteTrackReceivingOkay$( participant: Participant, source: Track.Source, ): Observable { @@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay( }; return combineLatest([ - observeTrackReference(of(participant), source), + observeTrackReference$(of(participant), source), interval(1000).pipe(startWith(0)), ]).pipe( switchMap(async ([trackReference]) => { @@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay( ); } -function encryptionErrorObservable( +function encryptionErrorObservable$( room: LivekitRoom, participant: Participant, encryptionSystem: EncryptionSystem, @@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel { /** * The LiveKit video track for this media. */ - public readonly video: Observable; + public readonly video$: Observable; /** * Whether there should be a warning that this media is unencrypted. */ - public readonly unencryptedWarning: Observable; + public readonly unencryptedWarning$: Observable; - public readonly encryptionStatus: Observable; + public readonly encryptionStatus$: Observable; /** * Whether this media corresponds to the local participant. @@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel { public readonly member: RoomMember | undefined, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // livekit. - protected readonly participant: Observable< + protected readonly participant$: Observable< LocalParticipant | RemoteParticipant | undefined >, @@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel { livekitRoom: LivekitRoom, ) { super(); - const audio = observeTrackReference(participant, audioSource).pipe( + const audio$ = observeTrackReference$(participant$, audioSource).pipe( this.scope.state(), ); - this.video = observeTrackReference(participant, videoSource).pipe( + this.video$ = observeTrackReference$(participant$, videoSource).pipe( this.scope.state(), ); - this.unencryptedWarning = combineLatest( - [audio, this.video], + 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( + this.encryptionStatus$ = this.participant$.pipe( switchMap((participant): Observable => { if (!participant) { return of(EncryptionStatus.Connecting); @@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel { return of(EncryptionStatus.Okay); } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { return combineLatest([ - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "MissingKey", ), - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "InvalidKey", ), - observeRemoteTrackReceivingOkay(participant, audioSource), - observeRemoteTrackReceivingOkay(participant, videoSource), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), ]).pipe( map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { if (keyMissing) return EncryptionStatus.KeyMissing; @@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel { ); } else { return combineLatest([ - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "InvalidKey", ), - observeRemoteTrackReceivingOkay(participant, audioSource), - observeRemoteTrackReceivingOkay(participant, videoSource), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), ]).pipe( map( ([keyInvalid, audioOkay, videoOkay]): @@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether the participant is speaking. */ - public readonly speaking = this.participant.pipe( + public readonly speaking$ = this.participant$.pipe( switchMap((p) => p ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( @@ -353,49 +353,49 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether this participant is sending audio (i.e. is unmuted on their side). */ - public readonly audioEnabled: Observable; + public readonly audioEnabled$: Observable; /** * Whether this participant is sending video. */ - public readonly videoEnabled: Observable; + public readonly videoEnabled$: Observable; - 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. */ - public readonly cropVideo: Observable = this._cropVideo; + public readonly cropVideo$: Observable = this._cropVideo$; public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { super( id, member, - participant, + participant$, encryptionSystem, Track.Source.Microphone, Track.Source.Camera, livekitRoom, ); - const media = participant.pipe( + const media$ = participant$.pipe( switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), this.scope.state(), ); - this.audioEnabled = media.pipe( + this.audioEnabled$ = media$.pipe( map((m) => m?.microphoneTrack?.isMuted === false), ); - this.videoEnabled = media.pipe( + this.videoEnabled$ = media$.pipe( map((m) => m?.cameraTrack?.isMuted === false), ); } public toggleFitContain(): void { - this._cropVideo.next(!this._cropVideo.value); + this._cropVideo$.next(!this._cropVideo$.value); } public get local(): boolean { @@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether the video should be mirrored. */ - public readonly mirror = this.video.pipe( + public readonly mirror$ = this.video$.pipe( switchMap((v) => { const track = v?.publication?.track; if (!(track instanceof LocalTrack)) return of(false); @@ -428,17 +428,17 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { * Whether to show this tile in a highly visible location near the start of * the grid. */ - public readonly alwaysShow = alwaysShowSelf.value; + public readonly alwaysShow$ = alwaysShowSelf.value$; public readonly setAlwaysShow = alwaysShowSelf.setValue; public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super(id, member, participant$, encryptionSystem, livekitRoom); } } @@ -446,18 +446,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { * A remote participant's user media. */ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { - private readonly locallyMutedToggle = new Subject(); - private readonly localVolumeAdjustment = new Subject(); - private readonly localVolumeCommit = new Subject(); + private readonly locallyMutedToggle$ = new Subject(); + private readonly localVolumeAdjustment$ = new Subject(); + private readonly localVolumeCommit$ = new Subject(); /** * The volume to which this participant's audio is set, as a scalar * multiplier. */ - public readonly localVolume: Observable = merge( - this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)), - this.localVolumeAdjustment, - this.localVolumeCommit.pipe(map(() => "commit" as const)), + public readonly localVolume$: Observable = merge( + this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), + this.localVolumeAdjustment$, + this.localVolumeCommit$.pipe(map(() => "commit" as const)), ).pipe( accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { switch (event) { @@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether this participant's audio is disabled. */ - public readonly locallyMuted: Observable = this.localVolume.pipe( + public readonly locallyMuted$: Observable = this.localVolume$.pipe( map((volume) => volume === 0), this.scope.state(), ); @@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super(id, member, participant$, encryptionSystem, livekitRoom); // Sync the local volume with LiveKit combineLatest([ - participant, - this.localVolume.pipe(this.scope.bind()), + participant$, + this.localVolume$.pipe(this.scope.bind()), ]).subscribe(([p, volume]) => p && p.setVolume(volume)); } public toggleLocallyMuted(): void { - this.locallyMutedToggle.next(); + this.locallyMutedToggle$.next(); } public setLocalVolume(value: number): void { - this.localVolumeAdjustment.next(value); + this.localVolumeAdjustment$.next(value); } public commitLocalVolume(): void { - this.localVolumeCommit.next(); + this.localVolumeCommit$.next(); } } @@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, public readonly local: boolean, @@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { super( id, member, - participant, + participant$, encryptionSystem, Track.Source.ScreenShareAudio, Track.Source.ScreenShare, diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index 5a2e0e9a..254fc03f 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -19,9 +19,9 @@ type MonoTypeOperator = (o: Observable) => Observable; * A scope which limits the execution lifetime of its bound Observables. */ export class ObservableScope { - private readonly ended = new Subject(); + private readonly ended$ = new Subject(); - private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended); + private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended$); /** * Binds an Observable to this scope, so that it completes when the scope @@ -31,8 +31,8 @@ export class ObservableScope { return this.bindImpl; } - private readonly stateImpl: MonoTypeOperator = (o) => - o.pipe( + private readonly stateImpl: MonoTypeOperator = (o$) => + o$.pipe( this.bind(), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: false }), @@ -51,7 +51,7 @@ export class ObservableScope { * Ends the scope, causing any bound Observables to complete. */ public end(): void { - this.ended.next(); - this.ended.complete(); + this.ended$.next(); + this.ended$.complete(); } } diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index cd269944..4d6878b6 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] { } let DEBUG_ENABLED = false; -debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value)); +debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value)); class SpotlightTileData { - private readonly media_: BehaviorSubject; + private readonly media$: BehaviorSubject; public get media(): MediaViewModel[] { - return this.media_.value; + return this.media$.value; } public set media(value: MediaViewModel[]) { - this.media_.next(value); + this.media$.next(value); } - private readonly maximised_: BehaviorSubject; + private readonly maximised$: BehaviorSubject; public get maximised(): boolean { - return this.maximised_.value; + return this.maximised$.value; } public set maximised(value: boolean) { - this.maximised_.next(value); + this.maximised$.next(value); } public readonly vm: SpotlightTileViewModel; public constructor(media: MediaViewModel[], maximised: boolean) { - this.media_ = new BehaviorSubject(media); - this.maximised_ = new BehaviorSubject(maximised); - this.vm = new SpotlightTileViewModel(this.media_, this.maximised_); + this.media$ = new BehaviorSubject(media); + this.maximised$ = new BehaviorSubject(maximised); + this.vm = new SpotlightTileViewModel(this.media$, this.maximised$); } public destroy(): void { @@ -51,19 +51,19 @@ class SpotlightTileData { } class GridTileData { - private readonly media_: BehaviorSubject; + private readonly media$: BehaviorSubject; public get media(): UserMediaViewModel { - return this.media_.value; + return this.media$.value; } public set media(value: UserMediaViewModel) { - this.media_.next(value); + this.media$.next(value); } public readonly vm: GridTileViewModel; public constructor(media: UserMediaViewModel) { - this.media_ = new BehaviorSubject(media); - this.vm = new GridTileViewModel(this.media_); + this.media$ = new BehaviorSubject(media); + this.vm = new GridTileViewModel(this.media$); } public destroy(): void { @@ -123,7 +123,10 @@ export class TileStoreBuilder { "speaking" in this.prevSpotlight.media[0] && this.prevSpotlight.media[0]; - private readonly prevGridByMedia = new Map( + private readonly prevGridByMedia: Map< + MediaViewModel, + [GridTileData, number] + > = new Map( this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const), ); diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 612d7033..5815df54 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -18,15 +18,15 @@ function createId(): string { export class GridTileViewModel extends ViewModel { public readonly id = createId(); - public constructor(public readonly media: Observable) { + public constructor(public readonly media$: Observable) { super(); } } export class SpotlightTileViewModel extends ViewModel { public constructor( - public readonly media: Observable, - public readonly maximised: Observable, + public readonly media$: Observable, + public readonly maximised$: Observable, ) { super(); } diff --git a/src/state/observeSpeaker.test.ts b/src/state/observeSpeaker.test.ts index daa5f033..2a73482c 100644 --- a/src/state/observeSpeaker.test.ts +++ b/src/state/observeSpeaker.test.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { describe, test } from "vitest"; import { withTestScheduler } from "../utils/test"; -import { observeSpeaker } from "./observeSpeaker"; +import { observeSpeaker$ } from "./observeSpeaker"; const yesNo = { y: true, @@ -22,40 +22,36 @@ describe("observeSpeaker", () => { // should default to false when no input is given const speakingInputMarbles = ""; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("after no speaking", () => { const speakingInputMarbles = "n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with speaking for 1ms", () => { const speakingInputMarbles = "y n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with speaking for 999ms", () => { const speakingInputMarbles = "y 999ms n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -63,20 +59,18 @@ describe("observeSpeaker", () => { const speakingInputMarbles = "y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with consecutive speaking then stops speaking", () => { const speakingInputMarbles = "y y y y y y y y y y n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); }); @@ -87,10 +81,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y"; const expectedOutputMarbles = "n 999ms y"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -98,10 +91,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y 1s n "; const expectedOutputMarbles = "n 999ms y 60s n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -109,10 +101,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y 5s n "; const expectedOutputMarbles = "n 999ms y 64s n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); }); diff --git a/src/state/observeSpeaker.ts b/src/state/observeSpeaker.ts index cce43ef9..8817af25 100644 --- a/src/state/observeSpeaker.ts +++ b/src/state/observeSpeaker.ts @@ -18,16 +18,16 @@ import { * Require 1 second of continuous speaking to become a speaker, and 60 second of * continuous silence to stop being considered a speaker */ -export function observeSpeaker( - isSpeakingObservable: Observable, +export function observeSpeaker$( + isSpeakingObservable$: Observable, ): Observable { - const distinct = isSpeakingObservable.pipe(distinctUntilChanged()); + const distinct$ = isSpeakingObservable$.pipe(distinctUntilChanged()); - return distinct.pipe( + return distinct$.pipe( // Either change to the new value after the timer or re-emit the same value if it toggles back // (audit will return the latest (toggled back) value) before the timeout. audit((s) => - merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))), + merge(timer(s ? 1000 : 60000), distinct$.pipe(filter((s1) => s1 !== s))), ), // Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->.. startWith(false), diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 73c17527..8c6b2d9b 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -83,13 +83,13 @@ const UserMediaTile = forwardRef( ref, ) => { const { t } = useTranslation(); - const video = useObservableEagerState(vm.video); - const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); - const encryptionStatus = useObservableEagerState(vm.encryptionStatus); - const audioEnabled = useObservableEagerState(vm.audioEnabled); - const videoEnabled = useObservableEagerState(vm.videoEnabled); - const speaking = useObservableEagerState(vm.speaking); - const cropVideo = useObservableEagerState(vm.cropVideo); + const video = useObservableEagerState(vm.video$); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); + const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); + const audioEnabled = useObservableEagerState(vm.audioEnabled$); + const videoEnabled = useObservableEagerState(vm.videoEnabled$); + const speaking = useObservableEagerState(vm.speaking$); + const cropVideo = useObservableEagerState(vm.cropVideo$); const onSelectFitContain = useCallback( (e: Event) => { e.preventDefault(); @@ -198,8 +198,8 @@ interface LocalUserMediaTileProps extends TileProps { const LocalUserMediaTile = forwardRef( ({ vm, onOpenProfile, ...props }, ref) => { const { t } = useTranslation(); - const mirror = useObservableEagerState(vm.mirror); - const alwaysShow = useObservableEagerState(vm.alwaysShow); + const mirror = useObservableEagerState(vm.mirror$); + const alwaysShow = useObservableEagerState(vm.alwaysShow$); const latestAlwaysShow = useLatest(alwaysShow); const onSelectAlwaysShow = useCallback( (e: Event) => { @@ -249,8 +249,8 @@ const RemoteUserMediaTile = forwardRef< RemoteUserMediaTileProps >(({ vm, ...props }, ref) => { const { t } = useTranslation(); - const locallyMuted = useObservableEagerState(vm.locallyMuted); - const localVolume = useObservableEagerState(vm.localVolume); + const locallyMuted = useObservableEagerState(vm.locallyMuted$); + const localVolume = useObservableEagerState(vm.localVolume$); const onSelectMute = useCallback( (e: Event) => { e.preventDefault(); @@ -316,7 +316,7 @@ export const GridTile = forwardRef( ({ vm, onOpenProfile, ...props }, theirRef) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); - const media = useObservableEagerState(vm.media); + const media = useObservableEagerState(vm.media$); const displayName = useDisplayName(media); if (media instanceof LocalUserMediaViewModel) { diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index a1c3d46f..c72bad81 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -72,7 +72,7 @@ const SpotlightLocalUserMediaItem = forwardRef< HTMLDivElement, SpotlightLocalUserMediaItemProps >(({ vm, ...props }, ref) => { - const mirror = useObservableEagerState(vm.mirror); + const mirror = useObservableEagerState(vm.mirror$); return ; }); @@ -86,8 +86,8 @@ const SpotlightUserMediaItem = forwardRef< HTMLDivElement, SpotlightUserMediaItemProps >(({ vm, ...props }, ref) => { - const videoEnabled = useObservableEagerState(vm.videoEnabled); - const cropVideo = useObservableEagerState(vm.cropVideo); + const videoEnabled = useObservableEagerState(vm.videoEnabled$); + const cropVideo = useObservableEagerState(vm.cropVideo$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { @@ -110,7 +110,7 @@ interface SpotlightItemProps { vm: MediaViewModel; targetWidth: number; targetHeight: number; - intersectionObserver: Observable; + intersectionObserver$: Observable; /** * Whether this item should act as a scroll snapping point. */ @@ -124,7 +124,7 @@ const SpotlightItem = forwardRef( vm, targetWidth, targetHeight, - intersectionObserver, + intersectionObserver$, snap, "aria-hidden": ariaHidden, }, @@ -133,15 +133,15 @@ const SpotlightItem = forwardRef( const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const displayName = useDisplayName(vm); - const video = useObservableEagerState(vm.video); - const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); - const encryptionStatus = useObservableEagerState(vm.encryptionStatus); + const video = useObservableEagerState(vm.video$); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); + const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); // Hook this item up to the intersection observer useEffect(() => { const element = ourRef.current!; let prevIo: IntersectionObserver | null = null; - const subscription = intersectionObserver.subscribe((io) => { + const subscription = intersectionObserver$.subscribe((io) => { prevIo?.unobserve(element); io.observe(element); prevIo = io; @@ -150,7 +150,7 @@ const SpotlightItem = forwardRef( subscription.unsubscribe(); prevIo?.unobserve(element); }; - }, [intersectionObserver]); + }, [intersectionObserver$]); const baseProps: SpotlightItemBaseProps & RefAttributes = { ref, @@ -208,10 +208,10 @@ export const SpotlightTile = forwardRef( theirRef, ) => { const { t } = useTranslation(); - const [ourRef, root] = useObservableRef(null); + const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); - const maximised = useObservableEagerState(vm.maximised); - const media = useObservableEagerState(vm.media); + const maximised = useObservableEagerState(vm.maximised$); + const media = useObservableEagerState(vm.media$); const [visibleId, setVisibleId] = useState( media[0]?.id, ); @@ -225,9 +225,9 @@ export const SpotlightTile = forwardRef( // hooked up to the root element and the items. Because the items will run // their effects before their parent does, we need to do this dance with an // Observable to actually give them the intersection observer. - const intersectionObserver = useInitial>( + const intersectionObserver$ = useInitial>( () => - root.pipe( + root$.pipe( map( (r) => new IntersectionObserver( @@ -295,7 +295,7 @@ export const SpotlightTile = forwardRef( vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} - intersectionObserver={intersectionObserver} + intersectionObserver$={intersectionObserver$} // This is how we get the container to scroll to the right media // when the previous/next buttons are clicked: we temporarily // remove all scroll snap points except for just the one media diff --git a/src/utils/observable.ts b/src/utils/observable.ts index a54c0293..977bdf79 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -15,10 +15,10 @@ const nothing = Symbol("nothing"); * callback will not be invoked. */ export function finalizeValue(callback: (finalValue: T) => void) { - return (source: Observable): Observable => + return (source$: Observable): Observable => defer(() => { let finalValue: T | typeof nothing = nothing; - return source.pipe( + return source$.pipe( tap((value) => (finalValue = value)), finalize(() => { if (finalValue !== nothing) callback(finalValue); @@ -35,6 +35,6 @@ export function accumulate( initial: State, update: (state: State, event: Event) => State, ) { - return (events: Observable): Observable => - events.pipe(scan(update, initial), startWith(initial)); + return (events$: Observable): Observable => + events$.pipe(scan(update, initial), startWith(initial)); } diff --git a/src/utils/test.ts b/src/utils/test.ts index 1cd21f01..db0d8959 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -77,14 +77,14 @@ export function withTestScheduler( continuation({ ...helpers, schedule(marbles, actions) { - const actionsObservable = helpers + const actionsObservable$ = helpers .cold(marbles) .pipe(map((value) => actions[value]())); const results = Object.fromEntries( Object.keys(actions).map((value) => [value, undefined] as const), ); // Run the actions and verify that none of them error - helpers.expectObservable(actionsObservable).toBe(marbles, results); + helpers.expectObservable(actionsObservable$).toBe(marbles, results); }, }), ); @@ -157,16 +157,16 @@ export function mockMatrixRoom(room: Partial): MatrixRoom { export function mockLivekitRoom( room: Partial, { - remoteParticipants, - }: { remoteParticipants?: Observable } = {}, + remoteParticipants$, + }: { remoteParticipants$?: Observable } = {}, ): LivekitRoom { const livekitRoom = { ...mockEmitter(), ...room, } as Partial as LivekitRoom; - if (remoteParticipants) { + if (remoteParticipants$) { livekitRoom.remoteParticipants = new Map(); - remoteParticipants.subscribe((newRemoteParticipants) => { + remoteParticipants$.subscribe((newRemoteParticipants) => { livekitRoom.remoteParticipants.clear(); newRemoteParticipants.forEach((p) => { livekitRoom.remoteParticipants.set(p.identity, p); @@ -238,7 +238,7 @@ export async function withRemoteMedia( { kind: E2eeType.PER_PARTICIPANT, }, - mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }), + mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), ); try { await continuation(vm); @@ -277,9 +277,9 @@ export class MockRTCSession extends TypedEventEmitter< } public withMemberships( - rtcMembers: Observable[]>, + rtcMembers$: Observable[]>, ): MockRTCSession { - rtcMembers.subscribe((m) => { + rtcMembers$.subscribe((m) => { const old = this.memberships; // always prepend the local participant const updated = [this.localMembership, ...(m as CallMembership[])];