diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index ed54161b35..23558fc3df 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -118,8 +118,6 @@ import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages"; import { LargeLoader } from "./LargeLoader"; import { isVideoRoom } from "../../utils/video-rooms"; import { SDKContext } from "../../contexts/SDKContext"; -import { CallStore, CallStoreEvent } from "../../stores/CallStore"; -import { type Call } from "../../models/Call"; import { RoomSearchView } from "./RoomSearchView"; import eventSearch, { type SearchInfo, SearchScope } from "../../Searching"; import VoipUserMapper from "../../VoipUserMapper"; @@ -190,7 +188,6 @@ export interface IRoomState { */ search?: SearchInfo; callState?: CallState; - activeCall: Call | null; canPeek: boolean; canSelfRedact: boolean; showApps: boolean; @@ -401,7 +398,6 @@ export class RoomView extends React.Component { membersLoaded: !llMembers, numUnreadMessages: 0, callState: undefined, - activeCall: null, canPeek: false, canSelfRedact: false, showApps: false, @@ -577,7 +573,6 @@ export class RoomView extends React.Component { mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: roomId ? this.context.rightPanelStore.isOpenForRoom(roomId) : false, - activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), viewRoomOpts: this.context.roomViewStore.getViewRoomOpts(), }; @@ -727,23 +722,17 @@ export class RoomView extends React.Component { }); }; - private onConnectedCalls = (): void => { - if (this.state.roomId === undefined) return; - const activeCall = CallStore.instance.getActiveCall(this.state.roomId); - if (activeCall === null) { - // We disconnected from the call, so stop viewing it - defaultDispatcher.dispatch( - { - action: Action.ViewRoom, - room_id: this.state.roomId, - view_call: false, - metricsTrigger: undefined, - }, - true, - ); // Synchronous so that CallView disappears immediately - } - - this.setState({ activeCall }); + private onCallClose = (): void => { + // Stop viewing the call + defaultDispatcher.dispatch( + { + action: Action.ViewRoom, + room_id: this.state.roomId, + view_call: false, + metricsTrigger: undefined, + }, + true, + ); // Synchronous so that CallView disappears immediately }; private getRoomId = (): string | undefined => { @@ -900,8 +889,6 @@ export class RoomView extends React.Component { WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); this.context.widgetStore.on(UPDATE_EVENT, this.onWidgetStoreUpdate); - CallStore.instance.on(CallStoreEvent.ConnectedCalls, this.onConnectedCalls); - this.props.resizeNotifier.on("isResizing", this.onIsResizing); this.settingWatchers = [ @@ -1027,7 +1014,6 @@ export class RoomView extends React.Component { ); } - CallStore.instance.off(CallStoreEvent.ConnectedCalls, this.onConnectedCalls); this.context.legacyCallHandler.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated @@ -2562,9 +2548,9 @@ export class RoomView extends React.Component { {previewBar} diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 010d9f092c..b3185921bb 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -126,10 +126,6 @@ const ActiveLoadedCallEvent = forwardRef(({ mxE return [_t("action|leave"), "danger", disconnect]; case ConnectionState.Disconnecting: return [_t("action|leave"), "danger", null]; - case ConnectionState.Connecting: - case ConnectionState.Lobby: - case ConnectionState.WidgetLoading: - return [_t("action|join"), "primary", null]; } }, [connectionState, connect, disconnect]); diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 21975fc49d..eed7d012e2 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -27,18 +27,6 @@ export const RoomTileCallSummary: FC = ({ call }) => { text = _t("common|video"); active = false; break; - case ConnectionState.WidgetLoading: - text = _t("common|loading"); - active = false; - break; - case ConnectionState.Lobby: - text = _t("common|lobby"); - active = false; - break; - case ConnectionState.Connecting: - text = _t("room|joining"); - active = true; - break; case ConnectionState.Connected: case ConnectionState.Disconnecting: text = _t("common|joined"); diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 78249f56de..87bf84d8c4 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -9,12 +9,13 @@ Please see LICENSE files in the repository root for full details. import React, { type FC, useContext, useEffect, type AriaRole, useCallback } from "react"; import type { Room } from "matrix-js-sdk/src/matrix"; -import { type Call, ConnectionState, ElementCall } from "../../../models/Call"; -import { useCall } from "../../../hooks/useCall"; +import { type Call, CallEvent } from "../../../models/Call"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { CallStore } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { useCall } from "../../../hooks/useCall"; interface JoinCallViewProps { room: Room; @@ -22,10 +23,12 @@ interface JoinCallViewProps { call: Call; skipLobby?: boolean; role?: AriaRole; + onClose: () => void; } -const JoinCallView: FC = ({ room, resizing, call, skipLobby, role }) => { +const JoinCallView: FC = ({ room, resizing, call, skipLobby, role, onClose }) => { const cli = useContext(MatrixClientContext); + useTypedEventEmitter(call, CallEvent.Close, onClose); useEffect(() => { // We'll take this opportunity to tidy up our room state @@ -38,17 +41,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, call.widget.data.skipLobby = skipLobby; }, [call.widget, skipLobby]); - useEffect(() => { - if (call.connectionState === ConnectionState.Disconnected) { - // immediately start the call - // (this will start the lobby view in the widget and connect to all required widget events) - call.start(); - } - return (): void => { - // If we are connected the widget is sticky and we do not want to destroy the call. - if (!call.connected) call.destroy(); - }; - }, [call]); const disconnectAllOtherCalls: () => Promise = useCallback(async () => { // The stickyPromise has to resolve before the widget actually becomes sticky. // We only let the widget become sticky after disconnecting all other active calls. @@ -57,6 +49,7 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, ); await Promise.all(calls.map(async (call) => await call.disconnect())); }, []); + return (
= ({ room, resizing, call, skipLobby, interface CallViewProps { room: Room; resizing: boolean; - /** - * If true, the view will be blank until a call appears. Otherwise, the join - * button will create a call if there isn't already one. - */ - waitForCall: boolean; skipLobby?: boolean; role?: AriaRole; + /** + * Callback for when the user closes the call. + */ + onClose: () => void; } -export const CallView: FC = ({ room, resizing, waitForCall, skipLobby, role }) => { +export const CallView: FC = ({ room, resizing, skipLobby, role, onClose }) => { const call = useCall(room.roomId); - useEffect(() => { - if (call === null && !waitForCall) { - ElementCall.create(room, skipLobby); - } - }, [call, room, skipLobby, waitForCall]); - if (call === null) { - return null; - } else { - return ; - } + return ( + call && ( + + ) + ); }; diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 2082f70bab..95e21fb0b8 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -70,7 +70,6 @@ const RoomContext = createContext< threadId: undefined, liveTimeline: undefined, narrow: false, - activeCall: null, msc3946ProcessDynamicPredecessor: false, canAskToJoin: false, promptAskToJoin: false, diff --git a/src/createRoom.ts b/src/createRoom.ts index d8c9cb2340..f226f68011 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -343,7 +343,7 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro await client.setPowerLevel(roomId, client.getUserId()!, 100); } else if (opts.roomType === RoomType.UnstableCall) { // Set up this video room with an Element call - await ElementCall.create(await room); + ElementCall.create(await room); // Reset our power level back to admin so that the call becomes immutable await client.setPowerLevel(roomId, client.getUserId()!, 100); diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 19b54fe8ea..687b7c014e 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -75,9 +75,5 @@ export const useFull = (call: Call | null): boolean => { export const useJoinCallButtonDisabledTooltip = (call: Call | null): string | null => { const isFull = useFull(call); - const state = useConnectionState(call); - - if (state === ConnectionState.Connecting) return _t("voip|join_button_tooltip_connecting"); - if (isFull) return _t("voip|join_button_tooltip_call_full"); - return null; + return isFull ? _t("voip|join_button_tooltip_call_full") : null; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 03626aab4e..0d42b7acce 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -497,7 +497,6 @@ "legal": "Legal", "light": "Light", "loading": "Loading…", - "lobby": "Lobby", "location": "Location", "low_priority": "Low priority", "matrix": "Matrix", @@ -3909,7 +3908,6 @@ "input_devices": "Input devices", "jitsi_call": "Jitsi Conference", "join_button_tooltip_call_full": "Sorry — this call is currently full", - "join_button_tooltip_connecting": "Connecting", "legacy_call": "Legacy Call", "maximise": "Fill screen", "maximise_call": "Maximise call", diff --git a/src/models/Call.ts b/src/models/Call.ts index 8ce5a1d2ca..3497dc01c6 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -77,13 +77,7 @@ const waitForEvent = async ( }; export enum ConnectionState { - // Widget related states that are equivalent to disconnected, - // but hold additional information about the state of the widget. - Lobby = "lobby", - WidgetLoading = "widget_loading", Disconnected = "disconnected", - - Connecting = "connecting", Connected = "connected", Disconnecting = "disconnecting", } @@ -100,6 +94,7 @@ export enum CallEvent { ConnectionState = "connection_state", Participants = "participants", Layout = "layout", + Close = "close", Destroy = "destroy", } @@ -110,6 +105,7 @@ interface CallEventHandlerMap { prevParticipants: Map>, ) => void; [CallEvent.Layout]: (layout: Layout) => void; + [CallEvent.Close]: () => void; [CallEvent.Destroy]: () => void; } @@ -167,6 +163,17 @@ export abstract class Call extends TypedEventEmitter { - this.connectionState = ConnectionState.WidgetLoading; - const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = (await MediaDeviceHandler.getDevices())!; @@ -257,16 +263,9 @@ export abstract class Call extends TypedEventEmitter => { + private readonly onMyMembership = async (_room: Room, membership: Membership): Promise => { if (membership !== KnownMembership.Join) this.setDisconnected(); }; - private onStopMessaging = (uid: string): void => { - if (uid === this.widgetUid) { + private readonly onStopMessaging = (uid: string): void => { + if (uid === this.widgetUid && this.connected) { logger.log("The widget died; treating this as a user hangup"); this.setDisconnected(); + this.close(); } }; - private beforeUnload = (): void => this.setDisconnected(); + private beforeUnload = (): void => { + this.setDisconnected(); + this.close(); + }; } export type { JitsiCallMemberContent }; @@ -466,7 +480,6 @@ export class JitsiCall extends Call { audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { - this.connectionState = ConnectionState.Lobby; // Ensure that the messaging doesn't get stopped while we're waiting for responses const dontStopMessaging = new Promise((resolve, reject) => { const messagingStore = WidgetMessagingStore.instance; @@ -569,9 +582,9 @@ export class JitsiCall extends Call { super.destroy(); } - private onRoomState = (): void => this.updateParticipants(); + private readonly onRoomState = (): void => this.updateParticipants(); - private onConnectionState = async (state: ConnectionState, prevState: ConnectionState): Promise => { + private readonly onConnectionState = async (state: ConnectionState, prevState: ConnectionState): Promise => { if (state === ConnectionState.Connected && !isConnected(prevState)) { this.updateParticipants(); // Local echo @@ -597,18 +610,18 @@ export class JitsiCall extends Call { } }; - private onDock = async (): Promise => { + private readonly onDock = async (): Promise => { // The widget is no longer a PiP, so let's restore the default layout await this.messaging!.transport.send(ElementWidgetActions.TileLayout, {}); }; - private onUndock = async (): Promise => { + private readonly onUndock = async (): Promise => { // The widget has become a PiP, so let's switch Jitsi to spotlight mode // to only show the active speaker and economize on space await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); }; - private onHangup = async (ev: CustomEvent): Promise => { + private readonly onHangup = async (ev: CustomEvent): Promise => { // If we're already in the middle of a client-initiated disconnection, // ignore the event if (this.connectionState === ConnectionState.Disconnecting) return; @@ -617,14 +630,15 @@ export class JitsiCall extends Call { // In case this hangup is caused by Jitsi Meet crashing at startup, // wait for the connection event in order to avoid racing - if (this.connectionState === ConnectionState.Connecting) { + if (this.connectionState === ConnectionState.Disconnected) { await waitForEvent(this, CallEvent.ConnectionState); } - await this.messaging!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); + this.close(); // In video rooms we immediately want to restart the call after hangup - // The lobby will be shown again and it connects to all signals from EC and Jitsi. + // The lobby will be shown again and it connects to all signals from Jitsi. if (isVideoRoom(this.room)) { this.start(); } @@ -653,6 +667,14 @@ export class ElementCall extends Call { this.emit(CallEvent.Layout, value); } + public get presented(): boolean { + return super.presented; + } + public set presented(value: boolean) { + super.presented = value; + this.checkDestroy(); + } + private static generateWidgetUrl(client: MatrixClient, roomId: string): URL { const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE); // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. @@ -740,7 +762,7 @@ export class ElementCall extends Call { // To use Element Call without touching room state, we create a virtual // widget (one that doesn't have a corresponding state event) const url = ElementCall.generateWidgetUrl(client, roomId); - return WidgetStore.instance.addVirtualWidget( + const createdWidget = WidgetStore.instance.addVirtualWidget( { id: secureRandomString(24), // So that it's globally unique creatorUserId: client.getUserId()!, @@ -761,6 +783,8 @@ export class ElementCall extends Call { }, roomId, ); + WidgetStore.instance.emit(UPDATE_EVENT, null); + return createdWidget; } private static getWidgetData( @@ -794,7 +818,7 @@ export class ElementCall extends Call { super(widget, client); this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); - this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded); + this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.checkDestroy); SettingsStore.watchSetting( "feature_disable_call_per_sender_encryption", null, @@ -827,9 +851,8 @@ export class ElementCall extends Call { return null; } - public static async create(room: Room, skipLobby = false): Promise { + public static create(room: Room, skipLobby = false): void { ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room)); - WidgetStore.instance.emit(UPDATE_EVENT, null); } protected async sendCallNotify(): Promise { @@ -875,17 +898,9 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, async (ev) => { - ev.preventDefault(); - await this.messaging!.transport.reply(ev.detail, {}); // ack - }); + this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose); + this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); - if (!this.widget.data?.skipLobby) { - // If we do not skip the lobby we need to wait until the widget has - // connected to matrixRTC. This is either observed through the session state - // or the MatrixRTCSessionManager session started event. - this.connectionState = ConnectionState.Lobby; - } // TODO: if the widget informs us when the join button is clicked (widget action), so we can // - set state to connecting // - send call notify @@ -927,15 +942,16 @@ export class ElementCall extends Call { public setDisconnected(): void { this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); super.setDisconnected(); } public destroy(): void { ActiveWidgetStore.instance.destroyPersistentWidget(this.widget.id, this.widget.roomId); WidgetStore.instance.removeVirtualWidget(this.widget.id, this.widget.roomId); - this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.session.off(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); - this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded); + this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.checkDestroy); SettingsStore.unwatchSetting(this.settingsStoreCallEncryptionWatcher); clearTimeout(this.terminationTimer); @@ -944,11 +960,10 @@ export class ElementCall extends Call { super.destroy(); } - private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => { - // Don't destroy the call on hangup for video call rooms. - if (roomId === this.roomId && !this.room.isCallRoom()) { - this.destroy(); - } + private checkDestroy = (): void => { + // A call ceases to exist as soon as all participants leave and also the + // user isn't looking at it (for example, waiting in an empty lobby) + if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy(); }; /** @@ -960,7 +975,7 @@ export class ElementCall extends Call { await this.messaging!.transport.send(action, {}); } - private onMembershipChanged = (): void => this.updateParticipants(); + private readonly onMembershipChanged = (): void => this.updateParticipants(); private updateParticipants(): void { const participants = new Map>(); @@ -980,9 +995,14 @@ export class ElementCall extends Call { this.participants = participants; } - private onHangup = async (ev: CustomEvent): Promise => { + private readonly onDeviceMute = (ev: CustomEvent): void => { ev.preventDefault(); - await this.messaging!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack + }; + + private readonly onHangup = async (ev: CustomEvent): Promise => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); // In video rooms we immediately want to reconnect after hangup // This starts the lobby again and connects to all signals from EC. @@ -991,16 +1011,23 @@ export class ElementCall extends Call { } }; - private onTileLayout = async (ev: CustomEvent): Promise => { + private readonly onClose = async (ev: CustomEvent): Promise => { ev.preventDefault(); - this.layout = Layout.Tile; - await this.messaging!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack + // User is done with the call; tell the UI to close it + this.close(); }; - private onSpotlightLayout = async (ev: CustomEvent): Promise => { + private readonly onTileLayout = async (ev: CustomEvent): Promise => { + ev.preventDefault(); + this.layout = Layout.Tile; + this.messaging!.transport.reply(ev.detail, {}); // ack + }; + + private readonly onSpotlightLayout = async (ev: CustomEvent): Promise => { ev.preventDefault(); this.layout = Layout.Spotlight; - await this.messaging!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack }; public clean(): Promise { diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index c691f0af79..91edcbb412 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -50,6 +50,8 @@ import { type CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJ import { type SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload"; import { ModuleRunner } from "../modules/ModuleRunner"; import { setMarkedUnreadState } from "../utils/notifications"; +import { ConnectionState, ElementCall } from "../models/Call"; +import { isVideoRoom } from "../utils/video-rooms"; const NUM_JOIN_RETRY = 5; @@ -353,6 +355,23 @@ export class RoomViewStore extends EventEmitter { }); } + if (room && (payload.view_call || isVideoRoom(room))) { + let call = CallStore.instance.getCall(payload.room_id); + // Start a call if not already there + if (call === null) { + ElementCall.create(room, false); + call = CallStore.instance.getCall(payload.room_id)!; + } + call.presented = true; + // Immediately start the call. This will connect to all required widget events + // and allow the widget to show the lobby. + if (call.connectionState === ConnectionState.Disconnected) call.start(); + } + // If we switch to a different room from the call, we are no longer presenting it + const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null; + if (prevRoomCall !== null && (!payload.view_call || payload.room_id !== this.state.roomId)) + prevRoomCall.presented = false; + if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index be887c88cd..c7bcfcd8cd 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -12,6 +12,7 @@ export enum ElementWidgetActions { // All of these actions are currently specific to Jitsi and Element Call JoinCall = "io.element.join", HangupCall = "im.vector.hangup", + Close = "io.element.close", CallParticipants = "io.element.participants", StartLiveStream = "im.vector.start_live_stream", diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index e8210317ce..2dcf8c4fdc 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -72,8 +72,11 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { * @param {string} widgetUid The widget UID. */ public stopMessagingByUid(widgetUid: string): void { - this.widgetMap.remove(widgetUid)?.stop(); - this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid); + const messaging = this.widgetMap.remove(widgetUid); + if (messaging !== undefined) { + messaging.stop(); + this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid); + } } /** diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 008858a129..a0a1c84536 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -109,5 +109,5 @@ export class MockedCall extends Call { export const useMockedCalls = () => { Call.get = (room) => MockedCall.get(room); JitsiCall.create = async (room) => MockedCall.create(room, "1"); - ElementCall.create = async (room) => MockedCall.create(room, "1"); + ElementCall.create = (room) => MockedCall.create(room, "1"); }; diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index 9fe77a970c..d4c6466ef2 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -80,7 +80,6 @@ export function getRoomContext(room: Room, override: Partial): IRoom canSelfRedact: false, resizing: false, narrow: false, - activeCall: null, msc3946ProcessDynamicPredecessor: false, canAskToJoin: false, promptAskToJoin: false, diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index 70febb2c1b..6130eef029 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -76,6 +76,15 @@ import { SearchScope } from "../../../../src/Searching"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../src/utils/crypto"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { type ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload.ts"; +import { CallStore } from "../../../../src/stores/CallStore.ts"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler.ts"; + +// Used by group calls +jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + [MediaDeviceKindEnum.AudioOutput]: [], +}); describe("RoomView", () => { let cli: MockedObject; @@ -98,6 +107,7 @@ describe("RoomView", () => { rooms = new Map(); rooms.set(room.roomId, room); cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null); + cli.getRooms.mockImplementation(() => [...rooms.values()]); // Re-emit certain events on the mocked client room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); @@ -371,6 +381,7 @@ describe("RoomView", () => { describe("video rooms", () => { beforeEach(async () => { + await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet()); // Make it a video room room.isElementVideoRoom = () => true; await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true); diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 7336d58574..4534e29e08 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -2006,6 +2006,41 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
+
+
+
+
+
+
+ Loading… +
+   +
+
+
+
+
+
{ }); it("opens the room summary", async () => { + const user = userEvent.setup(); const { container } = render(, getWrapper()); - fireEvent.click(getByText(container, ROOM_ID)); + await user.click(getByText(container, ROOM_ID)); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); it("shows a face pile for rooms", async () => { + const user = userEvent.setup(); const members = [ { userId: "@me:example.org", @@ -161,33 +164,36 @@ describe("RoomHeader", () => { const facePile = getByLabelText(document.body, "4 members"); expect(facePile).toHaveTextContent("4"); - fireEvent.click(facePile); + await user.click(facePile); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); }); it("has room info icon that opens the room info panel", async () => { + const user = userEvent.setup(); const { getAllByRole } = render(, getWrapper()); const infoButton = getAllByRole("button", { name: "Room info" })[1]; - fireEvent.click(infoButton); + await user.click(infoButton); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); it("opens the thread panel", async () => { + const user = userEvent.setup(); render(, getWrapper()); - fireEvent.click(getByLabelText(document.body, "Threads")); + await user.click(getByLabelText(document.body, "Threads")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }); }); it("opens the notifications panel", async () => { + const user = userEvent.setup(); jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => { if (name === "feature_notifications") return true; }); render(, getWrapper()); - fireEvent.click(getByLabelText(document.body, "Notifications")); + await user.click(getByLabelText(document.body, "Notifications")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel }); }); @@ -274,6 +280,7 @@ describe("RoomHeader", () => { }); it("you can call when you're two in the room", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 2); render(, getWrapper()); @@ -284,10 +291,10 @@ describe("RoomHeader", () => { const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - fireEvent.click(voiceButton); + await user.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - fireEvent.click(videoButton); + await user.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); @@ -332,6 +339,7 @@ describe("RoomHeader", () => { }); it("renders only the video call element", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 3); jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); // allow element calls @@ -344,9 +352,9 @@ describe("RoomHeader", () => { const videoCallButton = screen.getByRole("button", { name: "Video call" }); expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true"); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); + const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); - fireEvent.click(videoCallButton); + await user.click(videoCallButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); @@ -366,7 +374,8 @@ describe("RoomHeader", () => { expect(screen.getByRole("button", { name: "Ongoing call" })).toHaveAttribute("aria-disabled", "true"); }); - it("clicking on ongoing (unpinned) call re-pins it", () => { + it("clicking on ongoing (unpinned) call re-pins it", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 3); jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets); // allow calls @@ -386,7 +395,7 @@ describe("RoomHeader", () => { const videoButton = screen.getByRole("button", { name: "Video call" }); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - fireEvent.click(videoButton); + await user.click(videoButton); expect(spy).toHaveBeenCalledWith(room, widget, Container.Top); }); @@ -463,6 +472,7 @@ describe("RoomHeader", () => { }); it("calls using legacy or jitsi", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 2); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === "im.vector.modular.widgets") return true; @@ -476,14 +486,15 @@ describe("RoomHeader", () => { expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - fireEvent.click(voiceButton); + await user.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - fireEvent.click(videoButton); + await user.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); it("calls using legacy or jitsi for large rooms", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 3); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { @@ -497,11 +508,12 @@ describe("RoomHeader", () => { expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - fireEvent.click(videoButton); + await user.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); it("calls using element call for large rooms", async () => { + const user = userEvent.setup(); mockRoomMembers(room, 3); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { @@ -514,8 +526,8 @@ describe("RoomHeader", () => { const videoButton = screen.getByRole("button", { name: "Video call" }); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); - fireEvent.click(videoButton); + const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); + await user.click(videoButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); @@ -750,10 +762,11 @@ describe("RoomHeader", () => { }); it("should open room settings when clicking the room avatar", async () => { + const user = userEvent.setup(); render(, getWrapper()); const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); - fireEvent.click(getByLabelText(document.body, "Open room settings")); + await user.click(getByLabelText(document.body, "Open room settings")); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ action: "open_room_settings" })); }); }); diff --git a/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap index c2a339457a..3ac64ce10e 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap @@ -43,7 +43,8 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `