diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index e4f0dfa6086..73eb80702ba 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; +import defaultDispatcher, { type MatrixDispatcher } from "../../../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import { ConnectionState, type ElementCall } from "../../../models/Call"; @@ -35,7 +35,8 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, - skipLobby: "shiftKey" in ev ? ev.shiftKey : false, + // If shift is held down, always skip lobby. Else, use defaults. + skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined, metricsTrigger: undefined, }); }, @@ -79,6 +80,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => interface Props { roomId: Room["roomId"]; + dispatcher?: MatrixDispatcher; } const RoomCallBanner: React.FC = ({ roomId }) => { diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 87bf84d8c4a..8067cc065ca 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,12 +21,11 @@ interface JoinCallViewProps { room: Room; resizing: boolean; call: Call; - skipLobby?: boolean; role?: AriaRole; onClose: () => void; } -const JoinCallView: FC = ({ room, resizing, call, skipLobby, role, onClose }) => { +const JoinCallView: FC = ({ room, resizing, call, role, onClose }) => { const cli = useContext(MatrixClientContext); useTypedEventEmitter(call, CallEvent.Close, onClose); @@ -35,12 +34,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, call.clean(); }, [call]); - useEffect(() => { - // Always update the widget data so that we don't ignore "skipLobby" accidentally. - call.widget.data ??= {}; - call.widget.data.skipLobby = skipLobby; - }, [call.widget, skipLobby]); - 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. @@ -69,7 +62,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, interface CallViewProps { room: Room; resizing: boolean; - skipLobby?: boolean; role?: AriaRole; /** * Callback for when the user closes the call. @@ -77,19 +69,8 @@ interface CallViewProps { onClose: () => void; } -export const CallView: FC = ({ room, resizing, skipLobby, role, onClose }) => { +export const CallView: FC = ({ room, resizing, role, onClose }) => { const call = useCall(room.roomId); - return ( - call && ( - - ) - ); + return call && ; }; diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 5e7b48ee543..b01c194605c 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -229,7 +229,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey ?? false); + placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined); } }, [promptPinWidget, room, widget], @@ -240,7 +240,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey ?? false); + placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined); } }, [widget, promptPinWidget, room], diff --git a/src/models/Call.ts b/src/models/Call.ts index 81970da6b1a..1d9a79be509 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -97,6 +97,17 @@ interface CallEventHandlerMap { [CallEvent.Destroy]: () => void; } +/** + * Parameters to be passed during widget creation. + * These parameters are hints only, and may not be accepted by the implementation. + */ +export interface WidgetGenerationParameters { + /** + * Skip showing the lobby screen of a call. + */ + skipLobby?: boolean; +} + /** * A group call accessed through a widget. */ @@ -180,8 +191,8 @@ export abstract class Call extends TypedEventEmitter params.append("font", font)); } + this.appendAnalyticsParams(params, client); + this.appendRoomParams(params, client, roomId); const replacedUrl = params.toString().replace(/%24/g, "$"); url.hash = `#?${replacedUrl}`; @@ -675,33 +723,20 @@ export class ElementCall extends Call { } // Creates a new widget if there isn't any widget of typ Call in this room. - // Defaults for creating a new widget are: skipLobby = false - // When there is already a widget the current widget configuration will be used or can be overwritten - // by passing the according parameters (skipLobby). private static createOrGetCallWidget( roomId: string, client: MatrixClient, - skipLobby: boolean | undefined, - returnToLobby: boolean | undefined, + params: WidgetGenerationParameters = {}, ): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); if (ecWidget) { - // Always update the widget data because even if the widget is already created, - // we might have settings changes that update the widget. - const overwrites: IWidgetData = {}; - if (skipLobby !== undefined) { - overwrites.skipLobby = skipLobby; - } - if (returnToLobby !== undefined) { - overwrites.returnToLobby = returnToLobby; - } - ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites); + ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}); return ecWidget; } // 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); + const url = ElementCall.generateWidgetUrl(client, roomId, params); const createdWidget = WidgetStore.instance.addVirtualWidget( { id: secureRandomString(24), // So that it's globally unique @@ -710,15 +745,7 @@ export class ElementCall extends Call { type: WidgetType.CALL.preferred, url: url.toString(), waitForIframeLoad: false, - data: ElementCall.getWidgetData( - client, - roomId, - {}, - { - skipLobby: skipLobby ?? false, - returnToLobby: returnToLobby ?? false, - }, - ), + data: ElementCall.getWidgetData(client, roomId, {}), }, roomId, ); @@ -726,12 +753,7 @@ export class ElementCall extends Call { return createdWidget; } - private static getWidgetData( - client: MatrixClient, - roomId: string, - currentData: IWidgetData, - overwriteData: IWidgetData, - ): IWidgetData { + private static getWidgetData(client: MatrixClient, roomId: string, currentData: IWidgetData): IWidgetData { let perParticipantE2EE = false; if ( client.getRoom(roomId)?.hasEncryptionStateEvent() && @@ -740,13 +762,12 @@ export class ElementCall extends Call { perParticipantE2EE = true; return { ...currentData, - ...overwriteData, perParticipantE2EE, }; } private onCallEncryptionSettingsChange(): void { - this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {}); + this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}); } private constructor( @@ -766,7 +787,7 @@ export class ElementCall extends Call { this.updateParticipants(); } - public static get(room: Room): ElementCall | null { + public static get(room: Room, params: WidgetGenerationParameters = {}): ElementCall | null { const apps = WidgetStore.instance.getApps(room.roomId); const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type)); const session = room.client.matrixRTC.getRoomSession(room); @@ -777,20 +798,15 @@ export class ElementCall extends Call { // - or this is a call room. Then we also always want to show a call. if (hasEcWidget || session.memberships.length !== 0 || room.isCallRoom()) { // create a widget for the case we are joining a running call and don't have on yet. - const availableOrCreatedWidget = ElementCall.createOrGetCallWidget( - room.roomId, - room.client, - undefined, - isVideoRoom(room), - ); + const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(room.roomId, room.client, params); return new ElementCall(session, availableOrCreatedWidget, room.client); } return null; } - public static create(room: Room, skipLobby = false): void { - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); + public static create(room: Room, params: WidgetGenerationParameters = {}): void { + ElementCall.createOrGetCallWidget(room.roomId, room.client, params); } public async start(): Promise { diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 6347cc898ed..8185be7de87 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -8,9 +8,8 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; -import { type MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; +import { type EmptyObject, type GroupCall, type Room } from "matrix-js-sdk/src/matrix"; -import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -46,16 +45,11 @@ export class CallStore extends AsyncStoreWithClient { } protected async onReady(): Promise { - if (!this.matrixClient) return; - // We assume that the calls present in a room are a function of room - // widgets and group calls, so we initialize the room map here and then - // update it whenever those change - for (const room of this.matrixClient.getRooms()) { - this.updateRoom(room); - } - this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); + // Legacy calls only + this.matrixClient?.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); + this.matrixClient?.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + + // Whenever a widget gets updated, we want to recheck the list of calls. WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); // If the room ID of a previously connected call is still in settings at @@ -85,12 +79,9 @@ export class CallStore extends AsyncStoreWithClient { this.calls.clear(); this._connectedCalls.clear(); - if (this.matrixClient) { - this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); - this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); - } + this.matrixClient?.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); + this.matrixClient?.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + this.matrixClient?.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); } @@ -118,38 +109,43 @@ export class CallStore extends AsyncStoreWithClient { private callListeners = new Map unknown>>(); private updateRoom(room: Room): void { - if (!this.calls.has(room.roomId)) { - const call = Call.get(room); - - if (call) { - const onConnectionState = (state: ConnectionState): void => { - if (state === ConnectionState.Connected) { - this.connectedCalls = new Set([...this.connectedCalls, call]); - } else if (state === ConnectionState.Disconnected) { - this.connectedCalls = new Set([...this.connectedCalls].filter((c) => c !== call)); - } - }; - const onDestroy = (): void => { - this.calls.delete(room.roomId); - for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener); - this.updateRoom(room); - }; - - call.on(CallEvent.ConnectionState, onConnectionState); - call.on(CallEvent.Destroy, onDestroy); - - this.calls.set(room.roomId, call); - this.callListeners.set( - call, - new Map unknown>([ - [CallEvent.ConnectionState, onConnectionState], - [CallEvent.Destroy, onDestroy], - ]), - ); - } - - this.emit(CallStoreEvent.Call, call, room.roomId); + if (this.calls.has(room.roomId)) { + // Room has a call, nothing to do here. + return; } + const call = Call.get(room); + + if (call) { + logger.debug(`updateRooms(${room.roomId}) called, binding management hooks`); + const onConnectionState = (state: ConnectionState): void => { + if (state === ConnectionState.Connected) { + this.connectedCalls = new Set([...this.connectedCalls, call]); + } else if (state === ConnectionState.Disconnected) { + this.connectedCalls = new Set([...this.connectedCalls].filter((c) => c !== call)); + } + }; + const onDestroy = (): void => { + this.calls.delete(room.roomId); + for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener); + this.updateRoom(room); + }; + + call.on(CallEvent.ConnectionState, onConnectionState); + call.on(CallEvent.Destroy, onDestroy); + + this.calls.set(room.roomId, call); + this.callListeners.set( + call, + new Map unknown>([ + [CallEvent.ConnectionState, onConnectionState], + [CallEvent.Destroy, onDestroy], + ]), + ); + } else { + logger.debug(`updateRooms(${room.roomId}) called but no Call has been constructed`); + } + + this.emit(CallStoreEvent.Call, call, room.roomId); } /** @@ -187,7 +183,4 @@ export class CallStore extends AsyncStoreWithClient { }; private onGroupCall = (groupCall: GroupCall): void => this.updateRoom(groupCall.room); - private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => { - this.updateRoom(session.room); - }; } diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 50ca69eec37..7483eeb680e 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -359,7 +359,7 @@ export class RoomViewStore extends EventEmitter { let call = CallStore.instance.getCall(payload.room_id); // Start a call if not already there if (call === null) { - ElementCall.create(room, false); + ElementCall.create(room, { skipLobby: payload.skipLobby }); call = CallStore.instance.getCall(payload.room_id)!; } call.presented = true; @@ -739,10 +739,6 @@ export class RoomViewStore extends EventEmitter { return this.state.viewingCall; } - public skipCallLobby(): boolean | undefined { - return this.state.skipLobby; - } - /** * Gets the current state of the 'promptForAskToJoin' property. * diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 5a76df71035..fbae950dbc7 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -189,6 +189,7 @@ export default class WidgetStore extends AsyncStoreWithClient { const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined); this.widgetMap.set(WidgetUtils.getWidgetUid(app), app); this.roomMap.get(roomId)!.widgets.push(app); + this.emit(UPDATE_EVENT, roomId); return app; } @@ -198,6 +199,7 @@ export default class WidgetStore extends AsyncStoreWithClient { if (roomApps) { roomApps.widgets = roomApps.widgets.filter((app) => !(app.id === widgetId && app.roomId === roomId)); } + this.emit(UPDATE_EVENT, roomId); } } diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index 1f0d67c1e6f..3c7658f1d99 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -26,7 +26,7 @@ export const placeCall = async ( room: Room, callType: CallType, platformCallType: PlatformCallType, - skipLobby: boolean, + skipLobby: boolean | undefined, ): Promise => { const { analyticsName } = getPlatformCallTypeProps(platformCallType); PosthogTrackers.trackInteraction(analyticsName); diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 38cb77eb03f..24d9631930f 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -16,7 +16,8 @@ import { } from "matrix-js-sdk/src/matrix"; import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; -import { mocked, type Mocked } from "jest-mock"; +import { mocked, type MockedObject, type Mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import { mkRoomMember, @@ -31,6 +32,27 @@ import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMe import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { OwnBeaconStore } from "../../../../../src/stores/OwnBeaconStore"; +import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../src/dispatcher/actions"; + +jest.mock("../../../../../src/stores/OwnBeaconStore", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const EventEmitter = require("events"); + class MockOwnBeaconStore extends EventEmitter { + public getLiveBeaconIds = jest.fn().mockReturnValue([{}]); + public isMonitoringLiveLocation = false; + } + return { + // @ts-ignore + ...jest.requireActual("../../../../../src/stores/OwnBeaconStore"), + OwnBeaconStore: { + instance: new MockOwnBeaconStore() as unknown as OwnBeaconStore, + }, + }; +}); + +jest.mock("../../../../../src/dispatcher/dispatcher"); describe("", () => { let client: Mocked; @@ -65,6 +87,10 @@ describe("", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + const renderBanner = async (props = {}): Promise => { render(); await act(() => Promise.resolve()); // Let effects settle @@ -79,6 +105,7 @@ describe("", () => { describe("call started", () => { let call: MockedCall; let widget: Widget; + let beaconStore: MockedObject; beforeEach(() => { MockedCall.create(room, "1"); @@ -92,6 +119,10 @@ describe("", () => { WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, } as unknown as ClientWidgetApi); + beaconStore = mocked(OwnBeaconStore.instance); + beaconStore.getLiveBeaconIds.mockReturnValue([]); + // @ts-ignore writing to mock + beaconStore.isMonitoringLiveLocation = false; }); afterEach(() => { cleanup(); // Unmount before we do any cleanup that might update the component @@ -109,6 +140,41 @@ describe("", () => { await screen.findByText("Join"); }); + it("joins the call when Join is clicked", async () => { + const user = userEvent.setup(); + const dispatcherSpy = jest.fn(); + defaultDispatcher.dispatch = dispatcherSpy; + await renderBanner(); + const button = await screen.findByText("Join"); + await user.click(button); + + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + skipLobby: undefined, + metricsTrigger: undefined, + }); + }); + + it("joins the call and skips lobby while shift is held", async () => { + const user = userEvent.setup(); + const dispatcherSpy = jest.fn(); + defaultDispatcher.dispatch = dispatcherSpy; + await renderBanner(); + const button = await screen.findByText("Join"); + await user.keyboard("[ShiftLeft>]"); // Press Shift (without releasing it) + await user.click(button); + + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + skipLobby: true, + metricsTrigger: undefined, + }); + }); + it("doesn't show banner if the call is connected", async () => { call.setConnectionState(ConnectionState.Connected); await renderBanner(); @@ -116,6 +182,16 @@ describe("", () => { expect(banner).toBeFalsy(); }); + it("doesn't show banner if live location is ongoing", async () => { + beaconStore.getLiveBeaconIds.mockReturnValue(["abcdef"]); + // @ts-ignore Writing to readonly value + beaconStore.isMonitoringLiveLocation = true; + call.setConnectionState(ConnectionState.Disconnected); + await renderBanner(); + const banner = await screen.queryByText("Video call"); + expect(banner).toBeFalsy(); + }); + it("doesn't show banner if the call is shown", async () => { jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall"); mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true); @@ -126,5 +202,4 @@ describe("", () => { }); // TODO: test clicking buttons - // TODO: add live location share warning test (should not render if there is an active live location share) }); diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index ca6a50b4184..2b971a02528 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -82,13 +82,13 @@ describe("CallView", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); - const renderView = async (skipLobby = false, role: string | undefined = undefined): Promise => { - render( {}} />); + const renderView = async (role: string | undefined = undefined): Promise => { + render( {}} />); await act(() => Promise.resolve()); // Let effects settle }; it("accepts an accessibility role", async () => { - await renderView(undefined, "main"); + await renderView("main"); screen.getByRole("main"); }); @@ -97,9 +97,4 @@ describe("CallView", () => { await renderView(); expect(cleanSpy).toHaveBeenCalled(); }); - - it("updates the call's skipLobby parameter", async () => { - await renderView(true); - expect(call.widget.data?.skipLobby).toBe(true); - }); }); diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index a91f951be33..ae3f7db0d1b 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -828,7 +828,7 @@ describe("ElementCall", () => { jest.useFakeTimers(); jest.setSystemTime(0); - ElementCall.create(room, true); + ElementCall.create(room, { skipLobby: true }); const maybeCall = ElementCall.get(room); if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall;