From 8baf06c3fff6c5c0ae4e1935be630631db6592c7 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Mar 2022 09:12:09 -0400 Subject: [PATCH] Show voice room participants when not connected (#8136) * Add utility for getting connected voice participants * Allow voice room members to send connected device state * Update connected devices when connecting/disconnecting voice * Show voice room participants in room tile when not connected * Update voice room tests * Add null types and guards --- src/components/views/rooms/RoomTile.tsx | 80 +++++++--- src/createRoom.ts | 20 ++- src/stores/VoiceChannelStore.ts | 51 +++++- src/utils/VoiceChannelUtils.ts | 21 ++- test/components/views/rooms/RoomTile-test.tsx | 150 ++++++++++-------- .../views/voip/VoiceChannelRadio-test.tsx | 44 +---- test/stores/VoiceChannelStore-test.ts | 95 +++++++++++ test/test-utils/index.ts | 1 + test/test-utils/test-utils.ts | 1 + test/test-utils/voice.ts | 60 +++++++ 10 files changed, 379 insertions(+), 144 deletions(-) create mode 100644 test/stores/VoiceChannelStore-test.ts create mode 100644 test/test-utils/voice.ts diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 8f6a9be885f..bdea7ea9f81 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -19,6 +19,8 @@ import React, { createRef } from "react"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; @@ -32,6 +34,7 @@ import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextM import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import BaseAvatar from "../avatars/BaseAvatar"; +import MemberAvatar from "../avatars/MemberAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import FacePile from "../elements/FacePile"; import { RoomNotifState } from "../../../RoomNotifs"; @@ -53,6 +56,7 @@ import IconizedContextMenu, { IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import VoiceChannelStore, { VoiceChannelEvent, IJitsiParticipant } from "../../../stores/VoiceChannelStore"; +import { getConnectedMembers } from "../../../utils/VoiceChannelUtils"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -80,7 +84,10 @@ interface IState { generalMenuPosition: PartialDOMRect; messagePreview?: string; voiceConnectionState: VoiceConnectionState; - voiceParticipants: IJitsiParticipant[]; + // Active voice channel members, according to room state + voiceMembers: RoomMember[]; + // Active voice channel members, according to Jitsi + jitsiParticipants: IJitsiParticipant[]; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -112,7 +119,8 @@ export default class RoomTile extends React.PureComponent { messagePreview: "", voiceConnectionState: VoiceChannelStore.instance.roomId === this.props.room.roomId ? VoiceConnectionState.Connected : VoiceConnectionState.Disconnected, - voiceParticipants: [], + voiceMembers: [], + jitsiParticipants: [], }; this.generatePreview(); @@ -157,6 +165,8 @@ export default class RoomTile extends React.PureComponent { MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); + prevProps.room?.currentState?.off(RoomStateEvent.Events, this.updateVoiceMembers); + this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVoiceMembers); prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate); this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate); } @@ -167,6 +177,7 @@ export default class RoomTile extends React.PureComponent { if (this.state.selected) { this.scrollIntoView(); } + this.updateVoiceMembers(); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); this.dispatcherRef = defaultDispatcher.register(this.onAction); @@ -177,6 +188,7 @@ export default class RoomTile extends React.PureComponent { this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate); + this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVoiceMembers); } public componentWillUnmount() { @@ -186,6 +198,7 @@ export default class RoomTile extends React.PureComponent { MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); + this.props.room.currentState.off(RoomStateEvent.Events, this.updateVoiceMembers); this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate); } ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); @@ -571,24 +584,40 @@ export default class RoomTile extends React.PureComponent { ); } - private updateVoiceParticipants = (participants: IJitsiParticipant[]) => { - this.setState({ voiceParticipants: participants }); + private updateVoiceMembers = () => { + this.setState({ voiceMembers: getConnectedMembers(this.props.room.currentState) }); }; - private renderVoiceChannel(): React.ReactElement { - if (!this.state.voiceParticipants.length) return null; - - const faces = this.state.voiceParticipants.map(p => - , - ); + private updateJitsiParticipants = (participants: IJitsiParticipant[]) => { + this.setState({ jitsiParticipants: participants }); + }; + + private renderVoiceChannel(): React.ReactElement | null { + let faces; + if (this.state.voiceConnectionState === VoiceConnectionState.Connected) { + faces = this.state.jitsiParticipants.map(p => + , + ); + } else if (this.state.voiceMembers.length) { + faces = this.state.voiceMembers.map(m => + , + ); + } else { + return null; + } // TODO: The below "join" button will eventually show up on text rooms // with an active voice channel, but that isn't implemented yet @@ -615,21 +644,24 @@ export default class RoomTile extends React.PureComponent { // effort to solve this properly. await new Promise(resolve => setTimeout(resolve, 1000)); + const waitForConnect = VoiceChannelStore.instance.connect(this.props.room.roomId); + // Participant data comes down the event channel quickly, so prepare in advance + VoiceChannelStore.instance.on(VoiceChannelEvent.Participants, this.updateJitsiParticipants); try { - await VoiceChannelStore.instance.connect(this.props.room.roomId); - + await waitForConnect; this.setState({ voiceConnectionState: VoiceConnectionState.Connected }); + VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, () => { this.setState({ voiceConnectionState: VoiceConnectionState.Disconnected, - voiceParticipants: [], + jitsiParticipants: [], }), - VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateVoiceParticipants); + VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateJitsiParticipants); }); - VoiceChannelStore.instance.on(VoiceChannelEvent.Participants, this.updateVoiceParticipants); } catch (e) { + // If it failed, clean up our advance preparations logger.error("Failed to connect voice", e); - this.setState({ voiceConnectionState: VoiceConnectionState.Disconnected }); + VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateJitsiParticipants); } } diff --git a/src/createRoom.ts b/src/createRoom.ts index c0b1c243bd3..91eb1b45e95 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -42,7 +42,7 @@ import { isJoinedOrNearlyJoined } from "./utils/membership"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler"; import SpaceStore from "./stores/spaces/SpaceStore"; import { makeSpaceParentEvent } from "./utils/space"; -import { addVoiceChannel } from "./utils/VoiceChannelUtils"; +import { VOICE_CHANNEL_MEMBER, addVoiceChannel } from "./utils/VoiceChannelUtils"; import { Action } from "./dispatcher/actions"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import Spinner from "./components/views/elements/Spinner"; @@ -127,6 +127,24 @@ export default async function createRoom(opts: IOpts): Promise { ...createOpts.creation_content, [RoomCreateTypeField]: opts.roomType, }; + + // In voice rooms, allow all users to send voice member updates + if (opts.roomType === RoomType.UnstableCall) { + createOpts.power_level_content_override = { + events: { + [VOICE_CHANNEL_MEMBER]: 0, + // Annoyingly, we have to reiterate all the defaults here + [EventType.RoomName]: 50, + [EventType.RoomAvatar]: 50, + [EventType.RoomPowerLevels]: 100, + [EventType.RoomHistoryVisibility]: 100, + [EventType.RoomCanonicalAlias]: 50, + [EventType.RoomTombstone]: 100, + [EventType.RoomServerAcl]: 100, + [EventType.RoomEncryption]: 100, + }, + }; + } } // By default, view the room after creating it diff --git a/src/stores/VoiceChannelStore.ts b/src/stores/VoiceChannelStore.ts index d505c2575fa..9e77a0094bf 100644 --- a/src/stores/VoiceChannelStore.ts +++ b/src/stores/VoiceChannelStore.ts @@ -15,11 +15,17 @@ limitations under the License. */ import { EventEmitter } from "events"; +import { logger } from "matrix-js-sdk/src/logger"; import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; +import { MatrixClientPeg } from "../MatrixClientPeg"; import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; -import { getVoiceChannel } from "../utils/VoiceChannelUtils"; +import { + VOICE_CHANNEL_MEMBER, + IVoiceChannelMemberContent, + getVoiceChannel, +} from "../utils/VoiceChannelUtils"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; @@ -54,6 +60,7 @@ export default class VoiceChannelStore extends EventEmitter { return VoiceChannelStore._instance; } + private readonly cli = MatrixClientPeg.get(); private activeChannel: ClientWidgetApi; private _roomId: string; private _participants: IJitsiParticipant[]; @@ -118,6 +125,9 @@ export default class VoiceChannelStore extends EventEmitter { messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.emit(VoiceChannelEvent.Connect); + + // Tell others that we're connected, by adding our device to room state + await this.updateDevices(devices => Array.from(new Set(devices).add(this.cli.getDeviceId()))); }; public disconnect = async () => { @@ -169,8 +179,8 @@ export default class VoiceChannelStore extends EventEmitter { private waitForAction = async (action: ElementWidgetActions) => { const wait = new Promise(resolve => this.activeChannel.once(`action:${action}`, (ev: CustomEvent) => { - resolve(); this.ack(ev); + resolve(); }), ); if (await timeout(wait, false, VoiceChannelStore.TIMEOUT) === false) { @@ -182,22 +192,47 @@ export default class VoiceChannelStore extends EventEmitter { this.activeChannel.transport.reply(ev.detail, {}); }; - private onHangup = (ev: CustomEvent) => { + private updateDevices = async (fn: (devices: string[]) => string[]) => { + if (!this.roomId) { + logger.error("Tried to update devices while disconnected"); + return; + } + + const devices = this.cli.getRoom(this.roomId) + .currentState.getStateEvents(VOICE_CHANNEL_MEMBER, this.cli.getUserId()) + ?.getContent()?.devices ?? []; + + await this.cli.sendStateEvent( + this.roomId, VOICE_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(), + ); + }; + + private onHangup = async (ev: CustomEvent) => { + this.ack(ev); + this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio); this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio); this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo); this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo); - this._roomId = null; + this.activeChannel = null; this._participants = null; this._audioMuted = null; this._videoMuted = null; - this.emit(VoiceChannelEvent.Disconnect); - this.ack(ev); - // Save this for last, since ack needs activeChannel to exist - this.activeChannel = null; + try { + // Tell others that we're disconnected, by removing our device from room state + await this.updateDevices(devices => { + const devicesSet = new Set(devices); + devicesSet.delete(this.cli.getDeviceId()); + return Array.from(devicesSet); + }); + } finally { + // Save this for last, since updateDevices needs the room ID + this._roomId = null; + this.emit(VoiceChannelEvent.Disconnect); + } }; private onParticipants = (ev: CustomEvent) => { diff --git a/src/utils/VoiceChannelUtils.ts b/src/utils/VoiceChannelUtils.ts index 2b1deebddcf..bee6388ed4e 100644 --- a/src/utils/VoiceChannelUtils.ts +++ b/src/utils/VoiceChannelUtils.ts @@ -15,18 +15,33 @@ limitations under the License. */ import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import WidgetStore, { IApp } from "../stores/WidgetStore"; import { WidgetType } from "../widgets/WidgetType"; import WidgetUtils from "./WidgetUtils"; -export const VOICE_CHANNEL_ID = "io.element.voice"; +export const VOICE_CHANNEL = "io.element.voice"; +export const VOICE_CHANNEL_MEMBER = "io.element.voice.member"; + +export interface IVoiceChannelMemberContent { + // Connected device IDs + devices: string[]; +} export const getVoiceChannel = (roomId: string): IApp => { const apps = WidgetStore.instance.getApps(roomId); - return apps.find(app => WidgetType.JITSI.matches(app.type) && app.id === VOICE_CHANNEL_ID); + return apps.find(app => WidgetType.JITSI.matches(app.type) && app.id === VOICE_CHANNEL); }; export const addVoiceChannel = async (roomId: string, roomName: string) => { - await WidgetUtils.addJitsiWidget(roomId, CallType.Voice, "Voice channel", VOICE_CHANNEL_ID, roomName); + await WidgetUtils.addJitsiWidget(roomId, CallType.Voice, "Voice channel", VOICE_CHANNEL, roomName); }; + +export const getConnectedMembers = (state: RoomState): RoomMember[] => + state.getStateEvents(VOICE_CHANNEL_MEMBER) + // Must have a device connected and still be joined to the room + .filter(e => e.getContent().devices?.length) + .map(e => state.getMember(e.getStateKey())) + .filter(member => member.membership === "join"); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 680b3da509b..4e960afe46c 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -17,79 +17,78 @@ limitations under the License. import React from "react"; import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; -import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; import { mocked } from "jest-mock"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "../../../skinned-sdk"; -import { stubClient, mkStubRoom } from "../../../test-utils"; +import { + stubClient, + mockStateEventImplementation, + mkRoom, + mkEvent, +} from "../../../test-utils"; +import { stubVoiceChannelStore } from "../../../test-utils/voice"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; +import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import WidgetStore from "../../../../src/stores/WidgetStore"; -import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; -import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; import { DefaultTagID } from "../../../../src/stores/room-list/models"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { VOICE_CHANNEL_ID } from "../../../../src/utils/VoiceChannelUtils"; +import { VOICE_CHANNEL_MEMBER } from "../../../../src/utils/VoiceChannelUtils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; +const mkVoiceChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({ + event: true, + type: VOICE_CHANNEL_MEMBER, + room: "!1:example.org", + user: userId, + skey: userId, + content: { devices }, +}); + describe("RoomTile", () => { jest.spyOn(PlatformPeg, 'get') .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); - const cli = mocked(MatrixClientPeg.get()); + let cli; + let store; beforeEach(() => { const realGetValue = SettingsStore.getValue; - jest.spyOn(SettingsStore, 'getValue').mockImplementation((name, roomId) => { + SettingsStore.getValue = (name: string, roomId?: string): T => { if (name === "feature_voice_rooms") { - return true; + return true as unknown as T; } return realGetValue(name, roomId); - }); + }; stubClient(); + stubVoiceChannelStore(); DMRoomMap.makeShared(); + + cli = mocked(MatrixClientPeg.get()); + store = VoiceChannelStore.instance; }); + afterEach(() => jest.clearAllMocks()); + describe("voice rooms", () => { - const room = mkStubRoom("!1:example.org", "voice room", cli); - room.isCallRoom = () => true; - - // Set up mocks to simulate the remote end of the widget API - let messageSent; - let messageSendMock; - let onceMock; - beforeEach(() => { - let resolveMessageSent; - messageSent = new Promise(resolve => resolveMessageSent = resolve); - messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); - onceMock = jest.fn(); - - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ - id: VOICE_CHANNEL_ID, - eventId: "$1:example.org", - roomId: "!1:example.org", - type: MatrixWidgetType.JitsiMeet, - url: "", - name: "Voice channel", - creatorUserId: "@alice:example.org", - avatar_url: null, - }]); - jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ - on: () => {}, - off: () => {}, - once: onceMock, - transport: { - send: messageSendMock, - reply: () => {}, - }, - } as unknown as ClientWidgetApi); - }); + const room = mkRoom(cli, "!1:example.org"); + room.isCallRoom.mockReturnValue(true); it("tracks connection state", async () => { + // Insert a breakpoint in the connect method, so we can see the intermediate connecting state + let continueJoin; + const breakpoint = new Promise(resolve => continueJoin = resolve); + const realConnect = store.connect; + store.connect = async () => { + await breakpoint; + await realConnect(); + }; + const tile = mount( { tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connecting..."); - // Wait for the VoiceChannelStore to connect to the widget API - await messageSent; - // Then, locate the callback that will confirm the join - const [, join] = onceMock.mock.calls.find(([action]) => - action === `action:${ElementWidgetActions.JoinCall}`, - ); - - // Now we confirm the join and wait for the VoiceChannelStore to update + // Now we confirm the join and wait for the store to update const waitForConnect = new Promise(resolve => - VoiceChannelStore.instance.once(VoiceChannelEvent.Connect, resolve), + store.once(VoiceChannelEvent.Connect, resolve), ); - join({ detail: {} }); + continueJoin(); await waitForConnect; - // Wait yet another tick for the room tile to update + // Wait exactly 2 ticks for the room tile to update + await Promise.resolve(); await Promise.resolve(); tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connected"); - // Locate the callback that will perform the hangup - const [, hangup] = onceMock.mock.calls.find(([action]) => - action === `action:${ElementWidgetActions.HangupCall}`, - ); - - // Hangup and wait for the VoiceChannelStore, once again - const waitForHangup = new Promise(resolve => - VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, resolve), - ); - hangup({ detail: {} }); - await waitForHangup; - // Wait yet another tick for the room tile to update - await Promise.resolve(); + await store.disconnect(); tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room"); }); + + it("displays connected members", async () => { + mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ + // A user connected from 2 devices + mkVoiceChannelMember("@alice:example.org", ["device 1", "device 2"]), + // A disconnected user + mkVoiceChannelMember("@bob:example.org", []), + // A user that claims to have a connected device, but has left the room + mkVoiceChannelMember("@chris:example.org", ["device 1"]), + ])); + + mocked(room.currentState).getMember.mockImplementation(userId => ({ + userId, + membership: userId === "@chris:example.org" ? "leave" : "join", + name: userId, + rawDisplayName: userId, + roomId: "!1:example.org", + getAvatarUrl: () => {}, + getMxcAvatarUrl: () => {}, + }) as unknown as RoomMember); + + const tile = mount( + , + ); + + // Only Alice should display as connected + const avatar = tile.find(MemberAvatar); + expect(avatar.length).toEqual(1); + expect(avatar.props().member.userId).toEqual("@alice:example.org"); + }); }); }); diff --git a/test/components/views/voip/VoiceChannelRadio-test.tsx b/test/components/views/voip/VoiceChannelRadio-test.tsx index 9ab3c279374..b42ac0e0893 100644 --- a/test/components/views/voip/VoiceChannelRadio-test.tsx +++ b/test/components/views/voip/VoiceChannelRadio-test.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import React from "react"; import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; @@ -22,49 +21,14 @@ import { mocked } from "jest-mock"; import "../../../skinned-sdk"; import { stubClient, mkStubRoom, wrapInMatrixClientContext } from "../../../test-utils"; +import { stubVoiceChannelStore } from "../../../test-utils/voice"; import _VoiceChannelRadio from "../../../../src/components/views/voip/VoiceChannelRadio"; -import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; +import VoiceChannelStore from "../../../../src/stores/VoiceChannelStore"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; const VoiceChannelRadio = wrapInMatrixClientContext(_VoiceChannelRadio); -class StubVoiceChannelStore extends EventEmitter { - private _roomId: string; - public get roomId(): string { return this._roomId; } - private _audioMuted: boolean; - public get audioMuted(): boolean { return this._audioMuted; } - private _videoMuted: boolean; - public get videoMuted(): boolean { return this._videoMuted; } - - public connect = jest.fn().mockImplementation(async (roomId: string) => { - this._roomId = roomId; - this._audioMuted = true; - this._videoMuted = true; - this.emit(VoiceChannelEvent.Connect); - }); - public disconnect = jest.fn().mockImplementation(async () => { - this._roomId = null; - this.emit(VoiceChannelEvent.Disconnect); - }); - public muteAudio = jest.fn().mockImplementation(async () => { - this._audioMuted = true; - this.emit(VoiceChannelEvent.MuteAudio); - }); - public unmuteAudio = jest.fn().mockImplementation(async () => { - this._audioMuted = false; - this.emit(VoiceChannelEvent.UnmuteAudio); - }); - public muteVideo = jest.fn().mockImplementation(async () => { - this._videoMuted = true; - this.emit(VoiceChannelEvent.MuteVideo); - }); - public unmuteVideo = jest.fn().mockImplementation(async () => { - this._videoMuted = false; - this.emit(VoiceChannelEvent.UnmuteVideo); - }); -} - describe("VoiceChannelRadio", () => { const cli = mocked(MatrixClientPeg.get()); const room = mkStubRoom("!1:example.org", "voice channel", cli); @@ -72,10 +36,8 @@ describe("VoiceChannelRadio", () => { beforeEach(() => { stubClient(); + stubVoiceChannelStore(); DMRoomMap.makeShared(); - // Stub out the VoiceChannelStore - jest.spyOn(VoiceChannelStore, "instance", "get") - .mockReturnValue(new StubVoiceChannelStore() as unknown as VoiceChannelStore); }); it("shows when connecting voice", async () => { diff --git a/test/stores/VoiceChannelStore-test.ts b/test/stores/VoiceChannelStore-test.ts new file mode 100644 index 00000000000..cf70e7314c5 --- /dev/null +++ b/test/stores/VoiceChannelStore-test.ts @@ -0,0 +1,95 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; + +import "../skinned-sdk"; +import { stubClient } from "../test-utils"; +import WidgetStore from "../../src/stores/WidgetStore"; +import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; +import VoiceChannelStore, { VoiceChannelEvent } from "../../src/stores/VoiceChannelStore"; +import { VOICE_CHANNEL } from "../../src/utils/VoiceChannelUtils"; + +describe("VoiceChannelStore", () => { + // Set up mocks to simulate the remote end of the widget API + let messageSent; + let messageSendMock; + let onceMock; + beforeEach(() => { + stubClient(); + let resolveMessageSent; + messageSent = new Promise(resolve => resolveMessageSent = resolve); + messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); + onceMock = jest.fn(); + + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + id: VOICE_CHANNEL, + eventId: "$1:example.org", + roomId: "!1:example.org", + type: MatrixWidgetType.JitsiMeet, + url: "", + name: "Voice channel", + creatorUserId: "@alice:example.org", + avatar_url: null, + }]); + jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ + on: () => {}, + off: () => {}, + once: onceMock, + transport: { + send: messageSendMock, + reply: () => {}, + }, + } as unknown as ClientWidgetApi); + }); + + it("connects and disconnects", async () => { + const store = VoiceChannelStore.instance; + + expect(store.roomId).toBeFalsy(); + + store.connect("!1:example.org"); + // Wait for the store to contact the widget API + await messageSent; + // Then, locate the callback that will confirm the join + const [, join] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.JoinCall}`, + ); + // Confirm the join, and wait for the store to update + const waitForConnect = new Promise(resolve => + store.once(VoiceChannelEvent.Connect, resolve), + ); + join({ detail: {} }); + await waitForConnect; + + expect(store.roomId).toEqual("!1:example.org"); + + store.disconnect(); + // Locate the callback that will perform the hangup + const [, hangup] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.HangupCall}`, + ); + // Hangup and wait for the store, once again + const waitForHangup = new Promise(resolve => + store.once(VoiceChannelEvent.Disconnect, resolve), + ); + hangup({ detail: {} }); + await waitForHangup; + + expect(store.roomId).toBeFalsy(); + }); +}); diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index c6e4cbd182e..6cc84474491 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,5 +2,6 @@ export * from './beacon'; export * from './client'; export * from './platform'; export * from './test-utils'; +// TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning export * from './wrappers'; export * from './utilities'; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ef9ae57746a..45edc7fa15e 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -62,6 +62,7 @@ export function createTestClient(): MatrixClient { getDomain: jest.fn().mockReturnValue("matrix.rog"), getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"), getUser: jest.fn().mockReturnValue({ on: jest.fn() }), + getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"), credentials: { userId: "@userId:matrix.rog" }, getPushActionsForEvent: jest.fn(), diff --git a/test/test-utils/voice.ts b/test/test-utils/voice.ts new file mode 100644 index 00000000000..962e4c6c56b --- /dev/null +++ b/test/test-utils/voice.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; + +import VoiceChannelStore, { VoiceChannelEvent } from "../../src/stores/VoiceChannelStore"; + +class StubVoiceChannelStore extends EventEmitter { + private _roomId: string; + public get roomId(): string { return this._roomId; } + private _audioMuted: boolean; + public get audioMuted(): boolean { return this._audioMuted; } + private _videoMuted: boolean; + public get videoMuted(): boolean { return this._videoMuted; } + + public connect = jest.fn().mockImplementation(async (roomId: string) => { + this._roomId = roomId; + this._audioMuted = true; + this._videoMuted = true; + this.emit(VoiceChannelEvent.Connect); + }); + public disconnect = jest.fn().mockImplementation(async () => { + this._roomId = null; + this.emit(VoiceChannelEvent.Disconnect); + }); + public muteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = true; + this.emit(VoiceChannelEvent.MuteAudio); + }); + public unmuteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = false; + this.emit(VoiceChannelEvent.UnmuteAudio); + }); + public muteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = true; + this.emit(VoiceChannelEvent.MuteVideo); + }); + public unmuteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = false; + this.emit(VoiceChannelEvent.UnmuteVideo); + }); +} + +export const stubVoiceChannelStore = () => { + jest.spyOn(VoiceChannelStore, "instance", "get") + .mockReturnValue(new StubVoiceChannelStore() as unknown as VoiceChannelStore); +};