From d46fe55a670190a58822312725de3d08ce12e0df Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 28 Aug 2025 17:40:35 +0200 Subject: [PATCH] Import unfinished mute states refactor --- src/room/GroupCallView.tsx | 8 +- src/room/RoomPage.tsx | 26 ++++-- src/state/MuteStates.ts | 163 +++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/state/MuteStates.ts diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 8562d4f8..40ec4627 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -38,7 +38,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useProfile } from "../profile/useProfile"; import { findDeviceByName } from "../utils/media"; import { ActiveCall } from "./InCallView"; -import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates"; +import { type MuteStates } from "../state/MuteStates"; import { useMediaDevices } from "../MediaDevicesContext"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { leaveRTCSession } from "../rtcSessionHelpers"; @@ -76,6 +76,12 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +/** + * If there already are this many participants in the call, we automatically mute + * the user. + */ +export const MUTE_PARTICIPANT_COUNT = 8; + declare global { interface Window { rtcSession?: MatrixRTCSession; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 480f8706..3924437b 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -20,6 +20,8 @@ import { CheckIcon, UnknownSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useObservable } from "observable-hooks"; +import { map } from "rxjs"; import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; @@ -35,12 +37,13 @@ import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; import { E2eeType } from "../e2ee/e2eeType"; import { useProfile } from "../profile/useProfile"; -import { useMuteStates } from "./MuteStates"; import { useOptInAnalytics } from "../settings/settings"; import { Config } from "../config/Config"; import { Link } from "../button/Link"; import { ErrorView } from "../ErrorView"; -import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; +import { useMediaDevices } from "../MediaDevicesContext"; +import { MuteStates } from "../state/MuteStates"; +import { ObservableScope } from "../state/ObservableScope"; export const RoomPage: FC = () => { const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } = @@ -62,7 +65,18 @@ export const RoomPage: FC = () => { const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); const [joined, setJoined] = useState(false); - const muteStates = useMuteStates(joined); + + const devices = useMediaDevices(); + const [muteStates, setMuteStates] = useState(null); + const joined$ = useObservable( + (inputs$) => inputs$.pipe(map(([joined]) => joined)), + [joined], + ); + useEffect(() => { + const scope = new ObservableScope(); + setMuteStates(new MuteStates(scope, devices, joined$)); + return (): void => scope.end(); + }, [devices, joined$]); useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as @@ -99,10 +113,10 @@ export const RoomPage: FC = () => { } }, [groupCallState.kind]); - const groupCallView = (): JSX.Element => { + const groupCallView = (): ReactNode => { switch (groupCallState.kind) { case "loaded": - return ( + return muteStates && ( { ); return ( - ; + set: ((enabled: boolean) => void) | null; + toggle: (() => void) | null; +} + +class MuteState { + private readonly enabledByDefault$ = + this.enabledByConfig && !getUrlParams().skipLobby + ? this.isJoined$.pipe(map((isJoined) => !isJoined)) + : of(false); + + private readonly data$: Observable = + this.device.available$.pipe( + map((available) => available.size > 0), + distinctUntilChanged(), + withLatestFrom( + this.enabledByDefault$, + (devicesConnected, enabledByDefault) => { + if (!devicesConnected) + return { enabled$: of(false), set: null, toggle: null }; + + const set$ = new Subject(); + const toggle$ = new Subject(); + return { + set: (enabled: boolean) => set$.next(enabled), + toggle: () => toggle$.next(), + // Assume the default value only once devices are actually connected + enabled$: merge( + set$, + toggle$.pipe(map(() => "toggle" as const)), + ).pipe( + accumulate(enabledByDefault, (prev, update) => + update === "toggle" ? !prev : update, + ), + ), + }; + }, + ), + this.scope.state(), + ); + + public readonly enabled$: Observable = this.data$.pipe( + switchMap(({ enabled$ }) => enabled$), + ); + + public readonly setEnabled$: Observable<((enabled: boolean) => void) | null> = + this.data$.pipe(map(({ set }) => set)); + + public readonly toggle$: Observable<(() => void) | null> = this.data$.pipe( + map(({ toggle }) => toggle), + ); + + public constructor( + private readonly scope: ObservableScope, + private readonly device: MediaDevice, + private readonly isJoined$: Observable, + private readonly enabledByConfig: boolean, + ) {} +} + +export class MuteStates { + public readonly audio = new MuteState( + this.scope, + this.mediaDevices.audioInput, + this.isJoined$, + Config.get().media_devices.enable_video, + ); + public readonly video = new MuteState( + this.scope, + this.mediaDevices.videoInput, + this.isJoined$, + Config.get().media_devices.enable_video, + ); + + public constructor( + private readonly scope: ObservableScope, + private readonly mediaDevices: MediaDevices, + private readonly isJoined$: Observable, + ) { + if (widget !== null) { + // Sync our mute states with the hosting client + const widgetApiState$ = combineLatest( + [this.audio.enabled$, this.video.enabled$], + (audio, video) => ({ audio_enabled: audio, video_enabled: video }), + ); + widgetApiState$.pipe(this.scope.bind()).subscribe((state) => { + widget!.api.transport + .send(ElementWidgetActions.DeviceMute, state) + .catch((e) => + logger.warn("Could not send DeviceMute action to widget", e), + ); + }); + + // Also sync the hosting client's mute states back with ours + const muteActions$ = fromEvent( + widget.lazyActions, + ElementWidgetActions.DeviceMute, + ) as Observable>; + muteActions$ + .pipe( + withLatestFrom( + widgetApiState$, + this.audio.setEnabled$, + this.video.setEnabled$, + ), + this.scope.bind(), + ) + .subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => { + // First copy the current state into our new state + const newState = { ...state }; + // Update new state if there are any requested changes from the widget + // action in `ev.detail.data`. + if ( + ev.detail.data.audio_enabled != null && + typeof ev.detail.data.audio_enabled === "boolean" && + setAudioEnabled !== null + ) { + newState.audio_enabled = ev.detail.data.audio_enabled; + setAudioEnabled(newState.audio_enabled); + } + if ( + ev.detail.data.video_enabled != null && + typeof ev.detail.data.video_enabled === "boolean" && + setVideoEnabled !== null + ) { + newState.video_enabled = ev.detail.data.video_enabled; + setVideoEnabled(newState.video_enabled); + } + widget!.api.transport.reply(ev.detail, newState); + }); + } + } +}