From aa996010b49fc36ac258bc06ab9775283c7469de Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 5 Mar 2025 15:41:26 -0500 Subject: [PATCH] Show error screens in group calls (#29254) * Avoid destroying calls until they are hidden from the UI We often want calls to exist even when no more participants are left in the MatrixRTC session. So, we should avoid destroying calls as long as they're being presented in the UI; this means that the user has an intent to either join the call or continue looking at an error screen, and we shouldn't interrupt that interaction. The RoomViewStore is now what takes care of creating and destroying calls, rather than the CallView. In general it seems kinda impossible to safely create and destroy model objects from React lifecycle hooks, so moving this responsibility to a store seemed appropriate and resolves existing issues with calls in React strict mode. * Wait for a close action before closing a call This creates a distinction between the user hanging up and the widget being ready to close, which is useful for allowing Element Call to show error screens when disconnected from the call, for example. * Don't expect a 'close' action in video rooms These use the returnToLobby option and are expected to remain visible when the user leaves the call. --- src/components/structures/RoomView.tsx | 38 ++--- src/components/views/messages/CallEvent.tsx | 4 - .../views/rooms/RoomTileCallSummary.tsx | 12 -- src/components/views/voip/CallView.tsx | 54 +++---- src/contexts/RoomContext.ts | 1 - src/createRoom.ts | 2 +- src/hooks/useCall.ts | 6 +- src/i18n/strings/en_EN.json | 2 - src/models/Call.ts | 151 +++++++++++------- src/stores/RoomViewStore.tsx | 19 +++ src/stores/widgets/ElementWidgetActions.ts | 1 + src/stores/widgets/WidgetMessagingStore.ts | 7 +- test/test-utils/call.ts | 2 +- test/test-utils/room.ts | 1 - .../components/structures/RoomView-test.tsx | 11 ++ .../__snapshots__/RoomView-test.tsx.snap | 35 ++++ .../rooms/RoomHeader/RoomHeader-test.tsx | 47 ++++-- .../__snapshots__/RoomHeader-test.tsx.snap | 14 +- .../components/views/rooms/RoomTile-test.tsx | 40 +---- .../views/rooms/SendMessageComposer-test.tsx | 1 - .../components/views/voip/CallView-test.tsx | 130 ++++----------- test/unit-tests/models/Call-test.ts | 84 ++++------ test/unit-tests/stores/RoomViewStore-test.ts | 46 +++++- 23 files changed, 344 insertions(+), 364 deletions(-) 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`] = `