Import unfinished mute states refactor
This commit is contained in:
@@ -38,7 +38,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
|||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { findDeviceByName } from "../utils/media";
|
import { findDeviceByName } from "../utils/media";
|
||||||
import { ActiveCall } from "./InCallView";
|
import { ActiveCall } from "./InCallView";
|
||||||
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||||
import { leaveRTCSession } from "../rtcSessionHelpers";
|
import { leaveRTCSession } from "../rtcSessionHelpers";
|
||||||
@@ -76,6 +76,12 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
|||||||
import { useAppBarTitle } from "../AppBar.tsx";
|
import { useAppBarTitle } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
rtcSession?: MatrixRTCSession;
|
rtcSession?: MatrixRTCSession;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
UnknownSolidIcon,
|
UnknownSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { useObservable } from "observable-hooks";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import { useClientLegacy } from "../ClientContext";
|
import { useClientLegacy } from "../ClientContext";
|
||||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||||
@@ -35,12 +37,13 @@ import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
|||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useMuteStates } from "./MuteStates";
|
|
||||||
import { useOptInAnalytics } from "../settings/settings";
|
import { useOptInAnalytics } from "../settings/settings";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { ErrorView } from "../ErrorView";
|
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 = () => {
|
export const RoomPage: FC = () => {
|
||||||
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
|
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
|
||||||
@@ -62,7 +65,18 @@ export const RoomPage: FC = () => {
|
|||||||
|
|
||||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
const muteStates = useMuteStates(joined);
|
|
||||||
|
const devices = useMediaDevices();
|
||||||
|
const [muteStates, setMuteStates] = useState<MuteStates | null>(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(() => {
|
useEffect(() => {
|
||||||
// If we've finished loading, are not already authed and we've been given a display name as
|
// 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]);
|
}, [groupCallState.kind]);
|
||||||
|
|
||||||
const groupCallView = (): JSX.Element => {
|
const groupCallView = (): ReactNode => {
|
||||||
switch (groupCallState.kind) {
|
switch (groupCallState.kind) {
|
||||||
case "loaded":
|
case "loaded":
|
||||||
return (
|
return muteStates && (
|
||||||
<GroupCallView
|
<GroupCallView
|
||||||
widget={widget}
|
widget={widget}
|
||||||
client={client!}
|
client={client!}
|
||||||
@@ -134,7 +148,7 @@ export const RoomPage: FC = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<LobbyView
|
muteStates && <LobbyView
|
||||||
client={client!}
|
client={client!}
|
||||||
matrixInfo={{
|
matrixInfo={{
|
||||||
userId: client!.getUserId() ?? "",
|
userId: client!.getUserId() ?? "",
|
||||||
|
|||||||
163
src/state/MuteStates.ts
Normal file
163
src/state/MuteStates.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023-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 { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
withLatestFrom,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
|
||||||
|
import { ElementWidgetActions, widget } from "../widget";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
import { getUrlParams } from "../UrlParams";
|
||||||
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
|
import { accumulate } from "../utils/observable";
|
||||||
|
|
||||||
|
interface MuteStateData {
|
||||||
|
enabled$: Observable<boolean>;
|
||||||
|
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<MuteStateData> =
|
||||||
|
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<boolean>();
|
||||||
|
const toggle$ = new Subject<void>();
|
||||||
|
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<boolean> = 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<boolean>,
|
||||||
|
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<boolean>,
|
||||||
|
) {
|
||||||
|
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<CustomEvent<IWidgetApiRequest>>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user