mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Move the state layer onto the host bridge
MuteStates, CallViewModel and LocalMember reached the host through the widget global. None of them are React components, so they take the bridge as an explicit parameter: a constructor argument for MuteStates, a field on CallViewModelOptions, and one on createLocalMembership$'s props. src/state no longer refers to the widget API. The conditionals around it mostly disappear: nullHostBridge's observables are NEVER, so there is nothing to guard, and a request carries its own reply rather than needing the transport and the original event. CallViewModelWidget.test.ts drove hangup by emitting on the mocked widget's action emitter, so it now injects a bridge instead, and checks that the request is acknowledged.
This commit is contained in:
+9
-4
@@ -60,6 +60,7 @@ import {
|
|||||||
initializeWidget,
|
initializeWidget,
|
||||||
} from "../src/widget";
|
} from "../src/widget";
|
||||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||||
|
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||||
|
|
||||||
interface MatrixRTCSdk {
|
interface MatrixRTCSdk {
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +114,7 @@ export async function createMatrixRTCSdk(
|
|||||||
const widget = _widget;
|
const widget = _widget;
|
||||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||||
const client = await widget.client;
|
const client = await widget.client;
|
||||||
|
const hostBridge = createWidgetHostBridge(widget);
|
||||||
logger.info("client created");
|
logger.info("client created");
|
||||||
|
|
||||||
// url params
|
// url params
|
||||||
@@ -132,10 +134,12 @@ export async function createMatrixRTCSdk(
|
|||||||
controlledAudioDevices,
|
controlledAudioDevices,
|
||||||
callIntent,
|
callIntent,
|
||||||
});
|
});
|
||||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
const muteStates = new MuteStates(
|
||||||
audioEnabled: false,
|
scope,
|
||||||
videoEnabled: false,
|
mediaDevices,
|
||||||
});
|
{ audioEnabled: false, videoEnabled: false },
|
||||||
|
hostBridge,
|
||||||
|
);
|
||||||
|
|
||||||
// call view model
|
// call view model
|
||||||
const callViewModel = createCallViewModel$(
|
const callViewModel = createCallViewModel$(
|
||||||
@@ -146,6 +150,7 @@ export async function createMatrixRTCSdk(
|
|||||||
muteStates,
|
muteStates,
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
hostBridge,
|
||||||
controlledAudioDevices,
|
controlledAudioDevices,
|
||||||
callIntent,
|
callIntent,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
|||||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -116,6 +117,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
||||||
|
|
||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,6 +143,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.muteStates,
|
props.muteStates,
|
||||||
{
|
{
|
||||||
encryptionSystem: props.e2eeSystem,
|
encryptionSystem: props.e2eeSystem,
|
||||||
|
hostBridge,
|
||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
matrixRTCMode$: matrixRTCModeSetting.value$,
|
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||||
@@ -171,6 +174,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
props.onLeft,
|
props.onLeft,
|
||||||
urlParams,
|
urlParams,
|
||||||
|
hostBridge,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
trackProcessorState$,
|
trackProcessorState$,
|
||||||
props.client,
|
props.client,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
|||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
import { HomePage } from "../home/HomePage";
|
import { HomePage } from "../home/HomePage";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
@@ -44,6 +45,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts";
|
|||||||
|
|
||||||
export const RoomPage: FC = (): ReactNode => {
|
export const RoomPage: FC = (): ReactNode => {
|
||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
|
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||||
@@ -77,10 +79,11 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
urlParams.callIntent,
|
urlParams.callIntent,
|
||||||
widget !== null,
|
widget !== null,
|
||||||
),
|
),
|
||||||
|
hostBridge,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return (): void => scope.end();
|
return (): void => scope.end();
|
||||||
}, [devices, urlParams]);
|
}, [devices, urlParams, hostBridge]);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
type RTCCallIntent,
|
type RTCCallIntent,
|
||||||
type RTCNotificationType,
|
type RTCNotificationType,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
@@ -89,7 +88,7 @@ import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
|||||||
import { type MuteStates } from "../MuteStates";
|
import { type MuteStates } from "../MuteStates";
|
||||||
import { HeaderStyle } from "../../UrlParams";
|
import { HeaderStyle } from "../../UrlParams";
|
||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { ElementWidgetActions, widget } from "../../widget";
|
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||||
import {
|
import {
|
||||||
layoutShallowEquals,
|
layoutShallowEquals,
|
||||||
type Alignment,
|
type Alignment,
|
||||||
@@ -173,6 +172,11 @@ import { type GridTileViewModel } from "../TileViewModel.ts";
|
|||||||
// callMembership -> rtcMembership
|
// callMembership -> rtcMembership
|
||||||
export interface CallViewModelOptions {
|
export interface CallViewModelOptions {
|
||||||
encryptionSystem: EncryptionSystem;
|
encryptionSystem: EncryptionSystem;
|
||||||
|
/**
|
||||||
|
* The application hosting Element Call, which can ask it to hang up and wants
|
||||||
|
* to know when the user joins or leaves. Defaults to no host.
|
||||||
|
*/
|
||||||
|
hostBridge?: HostBridge;
|
||||||
/**
|
/**
|
||||||
* Whether the app hosting Element Call controls the audio output devices,
|
* Whether the app hosting Element Call controls the audio output devices,
|
||||||
* rather than the browser. Defaults to false.
|
* rather than the browser. Defaults to false.
|
||||||
@@ -457,6 +461,7 @@ export function createCallViewModel$(
|
|||||||
// Defaults match what the URL parameters resolve to outside of widget mode,
|
// Defaults match what the URL parameters resolve to outside of widget mode,
|
||||||
// so that callers which don't care (chiefly tests) behave as they always have.
|
// so that callers which don't care (chiefly tests) behave as they always have.
|
||||||
const {
|
const {
|
||||||
|
hostBridge = nullHostBridge,
|
||||||
controlledAudioDevices = false,
|
controlledAudioDevices = false,
|
||||||
header = HeaderStyle.Standard,
|
header = HeaderStyle.Standard,
|
||||||
showControls = true,
|
showControls = true,
|
||||||
@@ -632,6 +637,7 @@ export function createCallViewModel$(
|
|||||||
localTransport$,
|
localTransport$,
|
||||||
roomId: matrixRoom.roomId,
|
roomId: matrixRoom.roomId,
|
||||||
hideScreensharing,
|
hideScreensharing,
|
||||||
|
hostBridge,
|
||||||
logger: logger.getChild(`[${Date.now()}]`),
|
logger: logger.getChild(`[${Date.now()}]`),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -923,24 +929,16 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
const userHangup$ = new Subject<void>();
|
const userHangup$ = new Subject<void>();
|
||||||
|
|
||||||
const widgetHangup$ =
|
const hostHangup$ = hostBridge.hangUp$.pipe(
|
||||||
widget === null
|
tap((request) => {
|
||||||
? NEVER
|
request.reply();
|
||||||
: (
|
|
||||||
fromEvent(
|
|
||||||
widget.lazyActions,
|
|
||||||
ElementWidgetActions.HangupCall,
|
|
||||||
) as Observable<CustomEvent<IWidgetApiRequest>>
|
|
||||||
).pipe(
|
|
||||||
tap((ev) => {
|
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
|
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
|
||||||
merge(
|
merge(
|
||||||
autoLeave$,
|
autoLeave$,
|
||||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)),
|
||||||
).pipe(scope.share);
|
).pipe(scope.share);
|
||||||
|
|
||||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
|||||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||||
import { type Publisher } from "./Publisher";
|
import { type Publisher } from "./Publisher";
|
||||||
import { initializeWidget } from "../../../widget";
|
import { initializeWidget } from "../../../widget";
|
||||||
|
import { nullHostBridge } from "../../../HostBridge";
|
||||||
import {
|
import {
|
||||||
type LocalTransport,
|
type LocalTransport,
|
||||||
type LocalTransportWithSFUConfig,
|
type LocalTransportWithSFUConfig,
|
||||||
@@ -200,6 +201,7 @@ describe("LocalMembership", () => {
|
|||||||
},
|
},
|
||||||
roomId: "!test-room-id:example.org",
|
roomId: "!test-room-id:example.org",
|
||||||
hideScreensharing: false,
|
hideScreensharing: false,
|
||||||
|
hostBridge: nullHostBridge,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
it("throws error on missing RTC config error", () => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import {
|
|||||||
MembershipManagerError,
|
MembershipManagerError,
|
||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
import { ElementWidgetActions, widget } from "../../../widget.ts";
|
import { type HostBridge } from "../../../HostBridge.ts";
|
||||||
|
|
||||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||||
import {
|
import {
|
||||||
@@ -145,6 +145,8 @@ interface Props {
|
|||||||
>;
|
>;
|
||||||
/** Whether to hide the screen-sharing button. */
|
/** Whether to hide the screen-sharing button. */
|
||||||
hideScreensharing: boolean;
|
hideScreensharing: boolean;
|
||||||
|
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||||
|
hostBridge: HostBridge;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,7 @@ interface Props {
|
|||||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||||
* @param props.roomId The room ID used as the call identifier in analytics events.
|
* @param props.roomId The room ID used as the call identifier in analytics events.
|
||||||
* @param props.hideScreensharing Whether to hide the screen-sharing button.
|
* @param props.hideScreensharing Whether to hide the screen-sharing button.
|
||||||
|
* @param props.hostBridge The application hosting Element Call.
|
||||||
* @returns
|
* @returns
|
||||||
* - publisher: The handle to create tracks and publish them to the room.
|
* - publisher: The handle to create tracks and publish them to the room.
|
||||||
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
||||||
@@ -184,6 +187,7 @@ export const createLocalMembership$ = ({
|
|||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
roomId,
|
roomId,
|
||||||
hideScreensharing,
|
hideScreensharing,
|
||||||
|
hostBridge,
|
||||||
}: Props): {
|
}: Props): {
|
||||||
/**
|
/**
|
||||||
* This request to start audio and video tracks.
|
* This request to start audio and video tracks.
|
||||||
@@ -576,28 +580,23 @@ export const createLocalMembership$ = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// inform the widget about the connect and disconnect intent from the user.
|
// inform the host about the connect and disconnect intent from the user.
|
||||||
scope
|
scope
|
||||||
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||||
undefined,
|
undefined,
|
||||||
joinAndPublishRequested$.value,
|
joinAndPublishRequested$.value,
|
||||||
])
|
])
|
||||||
.subscribe(([prev, current]) => {
|
.subscribe(([prev, current]) => {
|
||||||
if (!widget) return;
|
|
||||||
// JOIN prev=false (was left) => current-true (now joiend)
|
// JOIN prev=false (was left) => current-true (now joiend)
|
||||||
if (!prev && current) {
|
if (!prev && current) {
|
||||||
widget.api.transport
|
hostBridge.notifyJoined().catch((e) => {
|
||||||
.send(ElementWidgetActions.JoinCall, {})
|
logger.error("Failed to notify the host that we joined", e);
|
||||||
.catch((e) => {
|
|
||||||
logger.error("Failed to send join action", e);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// LEAVE prev=false (was joined) => current-true (now left)
|
// LEAVE prev=false (was joined) => current-true (now left)
|
||||||
if (prev && !current) {
|
if (prev && !current) {
|
||||||
widget.api.transport
|
hostBridge.notifyHungUp().catch((e) => {
|
||||||
.send(ElementWidgetActions.HangupCall, {})
|
logger.error("Failed to notify the host that we hung up", e);
|
||||||
.catch((e) => {
|
|
||||||
logger.error("Failed to send hangup action", e);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -845,7 +844,7 @@ interface EnterRTCSessionOptions {
|
|||||||
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
|
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
|
||||||
* Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of
|
* Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of
|
||||||
* notification to send on join. `callIntent`: The kind of call being placed.
|
* notification to send on join. `callIntent`: The kind of call being placed.
|
||||||
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
* @throws If the host could not be told that we are joining.
|
||||||
*/
|
*/
|
||||||
// Exported for unit testing
|
// Exported for unit testing
|
||||||
export function enterRTCSession(
|
export function enterRTCSession(
|
||||||
|
|||||||
@@ -6,39 +6,31 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { it, vi, expect } from "vitest";
|
import { it, vi, expect } from "vitest";
|
||||||
import EventEmitter from "events";
|
import { Subject } from "rxjs";
|
||||||
|
|
||||||
// import * as ComponentsCore from "@livekit/components-core";
|
// import * as ComponentsCore from "@livekit/components-core";
|
||||||
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
|
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
|
||||||
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
|
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
|
||||||
import { constant } from "./Behavior.ts";
|
import { constant } from "./Behavior.ts";
|
||||||
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
||||||
import { ElementWidgetActions, widget } from "../widget.ts";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
type HostRequest,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "../HostBridge.ts";
|
||||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||||
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
||||||
|
|
||||||
vi.mock("@livekit/components-core", { spy: true });
|
vi.mock("@livekit/components-core", { spy: true });
|
||||||
|
|
||||||
vi.mock("../widget", () => ({
|
|
||||||
ElementWidgetActions: {
|
|
||||||
HangupCall: "HangupCall",
|
|
||||||
// Add other actions if needed
|
|
||||||
},
|
|
||||||
widget: {
|
|
||||||
api: {
|
|
||||||
transport: {
|
|
||||||
send: vi.fn().mockResolvedValue(undefined),
|
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lazyActions: new EventEmitter(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
||||||
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
|
"expect leave when the host asks us to hang up (%s mode)",
|
||||||
async (mode) => {
|
async (mode) => {
|
||||||
const pr = Promise.withResolvers<string>();
|
const pr = Promise.withResolvers<string>();
|
||||||
|
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
|
||||||
|
const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ };
|
||||||
|
const reply = vi.fn();
|
||||||
|
|
||||||
withCallViewModel(mode)(
|
withCallViewModel(mode)(
|
||||||
{
|
{
|
||||||
remoteParticipants$: constant([aliceParticipant]),
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
@@ -49,25 +41,17 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
|||||||
pr.resolve(s);
|
pr.resolve(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
widget!.lazyActions!.emit(
|
hangUp$.next({ data: {}, reply });
|
||||||
ElementWidgetActions.HangupCall,
|
|
||||||
new CustomEvent(ElementWidgetActions.HangupCall, {
|
|
||||||
detail: {
|
|
||||||
action: "im.vector.hangup",
|
|
||||||
api: "toWidget",
|
|
||||||
data: {},
|
|
||||||
requestId: "widgetapi-1761237395918",
|
|
||||||
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
hostBridge,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const source = await pr.promise;
|
const source = await pr.promise;
|
||||||
expect(source).toBe("user");
|
expect(source).toBe("user");
|
||||||
|
// The host expects to hear back that we acted on its request
|
||||||
|
expect(reply).toHaveBeenCalledOnce();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { nullHostBridge } from "../HostBridge";
|
||||||
import { MuteStates, MuteState } from "./MuteStates";
|
import { MuteStates, MuteState } from "./MuteStates";
|
||||||
import {
|
import {
|
||||||
type AudioOutputDeviceLabel,
|
type AudioOutputDeviceLabel,
|
||||||
@@ -228,10 +229,12 @@ describe("MuteStates", () => {
|
|||||||
videoInput: aVideoInput(),
|
videoInput: aVideoInput(),
|
||||||
// other devices are not relevant for this test
|
// other devices are not relevant for this test
|
||||||
});
|
});
|
||||||
const muteStates = new MuteStates(testScope, mediaDevices, {
|
const muteStates = new MuteStates(
|
||||||
audioEnabled: false,
|
testScope,
|
||||||
videoEnabled: false,
|
mediaDevices,
|
||||||
});
|
{ audioEnabled: false, videoEnabled: false },
|
||||||
|
nullHostBridge,
|
||||||
|
);
|
||||||
|
|
||||||
let latestSyncedState: boolean | null = null;
|
let latestSyncedState: boolean | null = null;
|
||||||
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
|
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
|
||||||
|
|||||||
+22
-31
@@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
fromEvent,
|
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
Observable,
|
Observable,
|
||||||
@@ -24,7 +22,7 @@ import {
|
|||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
|
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
|
||||||
import { ElementWidgetActions, widget } from "../widget";
|
import { type DeviceMuteState, type HostBridge } from "../HostBridge";
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
import { type Behavior, constant } from "./Behavior";
|
import { type Behavior, constant } from "./Behavior";
|
||||||
|
|
||||||
@@ -216,58 +214,51 @@ export class MuteStates {
|
|||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
},
|
},
|
||||||
|
hostBridge: HostBridge,
|
||||||
) {
|
) {
|
||||||
if (widget !== null) {
|
// Keep the host informed of our mute state
|
||||||
// Sync our mute states with the hosting client
|
const muteState$ = combineLatest(
|
||||||
const widgetApiState$ = combineLatest(
|
|
||||||
[this.audio.enabled$, this.video.enabled$],
|
[this.audio.enabled$, this.video.enabled$],
|
||||||
(audio, video) => ({ audio_enabled: audio, video_enabled: video }),
|
(audio, video): DeviceMuteState => ({
|
||||||
);
|
audio_enabled: audio,
|
||||||
widgetApiState$.pipe(this.scope.bind()).subscribe((state) => {
|
video_enabled: video,
|
||||||
widget!.api.transport
|
}),
|
||||||
.send(ElementWidgetActions.DeviceMute, state)
|
|
||||||
.catch((e) =>
|
|
||||||
logger.warn("Could not send DeviceMute action to widget", e),
|
|
||||||
);
|
);
|
||||||
|
muteState$.pipe(this.scope.bind()).subscribe((state) => {
|
||||||
|
hostBridge
|
||||||
|
.notifyDeviceMute(state)
|
||||||
|
.catch((e) => logger.warn("Could not send mute state to the host", e));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also sync the hosting client's mute states back with ours
|
// And apply the changes the host asks for
|
||||||
const muteActions$ = fromEvent(
|
hostBridge.deviceMute$
|
||||||
widget.lazyActions,
|
|
||||||
ElementWidgetActions.DeviceMute,
|
|
||||||
) as Observable<CustomEvent<IWidgetApiRequest>>;
|
|
||||||
muteActions$
|
|
||||||
.pipe(
|
.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
widgetApiState$,
|
muteState$,
|
||||||
this.audio.setEnabled$,
|
this.audio.setEnabled$,
|
||||||
this.video.setEnabled$,
|
this.video.setEnabled$,
|
||||||
),
|
),
|
||||||
this.scope.bind(),
|
this.scope.bind(),
|
||||||
)
|
)
|
||||||
.subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => {
|
.subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => {
|
||||||
// First copy the current state into our new state
|
// First copy the current state into our new state
|
||||||
const newState = { ...state };
|
const newState = { ...state };
|
||||||
// Update new state if there are any requested changes from the widget
|
// Then apply whichever changes the host asked for
|
||||||
// action in `ev.detail.data`.
|
|
||||||
if (
|
if (
|
||||||
ev.detail.data.audio_enabled != null &&
|
typeof request.data.audio_enabled === "boolean" &&
|
||||||
typeof ev.detail.data.audio_enabled === "boolean" &&
|
|
||||||
setAudioEnabled !== null
|
setAudioEnabled !== null
|
||||||
) {
|
) {
|
||||||
newState.audio_enabled = ev.detail.data.audio_enabled;
|
newState.audio_enabled = request.data.audio_enabled;
|
||||||
setAudioEnabled(newState.audio_enabled);
|
setAudioEnabled(newState.audio_enabled);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
ev.detail.data.video_enabled != null &&
|
typeof request.data.video_enabled === "boolean" &&
|
||||||
typeof ev.detail.data.video_enabled === "boolean" &&
|
|
||||||
setVideoEnabled !== null
|
setVideoEnabled !== null
|
||||||
) {
|
) {
|
||||||
newState.video_enabled = ev.detail.data.video_enabled;
|
newState.video_enabled = request.data.video_enabled;
|
||||||
setVideoEnabled(newState.video_enabled);
|
setVideoEnabled(newState.video_enabled);
|
||||||
}
|
}
|
||||||
widget!.api.transport.reply(ev.detail, newState);
|
request.reply(newState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+7
-4
@@ -63,6 +63,7 @@ import { type MediaDevices } from "../state/MediaDevices";
|
|||||||
import { type Behavior, constant } from "../state/Behavior";
|
import { type Behavior, constant } from "../state/Behavior";
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
import { ObservableScope } from "../state/ObservableScope";
|
||||||
import { MuteStates } from "../state/MuteStates";
|
import { MuteStates } from "../state/MuteStates";
|
||||||
|
import { nullHostBridge } from "../HostBridge";
|
||||||
import {
|
import {
|
||||||
createLocalUserMedia,
|
createLocalUserMedia,
|
||||||
type LocalUserMediaViewModel,
|
type LocalUserMediaViewModel,
|
||||||
@@ -577,10 +578,12 @@ export function mockMuteStates(
|
|||||||
joined$: Observable<boolean> = of(true),
|
joined$: Observable<boolean> = of(true),
|
||||||
): MuteStates {
|
): MuteStates {
|
||||||
const observableScope = new ObservableScope();
|
const observableScope = new ObservableScope();
|
||||||
return new MuteStates(observableScope, mockMediaDevices({}), {
|
return new MuteStates(
|
||||||
audioEnabled: false,
|
observableScope,
|
||||||
videoEnabled: false,
|
mockMediaDevices({}),
|
||||||
});
|
{ audioEnabled: false, videoEnabled: false },
|
||||||
|
nullHostBridge,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MockConnection extends Connection {
|
export class MockConnection extends Connection {
|
||||||
|
|||||||
Reference in New Issue
Block a user