From 9bca273a7de1e7b4755fc38fe452a4b12ae5537f Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 14 Dec 2023 19:31:06 +0100 Subject: [PATCH 01/51] Refactor ElementCall to use the widget lobby. - expose skip lobby - use the widget.data to build the widget url Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 113 ++++++++---------------- src/models/Call.ts | 116 +++++++++++++++++++------ 2 files changed, 126 insertions(+), 103 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 625df3a897c..2495a81fefd 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -17,17 +17,10 @@ limitations under the License. import React, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import type { Room } from "matrix-js-sdk/src/matrix"; -import type { ConnectionState } from "../../../models/Call"; -import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; -import { - useCall, - useConnectionState, - useJoinCallButtonDisabledTooltip, - useParticipatingMembers, -} from "../../../hooks/useCall"; +import { Call, ElementCall } from "../../../models/Call"; +import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; @@ -42,7 +35,6 @@ import { aboveRightOf, ContextMenuButton, useContextMenu } from "../../structure import { Alignment } from "../elements/Tooltip"; import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import FacePile from "../elements/FacePile"; import MemberAvatar from "../avatars/MemberAvatar"; interface DeviceButtonProps { @@ -121,8 +113,6 @@ const DeviceButton: FC = ({ ); }; -const MAX_FACES = 8; - interface LobbyProps { room: Room; connect: () => Promise; @@ -297,39 +287,24 @@ interface StartCallViewProps { resizing: boolean; call: Call | null; setStartingCall: (value: boolean) => void; + startingCall: boolean; + skipLobby?: boolean; } -const StartCallView: FC = ({ room, resizing, call, setStartingCall }) => { +const StartCallView: FC = ({ room, resizing, call, setStartingCall, startingCall, skipLobby }) => { const cli = useContext(MatrixClientContext); - // Since connection has to be split across two different callbacks, we - // create a promise to communicate the results back to the caller - const connectDeferredRef = useRef>(); - if (connectDeferredRef.current === undefined) { - connectDeferredRef.current = defer(); - } - const connectDeferred = connectDeferredRef.current!; - - // Since the call might be null, we have to track connection state by hand. - // The alternative would be to split this component in two depending on - // whether we've received the call, so we could use the useConnectionState - // hook, but then React would remount the lobby when the call arrives. - const [connected, setConnected] = useState(() => call !== null && isConnected(call.connectionState)); + // We need to do this awkward double effect system, + // because otherwise we will not have subscribed to the CallStore + // before we create the call which emits the UPDATE_ROOM event. useEffect(() => { - if (call !== null) { - const onConnectionState = (state: ConnectionState): void => setConnected(isConnected(state)); - call.on(CallEvent.ConnectionState, onConnectionState); - return () => { - call.off(CallEvent.ConnectionState, onConnectionState); - }; - } - }, [call]); - - const connect = useCallback(async (): Promise => { setStartingCall(true); - await ElementCall.create(room); - await connectDeferred.promise; - }, [room, setStartingCall, connectDeferred]); + }, [setStartingCall]); + useEffect(() => { + if (startingCall) { + ElementCall.create(room, skipLobby); + } + }, [room, skipLobby, startingCall]); useEffect(() => { (async (): Promise => { @@ -339,17 +314,15 @@ const StartCallView: FC = ({ room, resizing, call, setStarti // Disconnect from any other active calls first, since we don't yet support holding await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect())); await call.connect(); - connectDeferred.resolve(); } catch (e) { - connectDeferred.reject(e); + logger.error(e); } } })(); - }, [call, connectDeferred]); + }, [call]); return (
- {connected ? null : } {call !== null && ( = ({ room, resizing, call }) => { +const JoinCallView: FC = ({ room, resizing, call, skipLobby }) => { const cli = useContext(MatrixClientContext); - const connected = isConnected(useConnectionState(call)); - const members = useParticipatingMembers(call); - const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const connect = useCallback(async (): Promise => { // Disconnect from any other active calls first, since we don't yet support holding @@ -387,37 +358,15 @@ const JoinCallView: FC = ({ room, resizing, call }) => { useEffect(() => { call.clean(); }, [call]); - - let lobby: JSX.Element | null = null; - if (!connected) { - let facePile: JSX.Element | null = null; - if (members.length) { - const shownMembers = members.slice(0, MAX_FACES); - const overflow = members.length > shownMembers.length; - - facePile = ( -
- {_t("voip|n_people_joined", { count: members.length })} - -
- ); + useEffect(() => { + if (call.connectionState === "disconnected") { + (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; + connect(); } - - lobby = ( - - {facePile} - - ); - } + }, [call.connectionState, call.widget.data, connect, skipLobby]); return (
- {lobby} - {/* We render the widget even if we're disconnected, so it stays loaded */} = ({ room, resizing, waitForCall }) => { +export const CallView: FC = ({ room, resizing, waitForCall, skipLobby }) => { const call = useCall(room.roomId); const [startingCall, setStartingCall] = useState(false); if (call === null || startingCall) { if (waitForCall) return null; - return ; + return ( + + ); } else { - return ; + return ; } }; diff --git a/src/models/Call.ts b/src/models/Call.ts index 004782120e1..64c939678fe 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -37,7 +37,7 @@ import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/Matri import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types"; import type EventEmitter from "events"; -import type { ClientWidgetApi } from "matrix-widget-api"; +import type { ClientWidgetApi, IWidgetData } from "matrix-widget-api"; import type { IApp } from "../stores/WidgetStore"; import SdkConfig, { DEFAULTS } from "../SdkConfig"; import SettingsStore from "../settings/SettingsStore"; @@ -620,7 +620,7 @@ export class JitsiCall extends Call { * (somewhat cheekily named) */ export class ElementCall extends Call { - // TODO this is only there to support backwards compatiblity in timeline rendering + // TODO this is only there to support backwards compatibility in timeline rendering // this should not be part of this class since it has nothing to do with it. public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix); public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix); @@ -649,8 +649,10 @@ export class ElementCall extends Call { // Splice together the Element Call URL for this call const params = new URLSearchParams({ embed: "true", // We're embedding EC within another application - preload: "true", // We want it to load in the background - skipLobby: "true", // Skip the lobby since we show a lobby component of our own + // Template variables are used, so that this can be configured using the widget data. + preload: "$preload", // We want it to load in the background. + skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. + perParticipantE2EE: "$perParticipantE2EE", hideHeader: "true", // Hide the header since our room header is enough userId: client.getUserId()!, deviceId: client.getDeviceId()!, @@ -661,8 +663,6 @@ export class ElementCall extends Call { analyticsID, }); - if (client.isRoomEncrypted(roomId) && !SettingsStore.getValue("feature_disable_call_per_sender_encryption")) - params.append("perParticipantE2EE", "true"); if (SettingsStore.getValue("fallbackICEServerAllowed")) params.append("allowIceFallback", "true"); if (SettingsStore.getValue("feature_allow_screen_share_only_mode")) params.append("allowVoipWithNoMedia", "true"); @@ -682,24 +682,42 @@ export class ElementCall extends Call { const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!); url.pathname = "/room"; - url.hash = `#?${params.toString()}`; + const replacedUrl = params.toString().replace(/%24/g, "$"); + url.hash = `#?${replacedUrl}`; return url; } - private static createOrGetCallWidget(roomId: string, client: MatrixClient): IApp { + // 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, preload = false + // When there is already a widget the current widget configuration will be used or can be overwritten + // by passing the according parameters (skipLobby, preload). + // + // `preload` is deprecated. We used it for optimizing EC by using a custom EW call lobby and preloading the iframe. + // now it should always be false. + private static createOrGetCallWidget( + roomId: string, + client: MatrixClient, + skipLobby: boolean | undefined, + preload: boolean | undefined, + ): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); - const url = ElementCall.generateWidgetUrl(client, roomId); - if (ecWidget) { - // always update the url because even if the widget is already created + // Always update the widget data because even if the widget is already created, // we might have settings changes that update the widget. - ecWidget.url = url.toString(); - + const overwrites: IWidgetData = {}; + if (skipLobby !== undefined) { + overwrites.skipLobby = skipLobby; + } + if (preload !== undefined) { + overwrites.preload = preload; + } + ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites); 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); return WidgetStore.instance.addVirtualWidget( { id: randomString(24), // So that it's globally unique @@ -708,13 +726,38 @@ export class ElementCall extends Call { type: WidgetType.CALL.preferred, url: url.toString(), // waitForIframeLoad: false, + data: ElementCall.getWidgetData( + client, + roomId, + {}, + { + skipLobby: skipLobby ?? false, + preload: preload ?? false, + }, + ), }, roomId, ); } + private static getWidgetData( + client: MatrixClient, + roomId: string, + currentData: IWidgetData, + overwriteData: IWidgetData, + ): IWidgetData { + let perParticipantE2EE = false; + if (client.isRoomEncrypted(roomId) && !SettingsStore.getValue("feature_disable_call_per_sender_encryption")) + perParticipantE2EE = true; + return { + ...currentData, + ...overwriteData, + perParticipantE2EE, + }; + } + private onCallEncryptionSettingsChange(): void { - this.widget.url = ElementCall.generateWidgetUrl(this.client, this.roomId).toString(); + this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {}); } private constructor(public session: MatrixRTCSession, widget: IApp, client: MatrixClient) { @@ -745,10 +788,15 @@ export class ElementCall extends Call { // A call is present if we // - have a widget: This means the create function was called. // - or there is a running session where we have not yet created a widget for. - // - or this this is a call room. Then we also always want to show a 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); + const availableOrCreatedWidget = ElementCall.createOrGetCallWidget( + room.roomId, + room.client, + undefined, + undefined, + ); return new ElementCall(session, availableOrCreatedWidget, room.client); } } @@ -756,12 +804,13 @@ export class ElementCall extends Call { return null; } - public static async create(room: Room): Promise { + public static async create(room: Room, skipLobby = false): Promise { const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom(); - ElementCall.createOrGetCallWidget(room.roomId, room.client); + + ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false); WidgetStore.instance.emit(UPDATE_EVENT, null); // Send Call notify @@ -789,18 +838,33 @@ export class ElementCall extends Call { audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { - try { - await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.label ?? null, - videoInput: videoInput?.label ?? null, - }); - } catch (e) { - throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); + // the JoinCall action is only send if the widget is waiting for it. + if ((this.widget.data ?? {}).preload) { + try { + await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { + audioInput: audioInput?.label ?? null, + videoInput: videoInput?.label ?? null, + }); + } catch (e) { + throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); + } } - this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + if (!(this.widget.data ?? {}).skipLobby) { + await new Promise((resolve) => { + const waitForLobbyJoin = (roomId: string, session: MatrixRTCSession): void => { + if (this.session.callId === session.callId && roomId === this.roomId) { + resolve(); + // This listener is not needed anymore. The promise resolved and we updated to the connection state + // when `performConnection` resolves. + this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); + } + }; + this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); + }); + } } protected async performDisconnection(): Promise { From 6d5ac0b120bd0b8d46dd67412f84ebc72c9daf66 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 14 Dec 2023 19:32:29 +0100 Subject: [PATCH 02/51] Use shiftKey click to skip the lobby Signed-off-by: Timo K --- src/components/structures/RoomView.tsx | 1 + .../views/beacon/RoomCallBanner.tsx | 1 + .../views/rooms/LegacyRoomHeader.tsx | 28 +++++++++++-------- src/dispatcher/payloads/ViewRoomPayload.ts | 1 + src/hooks/room/useRoomCall.ts | 4 +-- src/stores/RoomViewStore.tsx | 10 +++++++ src/toasts/IncomingCallToast.tsx | 1 + src/utils/room/placeCall.ts | 8 +++++- 8 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 364ec3a32f0..495789326ab 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2541,6 +2541,7 @@ export class RoomView extends React.Component { room={this.state.room} resizing={this.state.resizing} waitForCall={isVideoRoom(this.state.room)} + skipLobby={this.context.roomViewStore.skipCallLobby() ?? false} /> {previewBar} diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 68885fc693b..2a7ad1814ac 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -43,6 +43,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, + skipLobby: "shiftKey" in ev ? ev.shiftKey : false, metricsTrigger: undefined, }); }, diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index cad8cb88149..dbcc8bdf305 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -143,16 +143,20 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi setBusy(false); }, [setBusy, room]); - const startElementCall = useCallback(() => { - setBusy(true); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - view_call: true, - metricsTrigger: undefined, - }); - setBusy(false); - }, [setBusy, room]); + const startElementCall = useCallback( + (skipLobby: boolean) => { + setBusy(true); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + skipLobby: skipLobby, + metricsTrigger: undefined, + }); + setBusy(false); + }, + [setBusy, room], + ); const { onClick, tooltip, disabled } = useMemo(() => { if (behavior instanceof DisabledWithReason) { @@ -173,7 +177,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi return { onClick: async (ev: ButtonEvent): Promise => { ev.preventDefault(); - startElementCall(); + startElementCall("shiftKey" in ev ? ev.shiftKey : false); }, disabled: false, }; @@ -202,7 +206,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi (ev: ButtonEvent) => { ev.preventDefault(); closeMenu(); - startElementCall(); + startElementCall("shiftKey" in ev ? ev.shiftKey : false); }, [closeMenu, startElementCall], ); diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index 282204b458a..69ae5910ee4 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -44,6 +44,7 @@ interface BaseViewRoomPayload extends Pick { show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list clear_search?: boolean; // Whether to clear the room list search view_call?: boolean; // Whether to view the call or call lobby for the room + skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls) opts?: JoinRoomPayload["opts"]; deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index c1438eaca4a..e08b8fbe643 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -169,7 +169,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Voice, callType); + placeCall(room, CallType.Voice, callType, evt.shiftKey); } }, [promptPinWidget, room, widget, callType], @@ -180,7 +180,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Video, callType); + placeCall(room, CallType.Video, callType, evt.shiftKey); } }, [widget, promptPinWidget, room, callType], diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index c47114ecd45..968d65f8bf4 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -119,6 +119,10 @@ interface State { * Whether we're viewing a call or call lobby in this room */ viewingCall: boolean; + /** + * Whether want the call to skip the lobby and immediately join + */ + skipLobby?: boolean; promptAskToJoin: boolean; @@ -459,6 +463,7 @@ export class RoomViewStore extends EventEmitter { replyingToEvent: null, viaServers: payload.via_servers ?? [], wasContextSwitch: payload.context_switch ?? false, + skipLobby: payload.skipLobby, viewingCall: payload.view_call ?? (payload.room_id === this.state.roomId @@ -509,6 +514,7 @@ export class RoomViewStore extends EventEmitter { viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, viewingCall: payload.view_call ?? false, + skipLobby: payload.skipLobby, }); try { const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias); @@ -771,6 +777,10 @@ 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/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index ad4c5af57cf..0a54f33f548 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -131,6 +131,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { action: Action.ViewRoom, room_id: room?.roomId, view_call: true, + skipLobby: "shiftKey" in e ? e.shiftKey : false, metricsTrigger: undefined, }); }, diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index a50a7f2725d..da64e83d485 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -29,7 +29,12 @@ import { Action } from "../../dispatcher/actions"; * @param callType the type of call * @param platformCallType the platform to pass the call on */ -export const placeCall = async (room: Room, callType: CallType, platformCallType: PlatformCallType): Promise => { +export const placeCall = async ( + room: Room, + callType: CallType, + platformCallType: PlatformCallType, + skipLobby: boolean, +): Promise => { switch (platformCallType) { case "legacy_or_jitsi": await LegacyCallHandler.instance.placeCall(room.roomId, callType); @@ -43,6 +48,7 @@ export const placeCall = async (room: Room, callType: CallType, platformCallType room_id: room.roomId, view_call: true, metricsTrigger: undefined, + skipLobby, }); break; From 8ed045407eda8b3148e17d33dc263ae3c9f06388 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 14 Dec 2023 19:42:32 +0100 Subject: [PATCH 03/51] remove Lobby component Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 260 +------------------------ 1 file changed, 1 insertion(+), 259 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 2495a81fefd..c623b371c00 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback } from "react"; -import classNames from "classnames"; +import React, { FC, useState, useContext, useEffect, useCallback } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import type { Room } from "matrix-js-sdk/src/matrix"; @@ -23,264 +22,7 @@ import { Call, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; -import { _t } from "../../../languageHandler"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MediaDeviceHandler, { IMediaDevices } from "../../../MediaDeviceHandler"; import { CallStore } from "../../../stores/CallStore"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; -import { aboveRightOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; -import { Alignment } from "../elements/Tooltip"; -import { ButtonEvent } from "../elements/AccessibleButton"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import MemberAvatar from "../avatars/MemberAvatar"; - -interface DeviceButtonProps { - kind: string; - devices: MediaDeviceInfo[]; - setDevice: (device: MediaDeviceInfo) => void; - deviceListLabel: string; - muted: boolean; - disabled: boolean; - toggle: () => void; - unmutedTitle: string; - mutedTitle: string; -} - -const DeviceButton: FC = ({ - kind, - devices, - setDevice, - deviceListLabel, - muted, - disabled, - toggle, - unmutedTitle, - mutedTitle, -}) => { - const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu(); - const selectDevice = useCallback( - (device: MediaDeviceInfo) => { - setDevice(device); - closeMenu(); - }, - [setDevice, closeMenu], - ); - - let contextMenu: JSX.Element | null = null; - if (showMenu) { - const buttonRect = buttonRef.current!.getBoundingClientRect(); - contextMenu = ( - - - {devices.map((d) => ( - selectDevice(d)} /> - ))} - - - ); - } - - if (!devices.length) return null; - - return ( -
- - {devices.length > 1 ? ( - - ) : null} - {contextMenu} -
- ); -}; - -interface LobbyProps { - room: Room; - connect: () => Promise; - joinCallButtonDisabledTooltip?: string; - children?: ReactNode; -} - -export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { - const [connecting, setConnecting] = useState(false); - const me = useMemo(() => room.getMember(room.myUserId)!, [room]); - const videoRef = useRef(null); - - const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); - - const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); - const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); - - const toggleAudio = useCallback(() => { - MediaDeviceHandler.startWithAudioMuted = !audioMuted; - setAudioMuted(!audioMuted); - }, [audioMuted, setAudioMuted]); - const toggleVideo = useCallback(() => { - MediaDeviceHandler.startWithVideoMuted = !videoMuted; - setVideoMuted(!videoMuted); - }, [videoMuted, setVideoMuted]); - - // In case we can not fetch media devices we should mute the devices - const handleMediaDeviceFailing = (message: string): void => { - MediaDeviceHandler.startWithAudioMuted = true; - MediaDeviceHandler.startWithVideoMuted = true; - logger.warn(message); - }; - - const [videoStream, audioInputs, videoInputs] = useAsyncMemo( - async (): Promise<[MediaStream | null, MediaDeviceInfo[], MediaDeviceInfo[]]> => { - let devices: IMediaDevices | undefined; - try { - devices = await MediaDeviceHandler.getDevices(); - if (devices === undefined) { - handleMediaDeviceFailing("Could not access devices!"); - return [null, [], []]; - } - } catch (error) { - handleMediaDeviceFailing(`Unable to get Media Devices: ${error}`); - return [null, [], []]; - } - - // We get the preview stream before requesting devices: this is because - // we need (in some browsers) an active media stream in order to get - // non-blank labels for the devices. - let stream: MediaStream | null = null; - - try { - if (devices!.audioinput.length > 0) { - // Holding just an audio stream will be enough to get us all device labels, so - // if video is muted, don't bother requesting video. - stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: !videoMuted && devices!.videoinput.length > 0 && { deviceId: videoInputId }, - }); - } else if (devices!.videoinput.length > 0) { - // We have to resort to a video stream, even if video is supposed to be muted. - stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } }); - } - } catch (e) { - logger.warn(`Failed to get stream for device ${videoInputId}`, e); - handleMediaDeviceFailing(`Have access to Device list but unable to read from Media Devices`); - } - - // Refresh the devices now that we hold a stream - if (stream !== null) devices = await MediaDeviceHandler.getDevices(); - - // If video is muted, we don't actually want the stream, so we can get rid of it now. - if (videoMuted) { - stream?.getTracks().forEach((t) => t.stop()); - stream = null; - } - - return [stream, devices?.audioinput ?? [], devices?.videoinput ?? []]; - }, - [videoInputId, videoMuted], - [null, [], []], - ); - - const setAudioInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setAudioInput(device.deviceId); - }, []); - const setVideoInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setVideoInput(device.deviceId); - setVideoInputId(device.deviceId); - }, []); - - useEffect(() => { - if (videoStream) { - const videoElement = videoRef.current!; - videoElement.srcObject = videoStream; - videoElement.play(); - - return () => { - videoStream.getTracks().forEach((track) => track.stop()); - videoElement.srcObject = null; - }; - } - }, [videoStream]); - - const onConnectClick = useCallback( - async (ev: ButtonEvent): Promise => { - ev.preventDefault(); - setConnecting(true); - try { - await connect(); - } catch (e) { - logger.error(e); - setConnecting(false); - } - }, - [connect, setConnecting], - ); - - return ( -
- {children} -
- -
- -
- ); -}; interface StartCallViewProps { room: Room; From 2518d56ecb93f609b2ab13039cf69023689c87ad Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 15 Dec 2023 13:19:06 +0100 Subject: [PATCH 04/51] update tests + remove EW lobby related tests Signed-off-by: Timo K --- .../views/rooms/LegacyRoomHeader-test.tsx | 7 + test/components/views/voip/CallView-test.tsx | 125 +----------------- test/models/Call-test.ts | 39 +----- test/toasts/IncomingCallToast-test.tsx | 1 + 4 files changed, 19 insertions(+), 153 deletions(-) diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx index 575f1ddf1a4..2f4166eabbb 100644 --- a/test/components/views/rooms/LegacyRoomHeader-test.tsx +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -272,6 +272,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -405,6 +406,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -430,6 +432,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -560,7 +563,11 @@ describe("LegacyRoomHeader", () => { mockEnabledSettings(["feature_group_calls"]); await withCall(async (call) => { + // we set the call to skip lobby because otherwise the connection will wait until + // the user clicks the "join" button, inside the widget lobby which is hard to mock. + call.widget.data = { ...call.widget.data, skipLobby: true }; await call.connect(); + const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!; renderHeader({ viewingCall: true, activeCall: call }); diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index b1a672189fd..89a22832da4 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -37,8 +37,6 @@ import { CallView as _CallView } from "../../../../src/components/views/voip/Cal import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call, ConnectionState } from "../../../../src/models/Call"; -import SdkConfig from "../../../../src/SdkConfig"; -import MediaDeviceHandler from "../../../../src/MediaDeviceHandler"; const CallView = wrapInMatrixClientContext(_CallView); @@ -74,8 +72,8 @@ describe("CallView", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); - const renderView = async (): Promise => { - render(); + const renderView = async (skipLobby = false): Promise => { + render(); await act(() => Promise.resolve()); // Let effects settle }; @@ -107,20 +105,6 @@ describe("CallView", () => { expect(cleanSpy).toHaveBeenCalled(); }); - it("shows lobby and keeps widget loaded when disconnected", async () => { - await renderView(); - screen.getByRole("button", { name: "Join" }); - screen.getAllByText(/\bwidget\b/i); - }); - - it("only shows widget when connected", async () => { - await renderView(); - fireEvent.click(screen.getByRole("button", { name: "Join" })); - await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected)); - expect(screen.queryByRole("button", { name: "Join" })).toBe(null); - screen.getAllByText(/\bwidget\b/i); - }); - /** * TODO: Fix I do not understand this test */ @@ -165,40 +149,17 @@ describe("CallView", () => { expectAvatars([]); }); - it("connects to the call when the join button is pressed", async () => { - await renderView(); + it("automatically connects to the call when skipLobby is true", async () => { const connectSpy = jest.spyOn(call, "connect"); - fireEvent.click(screen.getByRole("button", { name: "Join" })); + await renderView(true); await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 }); }); - - it("disables join button when the participant limit has been exceeded", async () => { - const bob = mkRoomMember(room.roomId, "@bob:example.org"); - const carol = mkRoomMember(room.roomId, "@carol:example.org"); - - SdkConfig.put({ - element_call: { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" }, - }); - call.participants = new Map([ - [bob, new Set("b")], - [carol, new Set("c")], - ]); - - await renderView(); - const connectSpy = jest.spyOn(call, "connect"); - const joinButton = screen.getByRole("button", { name: "Join" }); - expect(joinButton).toHaveAttribute("aria-disabled", "true"); - fireEvent.click(joinButton); - await waitFor(() => expect(connectSpy).not.toHaveBeenCalled(), { interval: 1 }); - }); }); describe("without an existing call", () => { it("creates and connects to a new call when the join button is pressed", async () => { - await renderView(); expect(Call.get(room)).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Join" })); + await renderView(true); await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull()); const call = CallStore.instance.getCall(room.roomId)!; @@ -249,81 +210,5 @@ describe("CallView", () => { expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); }); - - it("hide when no access to device list", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValue("permission denied"); - await renderView(); - expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); - expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); - expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); - expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); - }); - - it("hide when unknown error with device list", async () => { - const originalGetDevices = MediaDeviceHandler.getDevices; - MediaDeviceHandler.getDevices = () => Promise.reject("unknown error"); - await renderView(); - expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); - expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); - expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); - expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); - MediaDeviceHandler.getDevices = originalGetDevices; - }); - - it("show without dropdown when only one device is available", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]); - - await renderView(); - screen.getByRole("button", { name: /camera/ }); - expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null); - }); - - it("show with dropdown when multiple devices are available", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]); - - await renderView(); - screen.getByRole("button", { name: /microphone/ }); - fireEvent.click(screen.getByRole("button", { name: "Audio devices" })); - screen.getByRole("menuitem", { name: "Headphones" }); - screen.getByRole("menuitem", { name: "Tailphones" }); - }); - - it("sets video device when selected", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]); - - await renderView(); - screen.getByRole("button", { name: /camera/ }); - fireEvent.click(screen.getByRole("button", { name: "Video devices" })); - fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label })); - - expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId); - }); - - it("sets audio device when selected", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]); - - await renderView(); - screen.getByRole("button", { name: /microphone/ }); - fireEvent.click(screen.getByRole("button", { name: "Audio devices" })); - fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label })); - - expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId); - }); - - it("set media muted if no access to audio device", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]); - mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected"); - await renderView(); - expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); - expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); - }); - - it("set media muted if no access to video device", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]); - mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected"); - await renderView(); - expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); - expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); - }); }); }); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 3b00e1cdd7e..5fee97e63b9 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -140,6 +140,7 @@ const setUpWidget = ( audioMutedSpy: jest.SpyInstance; videoMutedSpy: jest.SpyInstance; } => { + call.widget.data = { ...call.widget, skipLobby: true }; const widget = new Widget(call.widget); const eventEmitter = new EventEmitter(); @@ -738,33 +739,7 @@ describe("ElementCall", () => { }); afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); - - it("connects muted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(true); - videoMutedSpy.mockReturnValue(true); - - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: null, - videoInput: null, - }); - }); - - it("connects unmuted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(false); - videoMutedSpy.mockReturnValue(false); - - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: "Headphones", - videoInput: "Built-in webcam", - }); - }); - + // TODO add tests for passing device configuration to the widget it("waits for messaging when connecting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing @@ -780,6 +755,8 @@ describe("ElementCall", () => { }); it("fails to connect if the widget returns an error", async () => { + // we only send a JoinCall action if the widget is preloading + call.widget.data = { ...call.widget, preload: true }; mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.connect()).rejects.toBeDefined(); }); @@ -935,17 +912,13 @@ describe("ElementCall", () => { // should create call with perParticipantE2EE flag ElementCall.create(room); - - expect(addWidgetSpy.mock.calls[0][0].url).toContain("perParticipantE2EE=true"); - ElementCall.get(room)?.destroy(); + expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(true); // should create call without perParticipantE2EE flag enabledSettings.add("feature_disable_call_per_sender_encryption"); - await ElementCall.create(room); + expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(false); enabledSettings.delete("feature_disable_call_per_sender_encryption"); - expect(addWidgetSpy.mock.calls[1][0].url).not.toContain("perParticipantE2EE=true"); - client.isRoomEncrypted.mockClear(); addWidgetSpy.mockRestore(); }); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index de3dd8be887..d5620e9b7d6 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -167,6 +167,7 @@ describe("IncomingCallEvent", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); From 01d63dfede6226e47581f29e33f955c78b352c69 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 15 Dec 2023 13:22:25 +0100 Subject: [PATCH 05/51] remove lobby device button tests Signed-off-by: Timo K --- test/components/views/voip/CallView-test.tsx | 37 -------------------- 1 file changed, 37 deletions(-) diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 89a22832da4..55fcc6d3f2f 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -174,41 +174,4 @@ describe("CallView", () => { WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); }); }); - - describe("device buttons", () => { - const fakeVideoInput1: MediaDeviceInfo = { - deviceId: "v1", - groupId: "v1", - label: "Webcam", - kind: "videoinput", - toJSON: () => {}, - }; - const fakeVideoInput2: MediaDeviceInfo = { - deviceId: "v2", - groupId: "v2", - label: "Othercam", - kind: "videoinput", - toJSON: () => {}, - }; - const fakeAudioInput1: MediaDeviceInfo = { - deviceId: "v1", - groupId: "v1", - label: "Headphones", - kind: "audioinput", - toJSON: () => {}, - }; - const fakeAudioInput2: MediaDeviceInfo = { - deviceId: "v2", - groupId: "v2", - label: "Tailphones", - kind: "audioinput", - toJSON: () => {}, - }; - - it("hide when no devices are available", async () => { - await renderView(); - expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); - expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); - }); - }); }); From b12d176ef83a2c2443ec547be13a88fff806a354 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 15 Dec 2023 13:29:43 +0100 Subject: [PATCH 06/51] i18n Signed-off-by: Timo K --- src/i18n/strings/en_EN.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7b687d9a03f..f67a6fdd686 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3779,7 +3779,6 @@ "already_in_call_person": "You're already in a call with this person.", "answered_elsewhere": "Answered Elsewhere", "answered_elsewhere_description": "The call was answered on another device.", - "audio_devices": "Audio devices", "call_failed": "Call Failed", "call_failed_description": "The call could not be established", "call_failed_media": "Call failed because webcam or microphone could not be accessed. Check that:", @@ -3827,10 +3826,6 @@ "msisdn_lookup_failed": "Unable to look up phone number", "msisdn_lookup_failed_description": "There was an error looking up the phone number", "msisdn_transfer_failed": "Unable to transfer call", - "n_people_joined": { - "one": "%(count)s person joined", - "other": "%(count)s people joined" - }, "no_audio_input_description": "We didn't find a microphone on your device. Please check your settings and try again.", "no_audio_input_title": "No microphone found", "no_media_perms_description": "You may need to manually permit %(brand)s to access your microphone/webcam", @@ -3866,7 +3861,6 @@ "user_is_presenting": "%(sharerName)s is presenting", "video_call": "Video call", "video_call_started": "Video call started", - "video_devices": "Video devices", "voice_call": "Voice call", "you_are_presenting": "You are presenting" }, From 448403c257c7dfc6758b14b5a207d9e2732667c9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 15 Dec 2023 13:44:26 +0100 Subject: [PATCH 07/51] use voip participant label Signed-off-by: Timo K --- src/components/views/rooms/LiveContentSummary.tsx | 2 +- src/i18n/strings/en_EN.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx index 711a310b24b..847e1f1f2fe 100644 --- a/src/components/views/rooms/LiveContentSummary.tsx +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -51,7 +51,7 @@ export const LiveContentSummary: FC = ({ type, text, active, participantC {" • "} {participantCount} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f67a6fdd686..658dbdb0e46 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -486,10 +486,6 @@ "one": "%(count)s member", "other": "%(count)s members" }, - "n_participants": { - "one": "1 participant", - "other": "%(count)s participants" - }, "n_rooms": { "one": "%(count)s room", "other": "%(count)s rooms" @@ -3826,6 +3822,10 @@ "msisdn_lookup_failed": "Unable to look up phone number", "msisdn_lookup_failed_description": "There was an error looking up the phone number", "msisdn_transfer_failed": "Unable to transfer call", + "n_people_joined": { + "one": "%(count)s person joined", + "other": "%(count)s people joined" + }, "no_audio_input_description": "We didn't find a microphone on your device. Please check your settings and try again.", "no_audio_input_title": "No microphone found", "no_media_perms_description": "You may need to manually permit %(brand)s to access your microphone/webcam", From ce76630b57037b80b664b863d564fff84b5800ab Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 15 Dec 2023 14:01:21 +0100 Subject: [PATCH 08/51] update tests Signed-off-by: Timo K --- test/components/views/messages/CallEvent-test.tsx | 2 +- test/components/views/rooms/RoomTile-test.tsx | 4 ++-- test/toasts/IncomingCallToast-test.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx index 2b29f866c68..f2d1ebb590c 100644 --- a/test/components/views/messages/CallEvent-test.tsx +++ b/test/components/views/messages/CallEvent-test.tsx @@ -140,7 +140,7 @@ describe("CallEvent", () => { renderEvent(); screen.getByText("@alice:example.org started a video call"); - screen.getByLabelText("2 participants"); + screen.getByLabelText("2 people joined"); // Test that the join button works const dispatcherSpy = jest.fn(); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 616274a66a5..a834af9df91 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -274,12 +274,12 @@ describe("RoomTile", () => { act(() => { call.participants = new Map([alice]); }); - expect(screen.getByLabelText("1 participant").textContent).toBe("1"); + expect(screen.getByLabelText("1 person joined").textContent).toBe("1"); act(() => { call.participants = new Map([alice, bob, carol]); }); - expect(screen.getByLabelText("4 participants").textContent).toBe("4"); + expect(screen.getByLabelText("4 people joined").textContent).toBe("4"); act(() => { call.participants = new Map(); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index d5620e9b7d6..162ccca76f9 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -125,7 +125,7 @@ describe("IncomingCallEvent", () => { screen.getByText("Video call started"); screen.getByText("Video"); - screen.getByLabelText("3 participants"); + screen.getByLabelText("3 people joined"); screen.getByRole("button", { name: "Join" }); screen.getByRole("button", { name: "Close" }); From f01b155fac2d83c3f844e616edefb3583d7c3ca2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:45:35 +0100 Subject: [PATCH 09/51] fix rounded corners in pip Signed-off-by: Timo K --- res/css/views/rooms/_AppsDrawer.pcss | 4 ++++ src/components/views/elements/AppTile.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 99d64739fa5..4b842a8954c 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -325,6 +325,10 @@ limitations under the License. &.mx_AppTileBody--call { border-radius: 0px; } + &.mx_AppTileBody--call, + &.mx_AppTileBody--mini { + border-radius: 8px; + } } /* appTileBody is embedded to PersistedElement outside of mx_AppTile, diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 642e2bd4769..c7f1d590e33 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -608,11 +608,11 @@ export default class AppTile extends React.Component { "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;"; const appTileBodyClass = classNames({ - // We don't want mx_AppTileBody (rounded corners) for call widgets "mx_AppTileBody": true, "mx_AppTileBody--large": !this.props.miniMode, "mx_AppTileBody--mini": this.props.miniMode, "mx_AppTileBody--loading": this.state.loading, + // We don't want mx_AppTileBody (rounded corners) for call widgets "mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred, }); const appTileBodyStyles: CSSProperties = {}; From 68808fbee3f6f43da32688726a874d698548c7c8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:46:39 +0100 Subject: [PATCH 10/51] allow joining call in legacy room header (without banner) Signed-off-by: Timo K --- src/components/views/rooms/LegacyRoomHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index dbcc8bdf305..ecc5eb3aee8 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -309,7 +309,7 @@ const CallButtons: FC = ({ room }) => { } else { return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call"))); } - } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { + } else if (hasLegacyCall || hasJitsiWidget) { return ( <> {makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))} From 821211a5e56cdfc821c215b91d8a915ccc1c112d Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:48:06 +0100 Subject: [PATCH 11/51] Introduce new connection states for calls. And use them for integrated lobby. Signed-off-by: Timo K --- .../views/rooms/RoomTileCallSummary.tsx | 4 ++ src/components/views/voip/CallView.tsx | 6 +-- src/models/Call.ts | 51 ++++++++++++++----- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 8cdd01598fd..0809ad02991 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -35,6 +35,10 @@ export const RoomTileCallSummary: FC = ({ call }) => { text = _t("common|video"); active = false; break; + case ConnectionState.Lobby: + text = _t("common|lobby"); + active = false; + break; case ConnectionState.Connecting: text = _t("room|joining"); active = true; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index c623b371c00..dcdced3bf89 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -18,7 +18,7 @@ import React, { FC, useState, useContext, useEffect, useCallback } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import type { Room } from "matrix-js-sdk/src/matrix"; -import { Call, ElementCall } from "../../../models/Call"; +import { Call, ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; @@ -101,8 +101,8 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby } call.clean(); }, [call]); useEffect(() => { - if (call.connectionState === "disconnected") { - (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; + (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; + if (call.connectionState == ConnectionState.Disconnected) { connect(); } }, [call.connectionState, call.widget.data, connect, skipLobby]); diff --git a/src/models/Call.ts b/src/models/Call.ts index 64c939678fe..c50ebcd96eb 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -32,6 +32,8 @@ import { IWidgetApiRequest } from "matrix-widget-api"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; // eslint-disable-next-line no-restricted-imports +import { CallMembership } from "matrix-js-sdk/src/matrixrtc/CallMembership"; +// eslint-disable-next-line no-restricted-imports import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; // eslint-disable-next-line no-restricted-imports import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types"; @@ -77,7 +79,12 @@ 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", @@ -210,7 +217,7 @@ export abstract class Call extends TypedEventEmitter { - this.connectionState = ConnectionState.Connecting; + this.connectionState = ConnectionState.WidgetLoading; const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = (await MediaDeviceHandler.getDevices())!; @@ -246,7 +253,7 @@ export abstract class Call extends TypedEventEmitter { - if (this.connectionState !== ConnectionState.Connected) throw new Error("Not connected"); + if (!this.connected) throw new Error("Not connected"); this.connectionState = ConnectionState.Disconnecting; await this.performDisconnection(); @@ -853,16 +860,36 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); 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; await new Promise((resolve) => { - const waitForLobbyJoin = (roomId: string, session: MatrixRTCSession): void => { - if (this.session.callId === session.callId && roomId === this.roomId) { - resolve(); - // This listener is not needed anymore. The promise resolved and we updated to the connection state - // when `performConnection` resolves. - this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); - } - }; - this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); + const session = this.client.matrixRTC.getActiveRoomSession(this.room); + if (session) { + const waitForLobbyJoin = ( + oldMemberships: CallMembership[], + newMemberships: CallMembership[], + ): void => { + if (newMemberships.some((m) => m.sender === this.client.getUserId())) { + resolve(); + // This listener is not needed anymore. The promise resolved and we updated to the connection state + // when `performConnection` resolves. + session.off(MatrixRTCSessionEvent.MembershipsChanged, waitForLobbyJoin); + } + }; + session.on(MatrixRTCSessionEvent.MembershipsChanged, waitForLobbyJoin); + } else { + const waitForLobbyJoin = (roomId: string, session: MatrixRTCSession): void => { + if (this.session.callId === session.callId && roomId === this.roomId) { + resolve(); + // This listener is not needed anymore. The promise resolved and we updated to the connection state + // when `performConnection` resolves. + this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); + } + }; + this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); + } }); } } From d1e3a6db0fc351bcb7bf8a01c9bde5666c1acd27 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:48:29 +0100 Subject: [PATCH 12/51] New room header call join Fix broken top container element call. Signed-off-by: Timo K --- src/hooks/room/useRoomCall.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index e08b8fbe643..e48c66eed62 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -33,6 +33,9 @@ import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; import { IApp } from "../../stores/WidgetStore"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../dispatcher/actions"; export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; @@ -167,7 +170,13 @@ export const useRoomCall = ( (evt: React.MouseEvent): void => { evt.stopPropagation(); if (widget && promptPinWidget) { - WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + skipLobby: evt.shiftKey, + }); } else { placeCall(room, CallType.Voice, callType, evt.shiftKey); } @@ -178,7 +187,13 @@ export const useRoomCall = ( (evt: React.MouseEvent): void => { evt.stopPropagation(); if (widget && promptPinWidget) { - WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + skipLobby: evt.shiftKey, + }); } else { placeCall(room, CallType.Video, callType, evt.shiftKey); } From 1a2a2c9cdf2118b6dba61cc6305fa7f12a317ce7 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:48:39 +0100 Subject: [PATCH 13/51] i18n Signed-off-by: Timo K --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 658dbdb0e46..45ecb8d14a8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -473,6 +473,7 @@ "legal": "Legal", "light": "Light", "loading": "Loading…", + "lobby": "Lobby", "location": "Location", "low_priority": "Low priority", "matrix": "Matrix", From 24d39b88940f978e4517ee3bcdb925464135410b Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 17:51:29 +0100 Subject: [PATCH 14/51] Fix closing element call in lobby view. (should destroy call if there the user never managed to connect (not clicked join in lobby) Signed-off-by: Timo K --- src/stores/CallStore.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index c32a5421339..cafec1d5986 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -29,6 +29,8 @@ import WidgetStore from "./WidgetStore"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; +import { SdkContextClass } from "../contexts/SDKContext"; +import ActiveWidgetStore from "./ActiveWidgetStore"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -66,6 +68,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { } this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSession); this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSession); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); @@ -203,4 +206,25 @@ export class CallStore extends AsyncStoreWithClient<{}> { private onRTCSession = (roomId: string, session: MatrixRTCSession): void => { this.updateRoom(session.room); }; + private onRoomViewStoreUpdate = (): void => { + this.calls.forEach((call) => { + // All calls where the user has not connected (calls in lobby or disconnected) + // should be destroyed if the user does not view the call anymore. + // A call in lobby state can easily be closed by not viewing the call anymore. + let viewedCallRoomId = null; + if (SdkContextClass.instance.roomViewStore.isViewingCall()) { + viewedCallRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + } + + if ( + viewedCallRoomId !== call.roomId && + (call.connectionState === ConnectionState.Disconnected || + call.connectionState === ConnectionState.Lobby) && + // Only destroy the call if it is associated with an active widget. (the call is already shown) + ActiveWidgetStore.instance.getWidgetPersistence(call.widget.id, call.roomId) + ) { + call?.destroy(); + } + }); + }; } From 54f39a9e81127bf674ba5df31db84aeb0f9541de Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 20:09:15 +0100 Subject: [PATCH 15/51] all cases for connection state Signed-off-by: Timo K --- src/components/views/messages/CallEvent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index dd2e29f51c8..f62fa2d65ed 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -131,13 +131,13 @@ const ActiveLoadedCallEvent = forwardRef(({ mxE switch (connectionState) { case ConnectionState.Disconnected: return [_t("action|join"), "primary", connect]; - case ConnectionState.Connecting: - return [_t("action|join"), "primary", null]; case ConnectionState.Connected: return [_t("action|leave"), "danger", disconnect]; case ConnectionState.Disconnecting: return [_t("action|leave"), "danger", null]; } + // ConnectionState.Connecting || ConnectionState.Lobby || ConnectionState.WidgetLoading + return [_t("action|join"), "primary", null]; }, [connectionState, connect, disconnect]); return ( From 628ea015bd91d17c61c547d95769372790be0366 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Dec 2023 20:44:56 +0100 Subject: [PATCH 16/51] add correct LiveContentSummary labels Signed-off-by: Timo K --- src/components/views/rooms/RoomTileCallSummary.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 0809ad02991..c244a78b438 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -35,6 +35,10 @@ 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; From 8ccb991bd1d379070b36f2ad831efd21649d2799 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 28 Dec 2023 11:52:40 +0100 Subject: [PATCH 17/51] Theme widget loading (no rounded corner) destroy call when switching room while a call is loading. Signed-off-by: Timo K --- res/css/views/rooms/_AppsDrawer.pcss | 3 +-- src/stores/CallStore.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 4b842a8954c..fc595683226 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -325,8 +325,7 @@ limitations under the License. &.mx_AppTileBody--call { border-radius: 0px; } - &.mx_AppTileBody--call, - &.mx_AppTileBody--mini { + &.mx_AppTileBody--call.mx_AppTileBody--mini { border-radius: 8px; } } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index cafec1d5986..f47e4c0a11b 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -217,13 +217,14 @@ export class CallStore extends AsyncStoreWithClient<{}> { } if ( - viewedCallRoomId !== call.roomId && - (call.connectionState === ConnectionState.Disconnected || - call.connectionState === ConnectionState.Lobby) && - // Only destroy the call if it is associated with an active widget. (the call is already shown) - ActiveWidgetStore.instance.getWidgetPersistence(call.widget.id, call.roomId) + (viewedCallRoomId !== call.roomId && + (call.connectionState === ConnectionState.Disconnected || + call.connectionState === ConnectionState.Lobby) && + // Only destroy the call if it is associated with an active widget. (the call is already shown) + ActiveWidgetStore.instance.getWidgetPersistence(call.widget.id, call.roomId)) || + call.connectionState === ConnectionState.WidgetLoading ) { - call?.destroy(); + call.destroy(); } }); }; From 3fed01446778bf9d1a05fa3ffaee22a740cf6638 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 15 Jan 2024 11:02:54 +0100 Subject: [PATCH 18/51] temp Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 15 +- src/components/views/voip/LegacyLobby.tsx | 278 ++++++++++++++++++++++ src/models/Call.ts | 63 +++-- src/stores/CallStore.ts | 10 +- 4 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 src/components/views/voip/LegacyLobby.tsx diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index b7549675188..17548ce672a 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -23,6 +23,7 @@ import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { CallStore } from "../../../stores/CallStore"; +import { Lobby } from "./LegacyLobby"; interface StartCallViewProps { room: Room; @@ -59,7 +60,7 @@ const StartCallView: FC = ({ useEffect(() => { (async (): Promise => { - // If the call was successfully started, connect automatically + // If the call was successfully created, connect automatically if (call !== null) { try { // Disconnect from any other active calls first, since we don't yet support holding @@ -73,7 +74,8 @@ const StartCallView: FC = ({ }, [call]); return ( -
+
+ {/* {!!call?.connected && !!(call?.widget?.type == "jitsi") ? : null} */} {call !== null && ( = ({ room, resizing, call, skipLobby, // We'll take this opportunity to tidy up our room state useEffect(() => { call.clean(); + // return () => { + // if (call.connectionState === ConnectionState.Lobby) call.setDisconnected(); + // }; }, [call]); useEffect(() => { (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; if (call.connectionState == ConnectionState.Disconnected) { - connect(); + // immediately connect (this will start the lobby view in the widget) + // connect(); } - }, [call.connectionState, call.widget.data, connect, skipLobby]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [call.widget.data, connect, skipLobby]); return (
diff --git a/src/components/views/voip/LegacyLobby.tsx b/src/components/views/voip/LegacyLobby.tsx new file mode 100644 index 00000000000..0829a6a5338 --- /dev/null +++ b/src/components/views/voip/LegacyLobby.tsx @@ -0,0 +1,278 @@ +/* +Copyright 2021 Timo Kandra + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, ReactNode, useState, useEffect, useMemo, useRef, useCallback, AriaRole } from "react"; +import classNames from "classnames"; +import { logger } from "matrix-js-sdk/src/logger"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import { _t } from "../../../languageHandler"; +import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import MediaDeviceHandler, { IMediaDevices } from "../../../MediaDeviceHandler"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { aboveRightOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; +import { Alignment } from "../elements/Tooltip"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import MemberAvatar from "../avatars/MemberAvatar"; + +interface DeviceButtonProps { + kind: string; + devices: MediaDeviceInfo[]; + setDevice: (device: MediaDeviceInfo) => void; + deviceListLabel: string; + muted: boolean; + disabled: boolean; + toggle: () => void; + unmutedTitle: string; + mutedTitle: string; +} + +const DeviceButton: FC = ({ + kind, + devices, + setDevice, + deviceListLabel, + muted, + disabled, + toggle, + unmutedTitle, + mutedTitle, +}) => { + const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu(); + const selectDevice = useCallback( + (device: MediaDeviceInfo) => { + setDevice(device); + closeMenu(); + }, + [setDevice, closeMenu], + ); + + let contextMenu: JSX.Element | null = null; + if (showMenu) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + contextMenu = ( + + + {devices.map((d) => ( + selectDevice(d)} /> + ))} + + + ); + } + + if (!devices.length) return null; + + return ( +
+ + {devices.length > 1 ? ( + + ) : null} + {contextMenu} +
+ ); +}; + +interface LobbyProps { + room: Room; + connect?: () => Promise; + joinCallButtonDisabledTooltip?: string; + children?: ReactNode; +} + +export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { + const [connecting, setConnecting] = useState(false); + const me = useMemo(() => room.getMember(room.myUserId)!, [room]); + const videoRef = useRef(null); + + const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); + + const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); + const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); + + const toggleAudio = useCallback(() => { + MediaDeviceHandler.startWithAudioMuted = !audioMuted; + setAudioMuted(!audioMuted); + }, [audioMuted, setAudioMuted]); + const toggleVideo = useCallback(() => { + MediaDeviceHandler.startWithVideoMuted = !videoMuted; + setVideoMuted(!videoMuted); + }, [videoMuted, setVideoMuted]); + + // In case we can not fetch media devices we should mute the devices + const handleMediaDeviceFailing = (message: string): void => { + MediaDeviceHandler.startWithAudioMuted = true; + MediaDeviceHandler.startWithVideoMuted = true; + logger.warn(message); + }; + + const [videoStream, audioInputs, videoInputs] = useAsyncMemo( + async (): Promise<[MediaStream | null, MediaDeviceInfo[], MediaDeviceInfo[]]> => { + let devices: IMediaDevices | undefined; + try { + devices = await MediaDeviceHandler.getDevices(); + if (devices === undefined) { + handleMediaDeviceFailing("Could not access devices!"); + return [null, [], []]; + } + } catch (error) { + handleMediaDeviceFailing(`Unable to get Media Devices: ${error}`); + return [null, [], []]; + } + + // We get the preview stream before requesting devices: this is because + // we need (in some browsers) an active media stream in order to get + // non-blank labels for the devices. + let stream: MediaStream | null = null; + + try { + if (devices!.audioinput.length > 0) { + // Holding just an audio stream will be enough to get us all device labels, so + // if video is muted, don't bother requesting video. + stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: !videoMuted && devices!.videoinput.length > 0 && { deviceId: videoInputId }, + }); + } else if (devices!.videoinput.length > 0) { + // We have to resort to a video stream, even if video is supposed to be muted. + stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } }); + } + } catch (e) { + logger.warn(`Failed to get stream for device ${videoInputId}`, e); + handleMediaDeviceFailing(`Have access to Device list but unable to read from Media Devices`); + } + + // Refresh the devices now that we hold a stream + if (stream !== null) devices = await MediaDeviceHandler.getDevices(); + + // If video is muted, we don't actually want the stream, so we can get rid of it now. + if (videoMuted) { + stream?.getTracks().forEach((t) => t.stop()); + stream = null; + } + + return [stream, devices?.audioinput ?? [], devices?.videoinput ?? []]; + }, + [videoInputId, videoMuted], + [null, [], []], + ); + + const setAudioInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setAudioInput(device.deviceId); + }, []); + const setVideoInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setVideoInput(device.deviceId); + setVideoInputId(device.deviceId); + }, []); + + useEffect(() => { + if (videoStream) { + const videoElement = videoRef.current!; + videoElement.srcObject = videoStream; + videoElement.play(); + + return () => { + videoStream.getTracks().forEach((track) => track.stop()); + videoElement.srcObject = null; + }; + } + }, [videoStream]); + + const onConnectClick = useCallback( + async (ev: ButtonEvent): Promise => { + ev.preventDefault(); + setConnecting(true); + try { + await connect(); + } catch (e) { + logger.error(e); + setConnecting(false); + } + }, + [connect, setConnecting], + ); + + return ( +
+ {children} +
+ +
+ +
+ ); +}; diff --git a/src/models/Call.ts b/src/models/Call.ts index c50ebcd96eb..7b98533a0ee 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -349,7 +349,7 @@ export class JitsiCall extends Call { } public static async create(room: Room): Promise { - await WidgetUtils.addJitsiWidget(room.client, room.roomId, CallType.Video, "Group call", true, room.name); + await WidgetUtils.addJitsiWidget(room.client, room.roomId, CallType.Video, "Group call", false, room.name); } private updateParticipants(): void { @@ -856,42 +856,42 @@ export class ElementCall extends Call { throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); } } - this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); - if (!(this.widget.data ?? {}).skipLobby) { + this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + + if ((this.widget.data ?? {}).skipLobby) { + this.connectionState = ConnectionState.Connecting; + } else { // 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; - await new Promise((resolve) => { - const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session) { - const waitForLobbyJoin = ( - oldMemberships: CallMembership[], - newMemberships: CallMembership[], - ): void => { - if (newMemberships.some((m) => m.sender === this.client.getUserId())) { - resolve(); - // This listener is not needed anymore. The promise resolved and we updated to the connection state - // when `performConnection` resolves. - session.off(MatrixRTCSessionEvent.MembershipsChanged, waitForLobbyJoin); - } - }; - session.on(MatrixRTCSessionEvent.MembershipsChanged, waitForLobbyJoin); - } else { - const waitForLobbyJoin = (roomId: string, session: MatrixRTCSession): void => { - if (this.session.callId === session.callId && roomId === this.roomId) { - resolve(); - // This listener is not needed anymore. The promise resolved and we updated to the connection state - // when `performConnection` resolves. - this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); - } - }; - this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForLobbyJoin); - } - }); } + await new Promise((resolve) => { + const session = this.client.matrixRTC.getActiveRoomSession(this.room); + if (session) { + const waitForJoin = (oldMemberships: CallMembership[], newMemberships: CallMembership[]): void => { + if (newMemberships.some((m) => m.sender === this.client.getUserId())) { + resolve(); + // This listener is not needed anymore. The promise resolved and we updated to the connection state + // when `performConnection` resolves. + session.off(MatrixRTCSessionEvent.MembershipsChanged, waitForJoin); + } + }; + session.on(MatrixRTCSessionEvent.MembershipsChanged, waitForJoin); + } else { + const waitForJoin = (roomId: string, session: MatrixRTCSession): void => { + if (this.session.callId === session.callId && roomId === this.roomId) { + resolve(); + // This listener is not needed anymore. The promise resolved and we updated to the connection state + // when `performConnection` resolves. + this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForJoin); + } + }; + this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForJoin); + } + }); } protected async performDisconnection(): Promise { @@ -903,7 +903,6 @@ export class ElementCall extends Call { } public setDisconnected(): void { - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); super.setDisconnected(); @@ -912,7 +911,7 @@ export class ElementCall extends Call { 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); diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index f47e4c0a11b..7671c897647 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -31,6 +31,7 @@ import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; import { SdkContextClass } from "../contexts/SDKContext"; import ActiveWidgetStore from "./ActiveWidgetStore"; +import { isVideoRoom } from "../utils/video-rooms"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -212,7 +213,10 @@ export class CallStore extends AsyncStoreWithClient<{}> { // should be destroyed if the user does not view the call anymore. // A call in lobby state can easily be closed by not viewing the call anymore. let viewedCallRoomId = null; - if (SdkContextClass.instance.roomViewStore.isViewingCall()) { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + const room = roomId ? this.matrixClient?.getRoom(roomId) : undefined; + const videoRoom = room ? isVideoRoom(room) : false; + if (SdkContextClass.instance.roomViewStore.isViewingCall() || videoRoom) { viewedCallRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); } @@ -221,10 +225,12 @@ export class CallStore extends AsyncStoreWithClient<{}> { (call.connectionState === ConnectionState.Disconnected || call.connectionState === ConnectionState.Lobby) && // Only destroy the call if it is associated with an active widget. (the call is already shown) - ActiveWidgetStore.instance.getWidgetPersistence(call.widget.id, call.roomId)) || + ActiveWidgetStore.instance.isLive(call.widget.id, call.roomId)) || call.connectionState === ConnectionState.WidgetLoading ) { call.destroy(); + } else if (viewedCallRoomId === call.roomId && call.connectionState === ConnectionState.Disconnected) { + call.connect(); } }); }; From a2fcfc0676e20666b01b0e3723b90a14a44cb42e Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 15 Jan 2024 19:04:45 +0100 Subject: [PATCH 19/51] usei view room dispatcher instead of emitter Signed-off-by: Timo K --- src/models/Call.ts | 20 ++++++----------- src/stores/CallStore.ts | 48 +++++++++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 4978ed4f31b..7a686bbf60c 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -467,6 +467,7 @@ 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; @@ -577,13 +578,10 @@ export class JitsiCall extends Call { // Tell others that we're connected, by adding our device to room state await this.addOurDevice(); // Re-add this device every so often so our video member event doesn't become stale - this.resendDevicesTimer = window.setInterval( - async (): Promise => { - logger.log(`Resending video member event for ${this.roomId}`); - await this.addOurDevice(); - }, - (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4, - ); + this.resendDevicesTimer = window.setInterval(async (): Promise => { + logger.log(`Resending video member event for ${this.roomId}`); + await this.addOurDevice(); + }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4); } else if (state === ConnectionState.Disconnected && isConnected(prevState)) { this.updateParticipants(); // Local echo @@ -770,11 +768,7 @@ export class ElementCall extends Call { this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {}); } - private constructor( - public session: MatrixRTCSession, - widget: IApp, - client: MatrixClient, - ) { + private constructor(public session: MatrixRTCSession, widget: IApp, client: MatrixClient) { super(widget, client); this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); @@ -934,7 +928,7 @@ export class ElementCall extends Call { } private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => { - // Don't destroy widget on hangup for video call rooms. + // Don't destroy call on hangup for video call rooms. if (roomId == this.roomId && !this.room.isCallRoom()) { this.destroy(); } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 7671c897647..a9abe78e12c 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -20,9 +20,9 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { Optional } from "matrix-events-sdk"; import type { GroupCall, Room } from "matrix-js-sdk/src/matrix"; -import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import WidgetStore from "./WidgetStore"; @@ -32,6 +32,10 @@ import { Call, CallEvent, ConnectionState } from "../models/Call"; import { SdkContextClass } from "../contexts/SDKContext"; import ActiveWidgetStore from "./ActiveWidgetStore"; import { isVideoRoom } from "../utils/video-rooms"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Action } from "../dispatcher/actions"; +import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; +import defaultDispatcher from "../dispatcher/dispatcher"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -55,8 +59,11 @@ export class CallStore extends AsyncStoreWithClient<{}> { this.setMaxListeners(100); // One for each RoomTile } - protected async onAction(): Promise { - // nothing to do + protected async onAction(payload: ActionPayload): Promise { + if (payload.action !== Action.ActiveRoomChanged) return; + + const changePayload = payload; + this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); } protected async onReady(): Promise { @@ -69,7 +76,6 @@ export class CallStore extends AsyncStoreWithClient<{}> { } this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSession); this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSession); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); @@ -207,30 +213,34 @@ export class CallStore extends AsyncStoreWithClient<{}> { private onRTCSession = (roomId: string, session: MatrixRTCSession): void => { this.updateRoom(session.room); }; - private onRoomViewStoreUpdate = (): void => { + private handleViewedRoomChange = (_oldRoomId: Optional, newRoomId: Optional): void => { this.calls.forEach((call) => { // All calls where the user has not connected (calls in lobby or disconnected) // should be destroyed if the user does not view the call anymore. // A call in lobby state can easily be closed by not viewing the call anymore. let viewedCallRoomId = null; - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - const room = roomId ? this.matrixClient?.getRoom(roomId) : undefined; - const videoRoom = room ? isVideoRoom(room) : false; + const newRoom = newRoomId ? this.matrixClient?.getRoom(newRoomId) : undefined; + const videoRoom = newRoom ? isVideoRoom(newRoom) : false; + const connState = call.connectionState; + const isDisconnceted = connState === ConnectionState.Disconnected; + const isInLobby = connState === ConnectionState.Lobby; + const isWidgetLive = ActiveWidgetStore.instance.isLive(call.widget.id, call.roomId); + const isWidgetLoading = connState === ConnectionState.WidgetLoading; if (SdkContextClass.instance.roomViewStore.isViewingCall() || videoRoom) { viewedCallRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); } - - if ( - (viewedCallRoomId !== call.roomId && - (call.connectionState === ConnectionState.Disconnected || - call.connectionState === ConnectionState.Lobby) && + if (viewedCallRoomId !== call.roomId) { + if ( // Only destroy the call if it is associated with an active widget. (the call is already shown) - ActiveWidgetStore.instance.isLive(call.widget.id, call.roomId)) || - call.connectionState === ConnectionState.WidgetLoading - ) { - call.destroy(); - } else if (viewedCallRoomId === call.roomId && call.connectionState === ConnectionState.Disconnected) { - call.connect(); + ((isDisconnceted || isInLobby) && isWidgetLive) || + isWidgetLoading + ) { + call.destroy(); + } + } else { + if (call.connectionState === ConnectionState.Disconnected) { + call.connect(); + } } }); }; From 521ac24033241f168eea7ad45df91417abe817bf Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 15 Jan 2024 19:15:25 +0100 Subject: [PATCH 20/51] tidy up Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 5 +- src/components/views/voip/LegacyLobby.tsx | 278 ---------------------- src/stores/CallStore.ts | 8 +- 3 files changed, 5 insertions(+), 286 deletions(-) delete mode 100644 src/components/views/voip/LegacyLobby.tsx diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 17548ce672a..4f74fce4254 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -75,7 +75,6 @@ const StartCallView: FC = ({ return (
- {/* {!!call?.connected && !!(call?.widget?.type == "jitsi") ? : null} */} {call !== null && ( = ({ room, resizing, call, skipLobby, const connect = useCallback(async (): Promise => { // Disconnect from any other active calls first, since we don't yet support holding + // TODO: disconnect only after the call has been connected (after Lobby) await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect())); await call.connect(); }, [call]); @@ -111,9 +111,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, // We'll take this opportunity to tidy up our room state useEffect(() => { call.clean(); - // return () => { - // if (call.connectionState === ConnectionState.Lobby) call.setDisconnected(); - // }; }, [call]); useEffect(() => { (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; diff --git a/src/components/views/voip/LegacyLobby.tsx b/src/components/views/voip/LegacyLobby.tsx deleted file mode 100644 index 0829a6a5338..00000000000 --- a/src/components/views/voip/LegacyLobby.tsx +++ /dev/null @@ -1,278 +0,0 @@ -/* -Copyright 2021 Timo Kandra - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { FC, ReactNode, useState, useEffect, useMemo, useRef, useCallback, AriaRole } from "react"; -import classNames from "classnames"; -import { logger } from "matrix-js-sdk/src/logger"; - -import type { Room } from "matrix-js-sdk/src/matrix"; -import { _t } from "../../../languageHandler"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MediaDeviceHandler, { IMediaDevices } from "../../../MediaDeviceHandler"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; -import { aboveRightOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; -import { Alignment } from "../elements/Tooltip"; -import { ButtonEvent } from "../elements/AccessibleButton"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import MemberAvatar from "../avatars/MemberAvatar"; - -interface DeviceButtonProps { - kind: string; - devices: MediaDeviceInfo[]; - setDevice: (device: MediaDeviceInfo) => void; - deviceListLabel: string; - muted: boolean; - disabled: boolean; - toggle: () => void; - unmutedTitle: string; - mutedTitle: string; -} - -const DeviceButton: FC = ({ - kind, - devices, - setDevice, - deviceListLabel, - muted, - disabled, - toggle, - unmutedTitle, - mutedTitle, -}) => { - const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu(); - const selectDevice = useCallback( - (device: MediaDeviceInfo) => { - setDevice(device); - closeMenu(); - }, - [setDevice, closeMenu], - ); - - let contextMenu: JSX.Element | null = null; - if (showMenu) { - const buttonRect = buttonRef.current!.getBoundingClientRect(); - contextMenu = ( - - - {devices.map((d) => ( - selectDevice(d)} /> - ))} - - - ); - } - - if (!devices.length) return null; - - return ( -
- - {devices.length > 1 ? ( - - ) : null} - {contextMenu} -
- ); -}; - -interface LobbyProps { - room: Room; - connect?: () => Promise; - joinCallButtonDisabledTooltip?: string; - children?: ReactNode; -} - -export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { - const [connecting, setConnecting] = useState(false); - const me = useMemo(() => room.getMember(room.myUserId)!, [room]); - const videoRef = useRef(null); - - const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); - - const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); - const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); - - const toggleAudio = useCallback(() => { - MediaDeviceHandler.startWithAudioMuted = !audioMuted; - setAudioMuted(!audioMuted); - }, [audioMuted, setAudioMuted]); - const toggleVideo = useCallback(() => { - MediaDeviceHandler.startWithVideoMuted = !videoMuted; - setVideoMuted(!videoMuted); - }, [videoMuted, setVideoMuted]); - - // In case we can not fetch media devices we should mute the devices - const handleMediaDeviceFailing = (message: string): void => { - MediaDeviceHandler.startWithAudioMuted = true; - MediaDeviceHandler.startWithVideoMuted = true; - logger.warn(message); - }; - - const [videoStream, audioInputs, videoInputs] = useAsyncMemo( - async (): Promise<[MediaStream | null, MediaDeviceInfo[], MediaDeviceInfo[]]> => { - let devices: IMediaDevices | undefined; - try { - devices = await MediaDeviceHandler.getDevices(); - if (devices === undefined) { - handleMediaDeviceFailing("Could not access devices!"); - return [null, [], []]; - } - } catch (error) { - handleMediaDeviceFailing(`Unable to get Media Devices: ${error}`); - return [null, [], []]; - } - - // We get the preview stream before requesting devices: this is because - // we need (in some browsers) an active media stream in order to get - // non-blank labels for the devices. - let stream: MediaStream | null = null; - - try { - if (devices!.audioinput.length > 0) { - // Holding just an audio stream will be enough to get us all device labels, so - // if video is muted, don't bother requesting video. - stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: !videoMuted && devices!.videoinput.length > 0 && { deviceId: videoInputId }, - }); - } else if (devices!.videoinput.length > 0) { - // We have to resort to a video stream, even if video is supposed to be muted. - stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } }); - } - } catch (e) { - logger.warn(`Failed to get stream for device ${videoInputId}`, e); - handleMediaDeviceFailing(`Have access to Device list but unable to read from Media Devices`); - } - - // Refresh the devices now that we hold a stream - if (stream !== null) devices = await MediaDeviceHandler.getDevices(); - - // If video is muted, we don't actually want the stream, so we can get rid of it now. - if (videoMuted) { - stream?.getTracks().forEach((t) => t.stop()); - stream = null; - } - - return [stream, devices?.audioinput ?? [], devices?.videoinput ?? []]; - }, - [videoInputId, videoMuted], - [null, [], []], - ); - - const setAudioInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setAudioInput(device.deviceId); - }, []); - const setVideoInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setVideoInput(device.deviceId); - setVideoInputId(device.deviceId); - }, []); - - useEffect(() => { - if (videoStream) { - const videoElement = videoRef.current!; - videoElement.srcObject = videoStream; - videoElement.play(); - - return () => { - videoStream.getTracks().forEach((track) => track.stop()); - videoElement.srcObject = null; - }; - } - }, [videoStream]); - - const onConnectClick = useCallback( - async (ev: ButtonEvent): Promise => { - ev.preventDefault(); - setConnecting(true); - try { - await connect(); - } catch (e) { - logger.error(e); - setConnecting(false); - } - }, - [connect, setConnecting], - ); - - return ( -
- {children} -
- -
- -
- ); -}; diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index a9abe78e12c..abcda5f6d8f 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -60,10 +60,10 @@ export class CallStore extends AsyncStoreWithClient<{}> { } protected async onAction(payload: ActionPayload): Promise { - if (payload.action !== Action.ActiveRoomChanged) return; - - const changePayload = payload; - this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); + if (payload.action === Action.ActiveRoomChanged) { + const changePayload = payload; + this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); + } } protected async onReady(): Promise { From d658c56796960e39e6d4db263933546ded8cb1a4 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Jan 2024 14:54:55 +0100 Subject: [PATCH 21/51] returnToLobby + remove StartCallView Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 120 ++++++------------------- src/models/Call.ts | 28 ++++-- src/stores/CallStore.ts | 46 +--------- src/utils/video-rooms.ts | 2 +- 4 files changed, 51 insertions(+), 145 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 4f74fce4254..546160f9f14 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -14,81 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, useState, useContext, useEffect, useCallback, AriaRole } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; +import React, { FC, useContext, useEffect, AriaRole, useState } from "react"; import type { Room } from "matrix-js-sdk/src/matrix"; import { Call, ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; -import { CallStore } from "../../../stores/CallStore"; -import { Lobby } from "./LegacyLobby"; - -interface StartCallViewProps { - room: Room; - resizing: boolean; - call: Call | null; - setStartingCall: (value: boolean) => void; - startingCall: boolean; - skipLobby?: boolean; - role?: AriaRole; -} - -const StartCallView: FC = ({ - room, - resizing, - call, - setStartingCall, - startingCall, - skipLobby, - role, -}) => { - const cli = useContext(MatrixClientContext); - - // We need to do this awkward double effect system, - // because otherwise we will not have subscribed to the CallStore - // before we create the call which emits the UPDATE_ROOM event. - useEffect(() => { - setStartingCall(true); - }, [setStartingCall]); - useEffect(() => { - if (startingCall) { - ElementCall.create(room, skipLobby); - } - }, [room, skipLobby, startingCall]); - - useEffect(() => { - (async (): Promise => { - // If the call was successfully created, connect automatically - if (call !== null) { - try { - // Disconnect from any other active calls first, since we don't yet support holding - await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect())); - await call.connect(); - } catch (e) { - logger.error(e); - } - } - })(); - }, [call]); - - return ( -
- {call !== null && ( - - )} -
- ); -}; interface JoinCallViewProps { room: Room; @@ -101,25 +33,24 @@ interface JoinCallViewProps { const JoinCallView: FC = ({ room, resizing, call, skipLobby, role }) => { const cli = useContext(MatrixClientContext); - const connect = useCallback(async (): Promise => { - // Disconnect from any other active calls first, since we don't yet support holding - // TODO: disconnect only after the call has been connected (after Lobby) - await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect())); - await call.connect(); - }, [call]); - // We'll take this opportunity to tidy up our room state useEffect(() => { call.clean(); }, [call]); useEffect(() => { (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; - if (call.connectionState == ConnectionState.Disconnected) { + }, [call.widget.data, skipLobby]); + + useEffect(() => { + if (call.connectionState === ConnectionState.Disconnected) { // immediately connect (this will start the lobby view in the widget) - // connect(); + call.connect(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [call.widget.data, connect, skipLobby]); + 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]); return (
@@ -150,22 +81,21 @@ interface CallViewProps { export const CallView: FC = ({ room, resizing, waitForCall, skipLobby, role }) => { const call = useCall(room.roomId); - const [startingCall, setStartingCall] = useState(false); - - if (call === null || startingCall) { - if (waitForCall) return null; - return ( - - ); + const [shouldCreateCall, setShouldCreateCall] = useState(false); + useEffect(() => { + if (!waitForCall) { + setShouldCreateCall(true); + } + }, [waitForCall]); + useEffect(() => { + if (call === null && shouldCreateCall) { + ElementCall.create(room, skipLobby); + } + }, [call, room, shouldCreateCall, skipLobby, waitForCall]); + if (call === null) { + return null; } else { return ; } + // } }; diff --git a/src/models/Call.ts b/src/models/Call.ts index 7a686bbf60c..1d0312acebd 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -56,6 +56,7 @@ import { FontWatcher } from "../settings/watchers/FontWatcher"; import { PosthogAnalytics } from "../PosthogAnalytics"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers"; +import { isVideoRoom } from "../utils/video-rooms"; const TIMEOUT_MS = 16000; @@ -620,6 +621,11 @@ export class JitsiCall extends Call { await 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. + if (isVideoRoom(this.room)) { + this.connect(); + } }; } @@ -660,6 +666,7 @@ export class ElementCall extends Call { // Template variables are used, so that this can be configured using the widget data. preload: "$preload", // We want it to load in the background. skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. + returnToLobby: "$returnToLobby", // Skip the lobby in case we show a lobby component of our own. perParticipantE2EE: "$perParticipantE2EE", hideHeader: "true", // Hide the header since our room header is enough userId: client.getUserId()!, @@ -707,6 +714,7 @@ export class ElementCall extends Call { client: MatrixClient, skipLobby: boolean | undefined, preload: boolean | undefined, + returnToLobby: boolean | undefined, ): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); if (ecWidget) { @@ -719,6 +727,9 @@ export class ElementCall extends Call { if (preload !== undefined) { overwrites.preload = preload; } + if (returnToLobby !== undefined) { + overwrites.returnToLobby = returnToLobby; + } ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites); return ecWidget; } @@ -741,6 +752,7 @@ export class ElementCall extends Call { { skipLobby: skipLobby ?? false, preload: preload ?? false, + returnToLobby: returnToLobby ?? false, }, ), }, @@ -783,6 +795,7 @@ export class ElementCall extends Call { public static get(room: Room): ElementCall | null { // Only supported in the new group call experience or in video rooms. + if ( SettingsStore.getValue("feature_group_calls") || (SettingsStore.getValue("feature_video_rooms") && @@ -804,6 +817,7 @@ export class ElementCall extends Call { room.client, undefined, undefined, + isVideoRoom(room), ); return new ElementCall(session, availableOrCreatedWidget, room.client); } @@ -813,12 +827,9 @@ export class ElementCall extends Call { } public static async create(room: Room, skipLobby = false): Promise { - const isVideoRoom = - SettingsStore.getValue("feature_video_rooms") && - SettingsStore.getValue("feature_element_call_video_rooms") && - room.isCallRoom(); + const isVidRoom = isVideoRoom(room); - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false); + ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVidRoom); WidgetStore.instance.emit(UPDATE_EVENT, null); // Send Call notify @@ -829,7 +840,7 @@ export class ElementCall extends Call { ); const memberCount = getJoinedNonFunctionalMembers(room).length; - if (!isVideoRoom && existingRoomCallMembers.length == 0) { + if (!isVidRoom && existingRoomCallMembers.length == 0) { // send ringing event const content: ICallNotifyContent = { "application": "m.call", @@ -967,6 +978,11 @@ export class ElementCall extends Call { ev.preventDefault(); await 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. + if (isVideoRoom(this.room)) { + this.connect(); + } }; private onTileLayout = async (ev: CustomEvent): Promise => { diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index abcda5f6d8f..820355d6c61 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -20,22 +20,16 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; -import { Optional } from "matrix-events-sdk"; +import { GroupCall, Room } from "matrix-js-sdk/src/matrix"; -import type { GroupCall, Room } from "matrix-js-sdk/src/matrix"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import WidgetStore from "./WidgetStore"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; -import { SdkContextClass } from "../contexts/SDKContext"; -import ActiveWidgetStore from "./ActiveWidgetStore"; -import { isVideoRoom } from "../utils/video-rooms"; -import { ActionPayload } from "../dispatcher/payloads"; -import { Action } from "../dispatcher/actions"; -import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import defaultDispatcher from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -60,10 +54,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { } protected async onAction(payload: ActionPayload): Promise { - if (payload.action === Action.ActiveRoomChanged) { - const changePayload = payload; - this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); - } + // nothing to do } protected async onReady(): Promise { @@ -213,35 +204,4 @@ export class CallStore extends AsyncStoreWithClient<{}> { private onRTCSession = (roomId: string, session: MatrixRTCSession): void => { this.updateRoom(session.room); }; - private handleViewedRoomChange = (_oldRoomId: Optional, newRoomId: Optional): void => { - this.calls.forEach((call) => { - // All calls where the user has not connected (calls in lobby or disconnected) - // should be destroyed if the user does not view the call anymore. - // A call in lobby state can easily be closed by not viewing the call anymore. - let viewedCallRoomId = null; - const newRoom = newRoomId ? this.matrixClient?.getRoom(newRoomId) : undefined; - const videoRoom = newRoom ? isVideoRoom(newRoom) : false; - const connState = call.connectionState; - const isDisconnceted = connState === ConnectionState.Disconnected; - const isInLobby = connState === ConnectionState.Lobby; - const isWidgetLive = ActiveWidgetStore.instance.isLive(call.widget.id, call.roomId); - const isWidgetLoading = connState === ConnectionState.WidgetLoading; - if (SdkContextClass.instance.roomViewStore.isViewingCall() || videoRoom) { - viewedCallRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); - } - if (viewedCallRoomId !== call.roomId) { - if ( - // Only destroy the call if it is associated with an active widget. (the call is already shown) - ((isDisconnceted || isInLobby) && isWidgetLive) || - isWidgetLoading - ) { - call.destroy(); - } - } else { - if (call.connectionState === ConnectionState.Disconnected) { - call.connect(); - } - } - }); - }; } diff --git a/src/utils/video-rooms.ts b/src/utils/video-rooms.ts index 4e17a396628..3a5bbe2dde8 100644 --- a/src/utils/video-rooms.ts +++ b/src/utils/video-rooms.ts @@ -18,4 +18,4 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../settings/SettingsStore"; export const isVideoRoom = (room: Room): boolean => - room.isElementVideoRoom() || (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom()); + room.isElementVideoRoom() || SettingsStore.getValue("feature_video_rooms") && (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom()); From 9c117cf184d2371e2ca8bb33fe76f1b8822d8ae9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Jan 2024 15:06:03 +0100 Subject: [PATCH 22/51] comment cleanup Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 15 ++++++--------- src/models/Call.ts | 2 +- src/stores/RoomViewStore.tsx | 2 +- .../views/rooms/LegacyRoomHeader-test.tsx | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 546160f9f14..03722258f24 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -33,11 +33,13 @@ interface JoinCallViewProps { const JoinCallView: FC = ({ room, resizing, call, skipLobby, role }) => { const cli = useContext(MatrixClientContext); - // We'll take this opportunity to tidy up our room state useEffect(() => { + // We'll take this opportunity to tidy up our room state call.clean(); }, [call]); + useEffect(() => { + // Always update the widget data so that we don't ignore "skipLobby" accidentally. (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; }, [call.widget.data, skipLobby]); @@ -81,17 +83,12 @@ interface CallViewProps { export const CallView: FC = ({ room, resizing, waitForCall, skipLobby, role }) => { const call = useCall(room.roomId); - const [shouldCreateCall, setShouldCreateCall] = useState(false); - useEffect(() => { - if (!waitForCall) { - setShouldCreateCall(true); - } - }, [waitForCall]); + useEffect(() => { - if (call === null && shouldCreateCall) { + if (call === null && !waitForCall) { ElementCall.create(room, skipLobby); } - }, [call, room, shouldCreateCall, skipLobby, waitForCall]); + }, [call, room, skipLobby, waitForCall]); if (call === null) { return null; } else { diff --git a/src/models/Call.ts b/src/models/Call.ts index 1d0312acebd..a2f8f3ebac3 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -666,7 +666,7 @@ export class ElementCall extends Call { // Template variables are used, so that this can be configured using the widget data. preload: "$preload", // We want it to load in the background. skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. - returnToLobby: "$returnToLobby", // Skip the lobby in case we show a lobby component of our own. + returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", hideHeader: "true", // Hide the header since our room header is enough userId: client.getUserId()!, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 2794f7a3bc9..83c91fdab79 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -120,7 +120,7 @@ interface State { */ viewingCall: boolean; /** - * Whether want the call to skip the lobby and immediately join + * If we want the call to skip the lobby and immediately join */ skipLobby?: boolean; diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx index a17aeefb5dd..5e4b326cafb 100644 --- a/test/components/views/rooms/LegacyRoomHeader-test.tsx +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -565,7 +565,7 @@ describe("LegacyRoomHeader", () => { mockEnabledSettings(["feature_group_calls"]); await withCall(async (call) => { - // we set the call to skip lobby because otherwise the connection will wait until + // We set the call to skip lobby because otherwise the connection will wait until // the user clicks the "join" button, inside the widget lobby which is hard to mock. call.widget.data = { ...call.widget.data, skipLobby: true }; await call.connect(); From 29a69edf737d9a4898f83ea50a8d839c4cb393b3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Jan 2024 19:32:18 +0100 Subject: [PATCH 23/51] disconnect ongoing calls before making widget sticky. Signed-off-by: Timo K --- src/components/views/elements/AppTile.tsx | 5 ++ src/components/views/voip/CallView.tsx | 14 ++++- src/models/Call.ts | 65 +++++++++++++---------- src/stores/widgets/StopGapWidget.ts | 11 ++-- 4 files changed, 61 insertions(+), 34 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 3f6285078d4..dd83c5b3016 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -95,6 +95,11 @@ interface IProps { movePersistedElement?: MutableRefObject<(() => void) | undefined>; // An element to render after the iframe as an overlay overlay?: ReactNode; + // If defined this async method will be called when the widget requests to become sticky. + // It will only become sticky once the returned promise resolves. + // This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately. + // This promise allows to do Widget B related cleanup before Widget A becomes sticky. + stickyPromise: (() => Promise) | null; } interface IState { diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 03722258f24..586f9f11968 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, useContext, useEffect, AriaRole, useState } from "react"; +import React, { FC, useContext, useEffect, AriaRole, useCallback } from "react"; import type { Room } from "matrix-js-sdk/src/matrix"; import { Call, ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; +import { CallStore } from "../../../stores/CallStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface JoinCallViewProps { room: Room; @@ -53,7 +55,14 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, if (!call.connected) call.destroy(); }; }, [call]); - + const allOtherCallsDisconnected: () => 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. + const calls = [...CallStore.instance.activeCalls].filter( + (call) => SdkContextClass.instance.roomViewStore.getRoomId() !== call.roomId, + ); + await Promise.all(calls.map(async (call) => await call.disconnect())); + }, []); return (
= ({ room, resizing, call, skipLobby, waitForIframeLoad={call.widget.waitForIframeLoad} showMenubar={false} pointerEvents={resizing ? "none" : undefined} + stickyPromise={allOtherCallsDisconnected} />
); diff --git a/src/models/Call.ts b/src/models/Call.ts index a2f8f3ebac3..71e3fbb30e6 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -579,10 +579,13 @@ export class JitsiCall extends Call { // Tell others that we're connected, by adding our device to room state await this.addOurDevice(); // Re-add this device every so often so our video member event doesn't become stale - this.resendDevicesTimer = window.setInterval(async (): Promise => { - logger.log(`Resending video member event for ${this.roomId}`); - await this.addOurDevice(); - }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4); + this.resendDevicesTimer = window.setInterval( + async (): Promise => { + logger.log(`Resending video member event for ${this.roomId}`); + await this.addOurDevice(); + }, + (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4, + ); } else if (state === ConnectionState.Disconnected && isConnected(prevState)) { this.updateParticipants(); // Local echo @@ -780,7 +783,11 @@ export class ElementCall extends Call { this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {}); } - private constructor(public session: MatrixRTCSession, widget: IApp, client: MatrixClient) { + private constructor( + public session: MatrixRTCSession, + widget: IApp, + client: MatrixClient, + ) { super(widget, client); this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); @@ -880,35 +887,35 @@ export class ElementCall extends Call { // or the MatrixRTCSessionManager session started event. this.connectionState = ConnectionState.Lobby; } - await new Promise((resolve) => { - const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session) { - const waitForJoin = (oldMemberships: CallMembership[], newMemberships: CallMembership[]): void => { - if (newMemberships.some((m) => m.sender === this.client.getUserId())) { - resolve(); - // This listener is not needed anymore. The promise resolved and we updated to the connection state - // when `performConnection` resolves. - session.off(MatrixRTCSessionEvent.MembershipsChanged, waitForJoin); - } - }; - session.on(MatrixRTCSessionEvent.MembershipsChanged, waitForJoin); - } else { - const waitForJoin = (roomId: string, session: MatrixRTCSession): void => { - if (this.session.callId === session.callId && roomId === this.roomId) { - resolve(); - // This listener is not needed anymore. The promise resolved and we updated to the connection state - // when `performConnection` resolves. - this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, waitForJoin); - } - }; - this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, waitForJoin); - } - }); + + const session = this.client.matrixRTC.getActiveRoomSession(this.room); + if (session) { + await waitForEvent( + session, + MatrixRTCSessionEvent.MembershipsChanged, + (_, newMemberships: CallMembership[]) => + newMemberships.some((m) => m.sender === this.client.getUserId()), + ); + } else { + await waitForEvent( + this.client.matrixRTC, + MatrixRTCSessionEvent.MembershipsChanged, + (roomId: string, session: MatrixRTCSession) => + this.session.callId === session.callId && roomId === this.roomId, + ); + } } protected async performDisconnection(): Promise { try { await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); + + await waitForEvent( + this.session, + MatrixRTCSessionEvent.MembershipsChanged, + (_, newMemberships: CallMembership[]) => + !newMemberships.some((m) => m.sender === this.client.getUserId()), + ); } catch (e) { throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 83278ec1079..ffbe959d870 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -75,6 +75,7 @@ interface IAppTileProps { waitForIframeLoad: boolean; whitelistCapabilities?: string[]; userWidget: boolean; + stickyPromise: (() => Promise) | null; } // TODO: Don't use this because it's wrong @@ -160,6 +161,7 @@ export class StopGapWidget extends EventEmitter { private kind: WidgetKind; private readonly virtual: boolean; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID + private stickyPromise: (() => Promise) | null = null; // This promise will be called and needs to resolve before the widget will actually become sticky. public constructor(private appTileProps: IAppTileProps) { super(); @@ -176,6 +178,7 @@ export class StopGapWidget extends EventEmitter { this.roomId = appTileProps.room?.roomId; this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably this.virtual = isAppWidget(app) && app.eventId === undefined; + this.stickyPromise = appTileProps.stickyPromise; } private get eventListenerRoomId(): Optional { @@ -338,15 +341,17 @@ export class StopGapWidget extends EventEmitter { this.messaging.on( `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, - (ev: CustomEvent) => { + async (ev: CustomEvent) => { if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, {}); // ack + + if (this.stickyPromise) await this.stickyPromise(); ActiveWidgetStore.instance.setWidgetPersistence( this.mockWidget.id, this.roomId ?? null, ev.detail.data.value, ); - ev.preventDefault(); - this.messaging.transport.reply(ev.detail, {}); // ack } }, ); From 85487148c96983d08d286c5975a50a5432d16d7c Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Jan 2024 20:06:30 +0100 Subject: [PATCH 24/51] linter + jitsi as videoChannel Signed-off-by: Timo K --- src/models/Call.ts | 2 +- src/utils/video-rooms.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 71e3fbb30e6..939e3c42bbc 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -350,7 +350,7 @@ export class JitsiCall extends Call { } public static async create(room: Room): Promise { - await WidgetUtils.addJitsiWidget(room.client, room.roomId, CallType.Video, "Group call", false, room.name); + await WidgetUtils.addJitsiWidget(room.client, room.roomId, CallType.Video, "Group call", true, room.name); } private updateParticipants(): void { diff --git a/src/utils/video-rooms.ts b/src/utils/video-rooms.ts index 3a5bbe2dde8..c321b26870b 100644 --- a/src/utils/video-rooms.ts +++ b/src/utils/video-rooms.ts @@ -18,4 +18,7 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../settings/SettingsStore"; export const isVideoRoom = (room: Room): boolean => - room.isElementVideoRoom() || SettingsStore.getValue("feature_video_rooms") && (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom()); + room.isElementVideoRoom() || + (SettingsStore.getValue("feature_video_rooms") && + SettingsStore.getValue("feature_element_call_video_rooms") && + room.isCallRoom()); From c91221fe462d3c1b4580606d578e7f20b19090f4 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Jan 2024 20:13:38 +0100 Subject: [PATCH 25/51] stickyPromise type Signed-off-by: Timo K --- src/components/views/elements/AppTile.tsx | 2 +- src/stores/widgets/StopGapWidget.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index dd83c5b3016..cedaf4bd6e5 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -99,7 +99,7 @@ interface IProps { // It will only become sticky once the returned promise resolves. // This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately. // This promise allows to do Widget B related cleanup before Widget A becomes sticky. - stickyPromise: (() => Promise) | null; + stickyPromise?: () => Promise; } interface IState { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index ffbe959d870..75529bfc1f5 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -75,7 +75,7 @@ interface IAppTileProps { waitForIframeLoad: boolean; whitelistCapabilities?: string[]; userWidget: boolean; - stickyPromise: (() => Promise) | null; + stickyPromise?: () => Promise; } // TODO: Don't use this because it's wrong @@ -161,7 +161,7 @@ export class StopGapWidget extends EventEmitter { private kind: WidgetKind; private readonly virtual: boolean; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID - private stickyPromise: (() => Promise) | null = null; // This promise will be called and needs to resolve before the widget will actually become sticky. + private stickyPromise?: () => Promise; // This promise will be called and needs to resolve before the widget will actually become sticky. public constructor(private appTileProps: IAppTileProps) { super(); From 5160df5d7c49d379cded129b90cccf6b96f71df4 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 17 Jan 2024 11:41:19 +0100 Subject: [PATCH 26/51] fix legacy call (jistsi, cisco, bbb) reopen when clicking call button Signed-off-by: Timo K --- src/hooks/room/useRoomCall.ts | 13 ++++--------- src/stores/WidgetStore.ts | 5 ++++- test/components/views/rooms/RoomHeader-test.tsx | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index e48c66eed62..cf3849fb6a3 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -32,7 +32,7 @@ import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutS import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; -import { IApp } from "../../stores/WidgetStore"; +import { IApp, isVirtualWidget } from "../../stores/WidgetStore"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../dispatcher/actions"; @@ -126,7 +126,8 @@ export const useRoomCall = ( const [canPinWidget, setCanPinWidget] = useState(false); const [widgetPinned, setWidgetPinned] = useState(false); - const promptPinWidget = canPinWidget && !widgetPinned; + // We only want to prompt to pin the widget if it's not virtual (not element call based) + const promptPinWidget = widget ? !isVirtualWidget(widget) : true && canPinWidget && !widgetPinned; const updateWidgetState = useCallback((): void => { setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top)); @@ -187,13 +188,7 @@ export const useRoomCall = ( (evt: React.MouseEvent): void => { evt.stopPropagation(); if (widget && promptPinWidget) { - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - view_call: true, - metricsTrigger: undefined, - skipLobby: evt.shiftKey, - }); + WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { placeCall(room, CallType.Video, callType, evt.shiftKey); } diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index fd092e7fbb4..04c1c0c8486 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -40,6 +40,9 @@ export interface IApp extends IWidget { export function isAppWidget(widget: IWidget | IApp): widget is IApp { return "roomId" in widget && typeof widget.roomId === "string"; } +export function isVirtualWidget(widget: IApp): boolean { + return widget.eventId === undefined; +} interface IRoomWidgets { widgets: IApp[]; @@ -127,7 +130,7 @@ export default class WidgetStore extends AsyncStoreWithClient { // otherwise we are out of sync with the rest of the app with stale widget events during removal Array.from(this.widgetMap.values()).forEach((app) => { if (app.roomId !== room.roomId) return; // skip - wrong room - if (app.eventId === undefined) { + if (isVirtualWidget(app)) { // virtual widget - keep it roomInfo.widgets.push(app); } else { diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index e2a54ae77c4..0fdcc1b63f9 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -331,12 +331,12 @@ describe("RoomHeader", () => { it("clicking on ongoing (unpinned) call re-pins it", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); - // allow element calls + // allow calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); - const widget = {}; + const widget = { eventId: "some_id_so_it_is_interpreted_as_non_virtual_widget" }; jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call); const { container } = render(, getWrapper()); From 9f685614e84a83621151a1b9ece2cf1802a50f37 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 17 Jan 2024 12:44:37 +0100 Subject: [PATCH 27/51] fix tests and connect resolves Signed-off-by: Timo K --- src/models/Call.ts | 2 +- .../views/rooms/LegacyRoomHeader-test.tsx | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 939e3c42bbc..6e777abaa59 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -899,7 +899,7 @@ export class ElementCall extends Call { } else { await waitForEvent( this.client.matrixRTC, - MatrixRTCSessionEvent.MembershipsChanged, + MatrixRTCSessionManagerEvents.SessionStarted, (roomId: string, session: MatrixRTCSession) => this.session.callId === session.callId && roomId === this.roomId, ); diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx index 5e4b326cafb..62a1f696817 100644 --- a/test/components/views/rooms/LegacyRoomHeader-test.tsx +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -31,6 +31,10 @@ import EventEmitter from "events"; import { setupJestCanvasMock } from "jest-canvas-mock"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { TooltipProvider } from "@vector-im/compound-web"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; @@ -59,11 +63,11 @@ import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import WidgetStore from "../../../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; -import WidgetUtils from "../../../../src/utils/WidgetUtils"; -import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../src/settings/UIFeature"; +import WidgetUtils from "../../../../src/utils/WidgetUtils"; +import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -568,6 +572,15 @@ describe("LegacyRoomHeader", () => { // We set the call to skip lobby because otherwise the connection will wait until // the user clicks the "join" button, inside the widget lobby which is hard to mock. call.widget.data = { ...call.widget.data, skipLobby: true }; + // The connect method will wait until the session actually connected. Otherwise it will timeout. + // Emitting SessionStarted will trigger the connect method to resolve. + setTimeout( + () => + client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, { + room, + } as MatrixRTCSession), + 100, + ); await call.connect(); const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!; From baf76690f982d259f4df09915775514ad7db66d3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 17 Jan 2024 13:17:54 +0100 Subject: [PATCH 28/51] fix "waits for messaging when connecting" test Signed-off-by: Timo K --- src/models/Call.ts | 3 ++- test/models/Call-test.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 6e777abaa59..24c29329860 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -548,7 +548,8 @@ export class JitsiCall extends Call { } public setDisconnected(): void { - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + // During tests this.messaging can be undefined + this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 02279917c95..0280bf209c8 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -282,9 +282,16 @@ describe("JitsiCall", () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); const connect = call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connecting); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); + setTimeout( + () => + client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, { + room, + } as MatrixRTCSession), + 100, + ); await connect; expect(call.connectionState).toBe(ConnectionState.Connected); }); From 201e155e54bf80e1b11be8a066c497447c9fd87e Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 17 Jan 2024 19:19:18 +0100 Subject: [PATCH 29/51] Allow to skip awaiting Call session events. This option is used in tests to spare mocking the events emitted when EC updates the room state Signed-off-by: Timo K --- src/models/Call.ts | 40 +++++++++--------- test/models/Call-test.ts | 91 ++++++++++++++++++---------------------- 2 files changed, 61 insertions(+), 70 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 24c29329860..9a30f2d835e 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -205,19 +205,20 @@ export abstract class Call extends TypedEventEmitter; /** * Contacts the widget to disconnect from the call. */ - protected abstract performDisconnection(): Promise; + protected abstract performDisconnection(skipSessionAwait?: boolean): Promise; /** * Connects the user to the call using the media devices set in * MediaDeviceHandler. The widget associated with the call must be active * for this to succeed. */ - public async connect(): Promise { + public async connect(skipSessionAwait = false): Promise { this.connectionState = ConnectionState.WidgetLoading; const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = @@ -256,7 +257,7 @@ export abstract class Call extends TypedEventEmitter { + public async disconnect(skipSessionAwait = false): Promise { if (!this.connected) throw new Error("Not connected"); this.connectionState = ConnectionState.Disconnecting; - await this.performDisconnection(); + await this.performDisconnection(skipSessionAwait); this.setDisconnected(); } @@ -467,6 +468,7 @@ export class JitsiCall extends Call { protected async performConnection( audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, + skipSessionAwait = false, ): Promise { this.connectionState = ConnectionState.Lobby; // Ensure that the messaging doesn't get stopped while we're waiting for responses @@ -529,7 +531,7 @@ export class JitsiCall extends Call { ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); } - protected async performDisconnection(): Promise { + protected async performDisconnection(skipSessionAwait = false): Promise { const response = waitForEvent( this.messaging!, `action:${ElementWidgetActions.HangupCall}`, @@ -864,6 +866,7 @@ export class ElementCall extends Call { protected async performConnection( audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, + skipSessionAwait = false, ): Promise { // the JoinCall action is only send if the widget is waiting for it. if ((this.widget.data ?? {}).preload) { @@ -880,9 +883,7 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - if ((this.widget.data ?? {}).skipLobby) { - this.connectionState = ConnectionState.Connecting; - } else { + 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. @@ -890,14 +891,14 @@ export class ElementCall extends Call { } const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session) { + if (session && !skipSessionAwait) { await waitForEvent( session, MatrixRTCSessionEvent.MembershipsChanged, (_, newMemberships: CallMembership[]) => newMemberships.some((m) => m.sender === this.client.getUserId()), ); - } else { + } else if (!skipSessionAwait) { await waitForEvent( this.client.matrixRTC, MatrixRTCSessionManagerEvents.SessionStarted, @@ -907,16 +908,17 @@ export class ElementCall extends Call { } } - protected async performDisconnection(): Promise { + protected async performDisconnection(skipSessionAwait = false): Promise { try { await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); - - await waitForEvent( - this.session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - !newMemberships.some((m) => m.sender === this.client.getUserId()), - ); + if (!skipSessionAwait) { + await waitForEvent( + this.session, + MatrixRTCSessionEvent.MembershipsChanged, + (_, newMemberships: CallMembership[]) => + !newMemberships.some((m) => m.sender === this.client.getUserId()), + ); + } } catch (e) { throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 0280bf209c8..ee4f3e8026d 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -285,13 +285,6 @@ describe("JitsiCall", () => { expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - setTimeout( - () => - client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, { - room, - } as MatrixRTCSession), - 100, - ); await connect; expect(call.connectionState).toBe(ConnectionState.Connected); }); @@ -313,34 +306,25 @@ describe("JitsiCall", () => { await call.connect(); expect(call.connectionState).toBe(ConnectionState.Connected); + const callback = jest.fn(); + + call.on(CallEvent.ConnectionState, callback); + messaging.emit( `action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", { detail: {} }), ); - await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 }); - }); - - it("handles instant remote disconnection when connecting", async () => { - mocked(messaging.transport).send.mockImplementation(async (action): Promise => { - if (action === ElementWidgetActions.JoinCall) { - // Emit the hangup event *before* the join event to fully - // exercise the race condition - messaging.emit( - `action:${ElementWidgetActions.HangupCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), + await waitFor(() => { + expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected), + expect(callback).toHaveBeenNthCalledWith( + 2, + ConnectionState.WidgetLoading, + ConnectionState.Disconnected, ); - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - } - return {}; + expect(callback).toHaveBeenNthCalledWith(3, ConnectionState.Connecting, ConnectionState.WidgetLoading); }); - expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - // Should disconnect on its own almost instantly - await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 }); + // in video rooms we expect the call to immediately reconnect + call.off(CallEvent.ConnectionState, callback); }); it("disconnects", async () => { @@ -459,8 +443,10 @@ describe("JitsiCall", () => { await call.connect(); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ - [ConnectionState.Connecting, ConnectionState.Disconnected], - [ConnectionState.Connected, ConnectionState.Connecting], + [ConnectionState.WidgetLoading, ConnectionState.Disconnected], + [ConnectionState.Connecting, ConnectionState.WidgetLoading], + [ConnectionState.Lobby, ConnectionState.Connecting], + [ConnectionState.Connected, ConnectionState.Lobby], [ConnectionState.Disconnecting, ConnectionState.Connected], [ConnectionState.Disconnected, ConnectionState.Disconnecting], ]); @@ -737,7 +723,7 @@ describe("ElementCall", () => { jest.useFakeTimers(); jest.setSystemTime(0); - await ElementCall.create(room); + await ElementCall.create(room, true); const maybeCall = ElementCall.get(room); if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; @@ -750,11 +736,12 @@ describe("ElementCall", () => { it("waits for messaging when connecting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connecting); + const connect = call.connect(true); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await connect; @@ -769,7 +756,7 @@ describe("ElementCall", () => { }); it("fails to disconnect if the widget returns an error", async () => { - await call.connect(); + await call.connect(true); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -777,7 +764,7 @@ describe("ElementCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await call.connect(true); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit( @@ -789,35 +776,35 @@ describe("ElementCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await call.connect(true); expect(call.connectionState).toBe(ConnectionState.Connected); - await call.disconnect(); + await call.disconnect(true); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.connect(); + await call.connect(true); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "leave"); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await call.connect(); + await call.connect(true); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "join"); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("disconnects if the widget dies", async () => { - await call.connect(); + await call.connect(true); expect(call.connectionState).toBe(ConnectionState.Connected); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("tracks layout", async () => { - await call.connect(); + await call.connect(true); expect(call.layout).toBe(Layout.Tile); messaging.emit( @@ -834,7 +821,7 @@ describe("ElementCall", () => { }); it("sets layout", async () => { - await call.connect(); + await call.connect(true); await call.setLayout(Layout.Spotlight); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); @@ -844,13 +831,15 @@ describe("ElementCall", () => { }); it("emits events when connection state changes", async () => { + // const wait = jest.spyOn(CallModule, "waitForEvent"); const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await call.connect(); - await call.disconnect(); + await call.connect(true); + await call.disconnect(true); expect(onConnectionState.mock.calls).toEqual([ - [ConnectionState.Connecting, ConnectionState.Disconnected], + [ConnectionState.WidgetLoading, ConnectionState.Disconnected], + [ConnectionState.Connecting, ConnectionState.WidgetLoading], [ConnectionState.Connected, ConnectionState.Connecting], [ConnectionState.Disconnecting, ConnectionState.Connected], [ConnectionState.Disconnected, ConnectionState.Disconnecting], @@ -871,7 +860,7 @@ describe("ElementCall", () => { }); it("emits events when layout changes", async () => { - await call.connect(); + await call.connect(true); const onLayout = jest.fn(); call.on(CallEvent.Layout, onLayout); @@ -889,10 +878,10 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await call.connect(); + await call.connect(true); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(); + await call.disconnect(true); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -954,10 +943,10 @@ describe("ElementCall", () => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); it("doesn't end the call when the last participant leaves", async () => { - await call.connect(); + await call.connect(true); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(); + await call.disconnect(true); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); From 0532c4269b815f649e39a71b315f0341222a9704 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 18 Jan 2024 17:45:32 +0100 Subject: [PATCH 30/51] add sticky test Signed-off-by: Timo K --- test/stores/widgets/StopGapWidget-test.ts | 68 ++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts index fce2b05a754..902e9f2a6f0 100644 --- a/test/stores/widgets/StopGapWidget-test.ts +++ b/test/stores/widgets/StopGapWidget-test.ts @@ -17,7 +17,7 @@ limitations under the License. import { mocked, MockedObject } from "jest-mock"; import { last } from "lodash"; import { MatrixEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix"; -import { ClientWidgetApi } from "matrix-widget-api"; +import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api"; import { stubClient, mkRoom, mkEvent } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -25,6 +25,7 @@ import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget"; import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions"; import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../src/voice-broadcast"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; +import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore"; jest.mock("matrix-widget-api/lib/ClientWidgetApi"); @@ -114,3 +115,68 @@ describe("StopGapWidget", () => { }); }); }); +describe("StopGapWidget with stickyPromise", () => { + let client: MockedObject; + let widget: StopGapWidget; + let messaging: MockedObject; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + }); + + afterEach(() => { + widget.stopMessaging(); + }); + it("should wait for the sticky promise to resolve before starting messaging", async () => { + jest.useFakeTimers(); + const getStickyPromise = async () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }; + widget = new StopGapWidget({ + app: { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url", + roomId: "!1:example.org", + }, + room: mkRoom(client, "!1:example.org"), + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + stickyPromise: getStickyPromise, + }); + + const setPersistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence"); + + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.startMessaging(null as unknown as HTMLIFrameElement); + const emitSticky = async () => { + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + messaging?.hasCapability.mockReturnValue(true); + // messaging.transport.reply will be called but transport is undefined in this test environment + // This just makes sure the call doesn't throw + Object.defineProperty(messaging, "transport", { value: { reply: () => {} } }); + messaging.on.mock.calls.find(([event, listener]) => { + if (event === `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`) { + listener({ preventDefault: () => {}, detail: { data: { value: true } } }); + return true; + } + }); + }; + emitSticky(); + expect(setPersistenceSpy).not.toHaveBeenCalled(); + // Advance the fake timer so that the sticky promise resolves + jest.runAllTimers(); + // Use a real timer and wait for the next tick so the sticky promise can resolve + jest.useRealTimers(); + await new Promise(process.nextTick); + expect(setPersistenceSpy).toHaveBeenCalled(); + }); +}); From 90a12d86d849db04bfb371dbfbaaa46570176bd2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 19 Jan 2024 12:19:17 +0100 Subject: [PATCH 31/51] add test for looby tile rendering Signed-off-by: Timo K --- test/components/views/rooms/RoomTile-test.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index a834af9df91..53823aa0dbe 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -56,6 +56,7 @@ import { UIComponent } from "../../../../src/settings/UIFeature"; import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import { ConnectionState } from "../../../../src/models/Call"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -235,18 +236,35 @@ describe("RoomTile", () => { renderRoomTile(); screen.getByText("Video"); + let completeWidgetLoading: () => void = () => {}; + const widgetLoadingCompleted = new Promise((resolve) => (completeWidgetLoading = resolve)); + // Insert an await point in the connection method so we can inspect // the intermediate connecting state let completeConnection: () => void = () => {}; const connectionCompleted = new Promise((resolve) => (completeConnection = resolve)); - jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted); + + let completeLobby: () => void = () => {}; + const lobbyCompleted = new Promise((resolve) => (completeLobby = resolve)); + + jest.spyOn(call, "performConnection").mockImplementation(async () => { + call.setConnectionState(ConnectionState.WidgetLoading); + await widgetLoadingCompleted; + call.setConnectionState(ConnectionState.Lobby); + await lobbyCompleted; + call.setConnectionState(ConnectionState.Connecting); + await connectionCompleted; + }); await Promise.all([ (async () => { + await screen.findByText("Loading…"); + completeWidgetLoading(); + await screen.findByText("Lobby"); + completeLobby(); await screen.findByText("Joining…"); - const joinedFound = screen.findByText("Joined"); completeConnection(); - await joinedFound; + await screen.findByText("Joined"); })(), call.connect(), ]); From dab8e1f3a6258855072238e9b696f79637331781 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 19 Jan 2024 14:21:31 +0100 Subject: [PATCH 32/51] fix flaky test Signed-off-by: Timo K --- test/stores/widgets/StopGapWidget-test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts index 902e9f2a6f0..59ea954b9cf 100644 --- a/test/stores/widgets/StopGapWidget-test.ts +++ b/test/stores/widgets/StopGapWidget-test.ts @@ -18,6 +18,7 @@ import { mocked, MockedObject } from "jest-mock"; import { last } from "lodash"; import { MatrixEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api"; +import { waitFor } from "@testing-library/react"; import { stubClient, mkRoom, mkEvent } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -170,13 +171,13 @@ describe("StopGapWidget with stickyPromise", () => { } }); }; - emitSticky(); + await emitSticky(); expect(setPersistenceSpy).not.toHaveBeenCalled(); // Advance the fake timer so that the sticky promise resolves jest.runAllTimers(); // Use a real timer and wait for the next tick so the sticky promise can resolve jest.useRealTimers(); - await new Promise(process.nextTick); - expect(setPersistenceSpy).toHaveBeenCalled(); + + waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); }); }); From 9bb4989bc5f02396eb6c074666e89ec3910e7595 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 19 Jan 2024 15:07:04 +0100 Subject: [PATCH 33/51] add reconnect after disconnect test (video room) Signed-off-by: Timo K --- test/models/Call-test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index ee4f3e8026d..d95ab9ca2f6 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -342,6 +342,14 @@ describe("JitsiCall", () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); }); + it("reconnects after disconnect in video rooms", async () => { + expect(call.connectionState).toBe(ConnectionState.Disconnected); + await call.connect(); + expect(call.connectionState).toBe(ConnectionState.Connected); + await call.disconnect(); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + }); + it("remains connected if we stay in the room", async () => { await call.connect(); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -923,6 +931,7 @@ describe("ElementCall", () => { describe("instance in a video room", () => { let call: ElementCall; let widget: Widget; + let messaging: Mocked; let audioMutedSpy: jest.SpyInstance; let videoMutedSpy: jest.SpyInstance; @@ -937,7 +946,7 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); }); afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); @@ -950,6 +959,19 @@ describe("ElementCall", () => { expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); + it("handles remote disconnection and reconnect right after", async () => { + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + await call.connect(true); + expect(call.connectionState).toBe(ConnectionState.Connected); + + messaging.emit( + `action:${ElementWidgetActions.HangupCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + // We want the call to be connecting after the hangup. + waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connecting), { interval: 5 }); + }); }); describe("create call", () => { function setRoomMembers(memberIds: string[]) { From 64bc2193296e02493417844bb3bad87882c48418 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 22 Jan 2024 13:14:25 +0100 Subject: [PATCH 34/51] add shift click test to call toast Signed-off-by: Timo K --- src/models/Call.ts | 4 ++-- test/toasts/IncomingCallToast-test.tsx | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 9a30f2d835e..03247650358 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -869,7 +869,7 @@ export class ElementCall extends Call { skipSessionAwait = false, ): Promise { // the JoinCall action is only send if the widget is waiting for it. - if ((this.widget.data ?? {}).preload) { + if (this.widget.data?.preload) { try { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { audioInput: audioInput?.label ?? null, @@ -883,7 +883,7 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - if (!(this.widget.data ?? {}).skipLobby) { + 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. diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 1ebec578b8c..d24ddae2495 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -179,6 +179,29 @@ describe("IncomingCallEvent", () => { defaultDispatcher.unregister(dispatcherRef); }); + it("skips lobby when using shift key click", async () => { + renderToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + fireEvent.click(screen.getByRole("button", { name: "Join" }), { shiftKey: true }); + await waitFor(() => + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + skipLobby: true, + view_call: true, + }), + ); + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), + ); + + defaultDispatcher.unregister(dispatcherRef); + }); it("closes the toast", async () => { renderToast(); From e20ea5efec8c2ae1bd1c14f19247aeb778e6a43c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 22 Jan 2024 14:02:45 +0100 Subject: [PATCH 35/51] test for allowVoipWithNoMedia in widget url Signed-off-by: Timo K --- test/models/Call-test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index d95ab9ca2f6..c5840531988 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -718,6 +718,17 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("analyticsID")).toBe(""); }); + + it("passes feature_allow_screen_share_only_mode setting to allowVoipWithNoMedia url param", async () => { + enabledSettings.add("feature_allow_screen_share_only_mode"); + await ElementCall.create(room); + enabledSettings.delete("feature_allow_screen_share_only_mode"); + const call = Call.get(room); + if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); + + const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); + expect(urlParams.get("allowVoipWithNoMedia")).toBe("true"); + }); }); describe("instance in a non-video room", () => { From 8b2df91494d56c0cc354926c6f9a099d4a85a3a0 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 22 Jan 2024 14:29:01 +0100 Subject: [PATCH 36/51] fix e2e tests to search for the right element Signed-off-by: Timo K --- playwright/e2e/room/room-header.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index ab9ed9ff7e3..4fcd9e376cd 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -179,7 +179,7 @@ test.describe("Room Header", () => { await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click(); // Assert that the video is rendered - await expect(page.locator(".mx_CallView video")).toBeVisible(); + await expect(page.locator(".mx_CallView")).toBeVisible(); // Assert that GELS is visible await expect( From 1c6a11b643373ef76335406e6cf84a236a5f2c7c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 22 Jan 2024 14:46:54 +0100 Subject: [PATCH 37/51] destroy call after test so next test does not fail Signed-off-by: Timo K --- test/models/Call-test.ts | 48 +++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index c5840531988..42e22fac75c 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -702,32 +702,43 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("analyticsID")).toBe(""); + call.destroy(); }); - it("passes empty analyticsID if the id is not in the account data", async () => { - client.getAccountData.mockImplementation((eventType: string) => { - if (eventType === PosthogAnalytics.ANALYTICS_EVENT_TYPE) { - return new MatrixEvent({ content: {} }); + it("passes feature_allow_screen_share_only_mode setting to allowVoipWithNoMedia url param", async () => { + // Now test with the preference set to true + const originalGetValue = SettingsStore.getValue; + SettingsStore.getValue = (name: string, roomId?: string, excludeDefault?: boolean) => { + switch (name) { + case "feature_allow_screen_share_only_mode": + return true as T; + default: + return originalGetValue(name, roomId, excludeDefault); } - return undefined; - }); + }; await ElementCall.create(room); const call = Call.get(room); if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); - expect(urlParams.get("analyticsID")).toBe(""); + expect(urlParams.get("allowVoipWithNoMedia")).toBe("true"); + SettingsStore.getValue = originalGetValue; + call.destroy(); }); - it("passes feature_allow_screen_share_only_mode setting to allowVoipWithNoMedia url param", async () => { - enabledSettings.add("feature_allow_screen_share_only_mode"); + it("passes empty analyticsID if the id is not in the account data", async () => { + client.getAccountData.mockImplementation((eventType: string) => { + if (eventType === PosthogAnalytics.ANALYTICS_EVENT_TYPE) { + return new MatrixEvent({ content: {} }); + } + return undefined; + }); await ElementCall.create(room); - enabledSettings.delete("feature_allow_screen_share_only_mode"); const call = Call.get(room); if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); - expect(urlParams.get("allowVoipWithNoMedia")).toBe("true"); + expect(urlParams.get("analyticsID")).toBe(""); }); }); @@ -767,6 +778,21 @@ describe("ElementCall", () => { expect(call.connectionState).toBe(ConnectionState.Connected); }); + it("doesn't stop messaging when connecting", async () => { + // Temporarily remove the messaging to simulate connecting while the + // widget is still initializing + + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + const connect = call.connect(true); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); + + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); + await connect; + expect(call.connectionState).toBe(ConnectionState.Connected); + }); + it("fails to connect if the widget returns an error", async () => { // we only send a JoinCall action if the widget is preloading call.widget.data = { ...call.widget, preload: true }; From b29827148e324f2a2229d30aeb458d6f1f4d373f Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 22 Jan 2024 22:32:09 +0100 Subject: [PATCH 38/51] new call test (connection failed) Signed-off-by: Timo K --- test/models/Call-test.ts | 56 +++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 42e22fac75c..ed184269680 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -289,6 +289,47 @@ describe("JitsiCall", () => { expect(call.connectionState).toBe(ConnectionState.Connected); }); + it("doesn't stop messaging when connecting", async () => { + // Temporarily remove the messaging to simulate connecting while the + // widget is still initializing + const oldSendMock = messaging.transport.send; + jest.useFakeTimers(); + mocked(messaging.transport).send.mockImplementation(async (action: string): Promise => { + if (action === ElementWidgetActions.JoinCall) { + await new Promise((resolve) => setTimeout(resolve, 100)); + messaging.emit( + `action:${ElementWidgetActions.JoinCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + } + }); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + const connect = call.connect(true); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(1000); + } + async function runStopMessaging() { + await new Promise((resolve) => setTimeout(resolve, 1000)); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + } + runStopMessaging(); + runTimers(); + let connectError; + try { + await connect; + } catch (e) { + console.log(e); + connectError = e; + } + expect(connectError).toBeDefined(); + // const connect2 = await connect; + // expect(connect2).toThrow(); + messaging.transport.send = oldSendMock; + }, 10000); + it("fails to connect if the widget returns an error", async () => { mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.connect()).rejects.toBeDefined(); @@ -778,21 +819,6 @@ describe("ElementCall", () => { expect(call.connectionState).toBe(ConnectionState.Connected); }); - it("doesn't stop messaging when connecting", async () => { - // Temporarily remove the messaging to simulate connecting while the - // widget is still initializing - - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - expect(call.connectionState).toBe(ConnectionState.Disconnected); - - const connect = call.connect(true); - expect(call.connectionState).toBe(ConnectionState.WidgetLoading); - - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; - expect(call.connectionState).toBe(ConnectionState.Connected); - }); - it("fails to connect if the widget returns an error", async () => { // we only send a JoinCall action if the widget is preloading call.widget.data = { ...call.widget, preload: true }; From 820e330ffff2b76588676840f43abb4e39416e62 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 11:57:41 +0100 Subject: [PATCH 39/51] reset to real timers Signed-off-by: Timo K --- test/models/Call-test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index ed184269680..8cae69dced9 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -292,8 +292,8 @@ describe("JitsiCall", () => { it("doesn't stop messaging when connecting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing - const oldSendMock = messaging.transport.send; jest.useFakeTimers(); + const oldSendMock = messaging.transport.send; mocked(messaging.transport).send.mockImplementation(async (action: string): Promise => { if (action === ElementWidgetActions.JoinCall) { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -328,6 +328,7 @@ describe("JitsiCall", () => { // const connect2 = await connect; // expect(connect2).toThrow(); messaging.transport.send = oldSendMock; + jest.useRealTimers(); }, 10000); it("fails to connect if the widget returns an error", async () => { From 59ddba7868b71d1d76473139f04501a14a099c26 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 13:20:21 +0100 Subject: [PATCH 40/51] dont use skipSessionAwait for tests Signed-off-by: Timo K --- src/models/Call.ts | 11 ++--- test/models/Call-test.ts | 100 ++++++++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 03247650358..5f5cc8acfce 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -205,7 +205,6 @@ export abstract class Call extends TypedEventEmitter; /** @@ -218,7 +217,7 @@ export abstract class Call extends TypedEventEmitter { + public async connect(): Promise { this.connectionState = ConnectionState.WidgetLoading; const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = @@ -257,7 +256,7 @@ export abstract class Call extends TypedEventEmitter { this.connectionState = ConnectionState.Lobby; // Ensure that the messaging doesn't get stopped while we're waiting for responses @@ -866,7 +864,6 @@ export class ElementCall extends Call { protected async performConnection( audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, - skipSessionAwait = false, ): Promise { // the JoinCall action is only send if the widget is waiting for it. if (this.widget.data?.preload) { @@ -891,14 +888,14 @@ export class ElementCall extends Call { } const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session && !skipSessionAwait) { + if (session) { await waitForEvent( session, MatrixRTCSessionEvent.MembershipsChanged, (_, newMemberships: CallMembership[]) => newMemberships.some((m) => m.sender === this.client.getUserId()), ); - } else if (!skipSessionAwait) { + } else { await waitForEvent( this.client.matrixRTC, MatrixRTCSessionManagerEvents.SessionStarted, diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 8cae69dced9..a27f5ee1509 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -305,7 +305,7 @@ describe("JitsiCall", () => { }); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(true); + const connect = call.connect(); expect(call.connectionState).toBe(ConnectionState.WidgetLoading); async function runTimers() { jest.advanceTimersByTime(500); @@ -329,7 +329,7 @@ describe("JitsiCall", () => { // expect(connect2).toThrow(); messaging.transport.send = oldSendMock; jest.useRealTimers(); - }, 10000); + }); it("fails to connect if the widget returns an error", async () => { mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); @@ -626,11 +626,40 @@ describe("ElementCall", () => { let room: Room; let alice: RoomMember; + const callConnectProcedure: (call: ElementCall) => Promise = async (call) => { + async function sessionConnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { + sessionId: undefined, + } as unknown as MatrixRTCSession); + call.session?.emit( + MatrixRTCSessionEvent.MembershipsChanged, + [], + [{ sender: client.getUserId() } as CallMembership], + ); + } + async function runTimers() { + jest.advanceTimersByTime(300); + jest.advanceTimersByTime(300); + jest.advanceTimersByTime(1000); + } + sessionConnect(); + const promise = call.connect(); + runTimers(); + await promise; + }; + beforeEach(() => { + jest.useFakeTimers(); ({ client, room, alice } = setUpClientRoomAndStores()); }); - afterEach(() => cleanUpClientRoomAndStores(client, room)); + afterEach(() => { + jest.useRealTimers(); + cleanUpClientRoomAndStores(client, room); + }); describe("get", () => { it("finds no calls", () => { @@ -812,7 +841,8 @@ describe("ElementCall", () => { WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(true); + const connect = callConnectProcedure(call); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); @@ -828,7 +858,7 @@ describe("ElementCall", () => { }); it("fails to disconnect if the widget returns an error", async () => { - await call.connect(true); + await callConnectProcedure(call); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -836,7 +866,7 @@ describe("ElementCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit( @@ -848,35 +878,35 @@ describe("ElementCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(true); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "leave"); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "join"); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("disconnects if the widget dies", async () => { - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("tracks layout", async () => { - await call.connect(true); + await callConnectProcedure(call); expect(call.layout).toBe(Layout.Tile); messaging.emit( @@ -893,7 +923,7 @@ describe("ElementCall", () => { }); it("sets layout", async () => { - await call.connect(true); + await callConnectProcedure(call); await call.setLayout(Layout.Spotlight); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); @@ -907,7 +937,7 @@ describe("ElementCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await call.connect(true); + await callConnectProcedure(call); await call.disconnect(true); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.WidgetLoading, ConnectionState.Disconnected], @@ -932,7 +962,7 @@ describe("ElementCall", () => { }); it("emits events when layout changes", async () => { - await call.connect(true); + await callConnectProcedure(call); const onLayout = jest.fn(); call.on(CallEvent.Layout, onLayout); @@ -950,7 +980,7 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await call.connect(true); + await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); await call.disconnect(true); @@ -1016,17 +1046,51 @@ describe("ElementCall", () => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); it("doesn't end the call when the last participant leaves", async () => { - await call.connect(true); + await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); await call.disconnect(true); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); + + it("connect to call with ongoing session", async () => { + // Mock membership getter used by `roomSessionForRoom`. + // This makes sure the roomSession will not be empty. + jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [ + { fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership, + ]); + // Create ongoing session + const roomSession = MatrixRTCSession.roomSessionForRoom(client, room); + const roomSessionEmitSpy = jest.spyOn(roomSession, "emit"); + + // Make sure the created session ends up in the call. + // `getActiveRoomSession` will be used during `call.connect` + // `getRoomSession` will be used during `Call.get` + client.matrixRTC.getActiveRoomSession.mockImplementation(() => { + return roomSession; + }); + client.matrixRTC.getRoomSession.mockImplementation(() => { + return roomSession; + }); + + await ElementCall.create(room); + const call = Call.get(room); + if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); + expect(call.session).toBe(roomSession); + await callConnectProcedure(call); + expect(roomSessionEmitSpy).toHaveBeenCalledWith( + "memberships_changed", + [], + [{ sender: "@alice:example.org" }], + ); + expect(call.connectionState).toBe(ConnectionState.Connected); + call.destroy(); + }); + it("handles remote disconnection and reconnect right after", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - - await call.connect(true); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit( From 967930a8ba8c4a461b7a40b83d09e7b9ffe8a312 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 13:42:08 +0100 Subject: [PATCH 41/51] code quality (sonar) Signed-off-by: Timo K --- src/hooks/room/useRoomCall.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index cf3849fb6a3..e7b1aeb9204 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -127,7 +127,8 @@ export const useRoomCall = ( const [canPinWidget, setCanPinWidget] = useState(false); const [widgetPinned, setWidgetPinned] = useState(false); // We only want to prompt to pin the widget if it's not virtual (not element call based) - const promptPinWidget = widget ? !isVirtualWidget(widget) : true && canPinWidget && !widgetPinned; + const isECWidget = widget ? isVirtualWidget(widget) : false; + const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned; const updateWidgetState = useCallback((): void => { setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top)); From 33cf605ff98bb518424f68bace367db174db1413 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 13:50:43 +0100 Subject: [PATCH 42/51] refactor call.disconnect tests (dont use skipSessionAwait) Signed-off-by: Timo K --- src/models/Call.ts | 24 +++++++++++------------- test/models/Call-test.ts | 32 +++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 5f5cc8acfce..5a839254b04 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -210,7 +210,7 @@ export abstract class Call extends TypedEventEmitter; + protected abstract performDisconnection(): Promise; /** * Connects the user to the call using the media devices set in @@ -271,11 +271,11 @@ export abstract class Call extends TypedEventEmitter { + public async disconnect(): Promise { if (!this.connected) throw new Error("Not connected"); this.connectionState = ConnectionState.Disconnecting; - await this.performDisconnection(skipSessionAwait); + await this.performDisconnection(); this.setDisconnected(); } @@ -529,7 +529,7 @@ export class JitsiCall extends Call { ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); } - protected async performDisconnection(skipSessionAwait = false): Promise { + protected async performDisconnection(): Promise { const response = waitForEvent( this.messaging!, `action:${ElementWidgetActions.HangupCall}`, @@ -905,17 +905,15 @@ export class ElementCall extends Call { } } - protected async performDisconnection(skipSessionAwait = false): Promise { + protected async performDisconnection(): Promise { try { await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); - if (!skipSessionAwait) { - await waitForEvent( - this.session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - !newMemberships.some((m) => m.sender === this.client.getUserId()), - ); - } + await waitForEvent( + this.session, + MatrixRTCSessionEvent.MembershipsChanged, + (_, newMemberships: CallMembership[]) => + !newMemberships.some((m) => m.sender === this.client.getUserId()), + ); } catch (e) { throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index a27f5ee1509..730e14b9281 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -641,15 +641,33 @@ describe("ElementCall", () => { ); } async function runTimers() { - jest.advanceTimersByTime(300); - jest.advanceTimersByTime(300); - jest.advanceTimersByTime(1000); + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); } sessionConnect(); const promise = call.connect(); runTimers(); await promise; }; + const callDisconnectionProcedure: (call: ElementCall) => Promise = async (call) => { + async function sessionDisconnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { + sessionId: undefined, + } as unknown as MatrixRTCSession); + call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []); + } + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); + } + sessionDisconnect(); + const promise = call.disconnect(); + runTimers(); + await promise; + }; beforeEach(() => { jest.useFakeTimers(); @@ -880,7 +898,7 @@ describe("ElementCall", () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); - await call.disconnect(true); + await callDisconnectionProcedure(call); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); @@ -938,7 +956,7 @@ describe("ElementCall", () => { call.on(CallEvent.ConnectionState, onConnectionState); await callConnectProcedure(call); - await call.disconnect(true); + await callDisconnectionProcedure(call); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.WidgetLoading, ConnectionState.Disconnected], [ConnectionState.Connecting, ConnectionState.WidgetLoading], @@ -983,7 +1001,7 @@ describe("ElementCall", () => { await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(true); + await callDisconnectionProcedure(call); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -1049,7 +1067,7 @@ describe("ElementCall", () => { await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(true); + await callDisconnectionProcedure(call); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); From 843079fb57620cde4549f48fc05ccc8cf38560ea Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 14:05:45 +0100 Subject: [PATCH 43/51] miscellaneous cleanup Signed-off-by: Timo K --- src/components/views/elements/AppTile.tsx | 2 +- src/components/views/messages/CallEvent.tsx | 6 ++++-- src/components/views/voip/CallView.tsx | 1 - src/hooks/room/useRoomCall.ts | 11 +---------- src/models/Call.ts | 4 ++-- src/stores/CallStore.ts | 7 +++---- test/models/Call-test.ts | 3 ++- 7 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index cedaf4bd6e5..74317041bd2 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -98,7 +98,7 @@ interface IProps { // If defined this async method will be called when the widget requests to become sticky. // It will only become sticky once the returned promise resolves. // This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately. - // This promise allows to do Widget B related cleanup before Widget A becomes sticky. + // This promise allows to do Widget B related cleanup before Widget A becomes sticky. (e.g. hangup a Voip call) stickyPromise?: () => Promise; } diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index f62fa2d65ed..e37217c4224 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -135,9 +135,11 @@ 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.Connecting || ConnectionState.Lobby || ConnectionState.WidgetLoading - return [_t("action|join"), "primary", null]; }, [connectionState, connect, disconnect]); return ( diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 586f9f11968..d91ec3cd47d 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -104,5 +104,4 @@ export const CallView: FC = ({ room, resizing, waitForCall, skipL } else { return ; } - // } }; diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index e7b1aeb9204..613ef98afcc 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -33,9 +33,6 @@ import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; import { IApp, isVirtualWidget } from "../../stores/WidgetStore"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../../dispatcher/actions"; export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; @@ -172,13 +169,7 @@ export const useRoomCall = ( (evt: React.MouseEvent): void => { evt.stopPropagation(); if (widget && promptPinWidget) { - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - view_call: true, - metricsTrigger: undefined, - skipLobby: evt.shiftKey, - }); + WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { placeCall(room, CallType.Voice, callType, evt.shiftKey); } diff --git a/src/models/Call.ts b/src/models/Call.ts index 5a839254b04..078ff302c22 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -865,7 +865,7 @@ export class ElementCall extends Call { audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { - // the JoinCall action is only send if the widget is waiting for it. + // The JoinCall action is only send if the widget is waiting for it. if (this.widget.data?.preload) { try { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { @@ -944,7 +944,7 @@ export class ElementCall extends Call { } private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => { - // Don't destroy call on hangup for video call rooms. + // Don't destroy the call on hangup for video call rooms. if (roomId == this.roomId && !this.room.isCallRoom()) { this.destroy(); } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 820355d6c61..c32a5421339 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -20,16 +20,15 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; -import { GroupCall, Room } from "matrix-js-sdk/src/matrix"; +import type { GroupCall, Room } from "matrix-js-sdk/src/matrix"; +import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import WidgetStore from "./WidgetStore"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { ActionPayload } from "../dispatcher/payloads"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -53,7 +52,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { this.setMaxListeners(100); // One for each RoomTile } - protected async onAction(payload: ActionPayload): Promise { + protected async onAction(): Promise { // nothing to do } diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 730e14b9281..8a658d6c097 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -851,7 +851,8 @@ describe("ElementCall", () => { }); afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); - // TODO add tests for passing device configuration to the widget + // TODO refactor initial device configuration to use the EW settings. + // Add tests for passing EW device configuration to the widget. it("waits for messaging when connecting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing From b9bf61a59e81c01d7bc242931a03bf1eea295135 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Jan 2024 16:29:02 +0100 Subject: [PATCH 44/51] only send call notify after the call has been joined (not when just opening the lobby) Signed-off-by: Timo K --- src/models/Call.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 078ff302c22..22ebb9eac28 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -835,20 +835,19 @@ export class ElementCall extends Call { } public static async create(room: Room, skipLobby = false): Promise { - const isVidRoom = isVideoRoom(room); - - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVidRoom); + ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room)); WidgetStore.instance.emit(UPDATE_EVENT, null); + } - // Send Call notify - + protected async sendCallNotify(): Promise { + const room = this.room; const existingRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter( // filter all memberships where the application is m.call and the call_id is "" (m) => m.application === "m.call" && m.callId === "", ); const memberCount = getJoinedNonFunctionalMembers(room).length; - if (!isVidRoom && existingRoomCallMembers.length == 0) { + if (!isVideoRoom(room) && existingRoomCallMembers.length == 0) { // send ringing event const content: ICallNotifyContent = { "application": "m.call", @@ -886,7 +885,9 @@ export class ElementCall extends Call { // 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 const session = this.client.matrixRTC.getActiveRoomSession(this.room); if (session) { await waitForEvent( @@ -903,6 +904,7 @@ export class ElementCall extends Call { this.session.callId === session.callId && roomId === this.roomId, ); } + this.sendCallNotify(); } protected async performDisconnection(): Promise { From 368b3ed8851ab86cd7bbd692352a73466b787a5e Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 24 Jan 2024 11:39:55 +0100 Subject: [PATCH 45/51] update call notify tests to expect notify on connect. Not on widget creation. Signed-off-by: Timo K --- .../views/rooms/RoomHeader-test.tsx | 2 +- test/models/Call-test.ts | 54 ++++++++++--------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 0fdcc1b63f9..7aa911d13a8 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -405,7 +405,7 @@ describe("RoomHeader", () => { expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); - it("calls using element calls for large rooms", async () => { + it("calls using element call for large rooms", async () => { mockRoomMembers(room, 3); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 8a658d6c097..21634e9c3fe 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -626,6 +626,10 @@ describe("ElementCall", () => { let room: Room; let alice: RoomMember; + function setRoomMembers(memberIds: string[]) { + jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); + } + const callConnectProcedure: (call: ElementCall) => Promise = async (call) => { async function sessionConnect() { await new Promise((r) => { @@ -1039,6 +1043,31 @@ describe("ElementCall", () => { client.isRoomEncrypted.mockClear(); addWidgetSpy.mockRestore(); }); + + it("sends notify event on connect in a room with more than two members", async () => { + const sendEventSpy = jest.spyOn(room.client, "sendEvent"); + await ElementCall.create(room); + await callConnectProcedure(Call.get(room) as ElementCall); + expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { + "application": "m.call", + "call_id": "", + "m.mentions": { room: true, user_ids: [] }, + "notify_type": "notify", + }); + }); + it("sends ring on create in a DM (two participants) room", async () => { + setRoomMembers(["@user:example.com", "@user2:example.com"]); + + const sendEventSpy = jest.spyOn(room.client, "sendEvent"); + await ElementCall.create(room); + await callConnectProcedure(Call.get(room) as ElementCall); + expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { + "application": "m.call", + "call_id": "", + "m.mentions": { room: true, user_ids: [] }, + "notify_type": "ring", + }); + }); }); describe("instance in a video room", () => { @@ -1121,34 +1150,9 @@ describe("ElementCall", () => { }); }); describe("create call", () => { - function setRoomMembers(memberIds: string[]) { - jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); - } beforeEach(async () => { setRoomMembers(["@user:example.com", "@user2:example.com", "@user4:example.com"]); }); - it("sends notify event on create in a room with more than two members", async () => { - const sendEventSpy = jest.spyOn(room.client, "sendEvent"); - await ElementCall.create(room); - expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { - "application": "m.call", - "call_id": "", - "m.mentions": { room: true, user_ids: [] }, - "notify_type": "notify", - }); - }); - it("sends ring on create in a DM (two participants) room", async () => { - setRoomMembers(["@user:example.com", "@user2:example.com"]); - - const sendEventSpy = jest.spyOn(room.client, "sendEvent"); - await ElementCall.create(room); - expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { - "application": "m.call", - "call_id": "", - "m.mentions": { room: true, user_ids: [] }, - "notify_type": "ring", - }); - }); it("don't sent notify event if there are existing room call members", async () => { jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([ { application: "m.call", callId: "" } as unknown as CallMembership, From 83e0374ad7b049d830bdea296bf27f9a09c16763 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:49:22 +0100 Subject: [PATCH 46/51] Update playwright/e2e/room/room-header.spec.ts Co-authored-by: Robin --- playwright/e2e/room/room-header.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 4fcd9e376cd..2d0af8a6df9 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -178,7 +178,7 @@ test.describe("Room Header", () => { await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click(); - // Assert that the video is rendered + // Assert that the call view is still visible await expect(page.locator(".mx_CallView")).toBeVisible(); // Assert that GELS is visible From 9d61bc44b9f7812d4867511452c7d3426fd20bec Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:51:20 +0100 Subject: [PATCH 47/51] Update src/components/views/voip/CallView.tsx Co-authored-by: Robin --- src/components/views/voip/CallView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index d91ec3cd47d..0b1f8bed115 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -42,7 +42,8 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, useEffect(() => { // Always update the widget data so that we don't ignore "skipLobby" accidentally. - (call.widget.data ?? { skipLobby }).skipLobby = skipLobby; + call.widget.data ??= {}; + call.widget.data.skipLobby = skipLobby; }, [call.widget.data, skipLobby]); useEffect(() => { From 09344a58cf8ffa759ffb5f7e0a01e7f4881fd93d Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 25 Jan 2024 16:40:44 +0100 Subject: [PATCH 48/51] review rename connect -> start isVideoRoom not dependant on feature flags rename allOtherCallsDisconnected -> disconnectAllOtherCalls Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 11 ++--- src/models/Call.ts | 20 ++++++---- src/utils/video-rooms.ts | 5 +-- .../structures/PipContainer-test.tsx | 2 +- .../views/messages/CallEvent-test.tsx | 2 +- .../views/rooms/LegacyRoomHeader-test.tsx | 2 +- test/components/views/rooms/RoomTile-test.tsx | 2 +- test/models/Call-test.ts | 40 +++++++++---------- .../room-list/algorithms/Algorithm-test.ts | 2 +- 9 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 0b1f8bed115..1c8f82b9494 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -44,19 +44,20 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, // Always update the widget data so that we don't ignore "skipLobby" accidentally. call.widget.data ??= {}; call.widget.data.skipLobby = skipLobby; - }, [call.widget.data, skipLobby]); + }, [call.widget, call.widget.data, skipLobby]); useEffect(() => { if (call.connectionState === ConnectionState.Disconnected) { - // immediately connect (this will start the lobby view in the widget) - call.connect(); + // 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 allOtherCallsDisconnected: () => Promise = useCallback(async () => { + 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. const calls = [...CallStore.instance.activeCalls].filter( @@ -74,7 +75,7 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, waitForIframeLoad={call.widget.waitForIframeLoad} showMenubar={false} pointerEvents={resizing ? "none" : undefined} - stickyPromise={allOtherCallsDisconnected} + stickyPromise={disconnectAllOtherCalls} />
); diff --git a/src/models/Call.ts b/src/models/Call.ts index 22ebb9eac28..243a1fe0c2c 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -196,7 +196,7 @@ export abstract class Call extends TypedEventEmitter; /** - * Contacts the widget to connect to the call. + * Contacts the widget to connect to the call or prompt the user to connect to the call. * @param {MediaDeviceInfo | null} audioInput The audio input to use, or * null to start muted. * @param {MediaDeviceInfo | null} audioInput The video input to use, or @@ -213,11 +213,15 @@ export abstract class Call extends TypedEventEmitter; /** - * Connects the user to the call using the media devices set in - * MediaDeviceHandler. The widget associated with the call must be active + * Starts the communication between the widget and the call. + * The call then waits for the necessary requirements to actually perform the connection + * or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...) + * It uses the media devices set in MediaDeviceHandler. + * The widget associated with the call must be active * for this to succeed. + * Only call this if the call state is: ConnectionState.Disconnected. */ - public async connect(): Promise { + public async start(): Promise { this.connectionState = ConnectionState.WidgetLoading; const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = @@ -625,10 +629,10 @@ export class JitsiCall extends Call { await 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. + // 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. if (isVideoRoom(this.room)) { - this.connect(); + this.start(); } }; } @@ -988,7 +992,7 @@ export class ElementCall extends Call { // In video rooms we immediately want to reconnect after hangup // This starts the lobby again and connects to all signals from EC. if (isVideoRoom(this.room)) { - this.connect(); + this.start(); } }; diff --git a/src/utils/video-rooms.ts b/src/utils/video-rooms.ts index c321b26870b..4e17a396628 100644 --- a/src/utils/video-rooms.ts +++ b/src/utils/video-rooms.ts @@ -18,7 +18,4 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../settings/SettingsStore"; export const isVideoRoom = (room: Room): boolean => - room.isElementVideoRoom() || - (SettingsStore.getValue("feature_video_rooms") && - SettingsStore.getValue("feature_element_call_video_rooms") && - room.isCallRoom()); + room.isElementVideoRoom() || (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom()); diff --git a/test/components/structures/PipContainer-test.tsx b/test/components/structures/PipContainer-test.tsx index 2af7f05000b..bac22091f84 100644 --- a/test/components/structures/PipContainer-test.tsx +++ b/test/components/structures/PipContainer-test.tsx @@ -179,7 +179,7 @@ describe("PipContainer", () => { } as unknown as ClientWidgetApi); await act(async () => { - await call.connect(); + await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx index 0a9a784947f..37a26d4bc40 100644 --- a/test/components/views/messages/CallEvent-test.tsx +++ b/test/components/views/messages/CallEvent-test.tsx @@ -155,7 +155,7 @@ describe("CallEvent", () => { }), ); defaultDispatcher.unregister(dispatcherRef); - await act(() => call.connect()); + await act(() => call.start()); // Test that the leave button works fireEvent.click(screen.getByRole("button", { name: "Leave" })); diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx index 62a1f696817..332d07556aa 100644 --- a/test/components/views/rooms/LegacyRoomHeader-test.tsx +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -581,7 +581,7 @@ describe("LegacyRoomHeader", () => { } as MatrixRTCSession), 100, ); - await call.connect(); + await call.start(); const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!; renderHeader({ viewingCall: true, activeCall: call }); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 53823aa0dbe..3cf3b3fee5e 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -266,7 +266,7 @@ describe("RoomTile", () => { completeConnection(); await screen.findByText("Joined"); })(), - call.connect(), + call.start(), ]); await Promise.all([screen.findByText("Video"), call.disconnect()]); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 21634e9c3fe..0e13d347af6 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -254,7 +254,7 @@ describe("JitsiCall", () => { audioMutedSpy.mockReturnValue(true); videoMutedSpy.mockReturnValue(true); - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { audioInput: null, @@ -267,7 +267,7 @@ describe("JitsiCall", () => { audioMutedSpy.mockReturnValue(false); videoMutedSpy.mockReturnValue(false); - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { audioInput: "Headphones", @@ -281,7 +281,7 @@ describe("JitsiCall", () => { WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(); + const connect = call.start(); expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); @@ -305,7 +305,7 @@ describe("JitsiCall", () => { }); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(); + const connect = call.start(); expect(call.connectionState).toBe(ConnectionState.WidgetLoading); async function runTimers() { jest.advanceTimersByTime(500); @@ -333,11 +333,11 @@ describe("JitsiCall", () => { it("fails to connect if the widget returns an error", async () => { mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); - await expect(call.connect()).rejects.toBeDefined(); + await expect(call.start()).rejects.toBeDefined(); }); it("fails to disconnect if the widget returns an error", async () => { - await call.connect(); + await call.start(); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -345,7 +345,7 @@ describe("JitsiCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); const callback = jest.fn(); @@ -371,14 +371,14 @@ describe("JitsiCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "leave"); expect(call.connectionState).toBe(ConnectionState.Disconnected); @@ -386,14 +386,14 @@ describe("JitsiCall", () => { it("reconnects after disconnect in video rooms", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await call.connect(); + await call.start(); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, "join"); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -419,7 +419,7 @@ describe("JitsiCall", () => { // Now, stub out client.sendStateEvent so we can test our local echo client.sendStateEvent.mockReset(); - await call.connect(); + await call.start(); expect(call.participants).toEqual( new Map([ [alice, new Set(["alices_device"])], @@ -433,7 +433,7 @@ describe("JitsiCall", () => { it("updates room state when connecting and disconnecting", async () => { const now1 = Date.now(); - await call.connect(); + await call.start(); await waitFor( () => expect( @@ -460,7 +460,7 @@ describe("JitsiCall", () => { }); it("repeatedly updates room state while connected", async () => { - await call.connect(); + await call.start(); await waitFor( () => expect(client.sendStateEvent).toHaveBeenLastCalledWith( @@ -490,7 +490,7 @@ describe("JitsiCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await call.connect(); + await call.start(); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.WidgetLoading, ConnectionState.Disconnected], @@ -508,7 +508,7 @@ describe("JitsiCall", () => { const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); - await call.connect(); + await call.start(); await call.disconnect(); expect(onParticipants.mock.calls).toEqual([ [new Map([[alice, new Set(["alices_device"])]]), new Map()], @@ -521,7 +521,7 @@ describe("JitsiCall", () => { }); it("switches to spotlight layout when the widget becomes a PiP", async () => { - await call.connect(); + await call.start(); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); @@ -565,7 +565,7 @@ describe("JitsiCall", () => { }); it("doesn't clean up valid devices", async () => { - await call.connect(); + await call.start(); await client.sendStateEvent( room.roomId, JitsiCall.MEMBER_EVENT_TYPE, @@ -649,7 +649,7 @@ describe("ElementCall", () => { jest.advanceTimersByTime(500); } sessionConnect(); - const promise = call.connect(); + const promise = call.start(); runTimers(); await promise; }; @@ -877,7 +877,7 @@ describe("ElementCall", () => { // we only send a JoinCall action if the widget is preloading call.widget.data = { ...call.widget, preload: true }; mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); - await expect(call.connect()).rejects.toBeDefined(); + await expect(call.start()).rejects.toBeDefined(); }); it("fails to disconnect if the widget returns an error", async () => { diff --git a/test/stores/room-list/algorithms/Algorithm-test.ts b/test/stores/room-list/algorithms/Algorithm-test.ts index a47b364ff6c..1fd9d3868e2 100644 --- a/test/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/stores/room-list/algorithms/Algorithm-test.ts @@ -100,7 +100,7 @@ describe("Algorithm", () => { // End of setup expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); - await call.connect(); + await call.start(); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]); await call.disconnect(); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); From 7673e5f06f708e8e8eb0fed5cd834162cc1d1e5c Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 25 Jan 2024 16:48:00 +0100 Subject: [PATCH 49/51] check for EC widget Signed-off-by: Timo K --- src/hooks/room/useRoomCall.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index 613ef98afcc..5d8d567dfdc 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -32,7 +32,7 @@ import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutS import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; -import { IApp, isVirtualWidget } from "../../stores/WidgetStore"; +import { IApp } from "../../stores/WidgetStore"; export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; @@ -123,8 +123,8 @@ export const useRoomCall = ( const [canPinWidget, setCanPinWidget] = useState(false); const [widgetPinned, setWidgetPinned] = useState(false); - // We only want to prompt to pin the widget if it's not virtual (not element call based) - const isECWidget = widget ? isVirtualWidget(widget) : false; + // We only want to prompt to pin the widget if it's not element call based. + const isECWidget = WidgetType.CALL.matches(widget?.type ?? ""); const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned; const updateWidgetState = useCallback((): void => { From f97fb9b323c2a6d0c4d20c2e8e6cb042bb242918 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 25 Jan 2024 16:50:15 +0100 Subject: [PATCH 50/51] dep array Signed-off-by: Timo K --- src/components/views/voip/CallView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 1c8f82b9494..ce7ed253a75 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -44,7 +44,7 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, // Always update the widget data so that we don't ignore "skipLobby" accidentally. call.widget.data ??= {}; call.widget.data.skipLobby = skipLobby; - }, [call.widget, call.widget.data, skipLobby]); + }, [call.widget, skipLobby]); useEffect(() => { if (call.connectionState === ConnectionState.Disconnected) { From f25c2ea056269d082e78465d29945c1ff7d2bfea Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 25 Jan 2024 17:08:36 +0100 Subject: [PATCH 51/51] rename in spyOn Signed-off-by: Timo K --- test/components/views/voip/CallView-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index fd9ff10b92e..2746200a2fa 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -153,7 +153,7 @@ describe("CallView", () => { }); it("automatically connects to the call when skipLobby is true", async () => { - const connectSpy = jest.spyOn(call, "connect"); + const connectSpy = jest.spyOn(call, "start"); await renderView(true); await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 }); });