diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index ab9ed9ff7e3..2d0af8a6df9 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -178,8 +178,8 @@ 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(); + // Assert that the call view is still visible + await expect(page.locator(".mx_CallView")).toBeVisible(); // Assert that GELS is visible await expect( diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 72aab052f2d..42a1b94ea46 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2564,6 +2564,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} role="main" /> {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/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index c33cfc571c3..74317041bd2 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. (e.g. hangup a Voip call) + stickyPromise?: () => Promise; } interface IState { @@ -610,11 +615,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 = {}; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index dd2e29f51c8..e37217c4224 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -131,12 +131,14 @@ 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]; + case ConnectionState.Connecting: + case ConnectionState.Lobby: + case ConnectionState.WidgetLoading: + return [_t("action|join"), "primary", null]; } }, [connectionState, connect, disconnect]); diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index 4d27f05cefb..7594931e3b7 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], ); @@ -305,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")))} 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/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 8cdd01598fd..c244a78b438 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -35,6 +35,14 @@ export const RoomTileCallSummary: FC = ({ call }) => { text = _t("common|video"); active = false; break; + case ConnectionState.WidgetLoading: + text = _t("common|loading"); + active = false; + break; + case ConnectionState.Lobby: + text = _t("common|lobby"); + active = false; + break; case ConnectionState.Connecting: text = _t("room|joining"); active = true; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 00130a92593..ce7ed253a75 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -14,412 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback, AriaRole } from "react"; -import classNames from "classnames"; -import { logger } from "matrix-js-sdk/src/logger"; -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; +import React, { FC, useContext, useEffect, AriaRole, useCallback } from "react"; 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, ConnectionState, 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 FacePile from "../elements/FacePile"; -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} -
- ); -}; - -const MAX_FACES = 8; - -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; - resizing: boolean; - call: Call | null; - setStartingCall: (value: boolean) => void; - role?: AriaRole; -} - -const StartCallView: FC = ({ room, resizing, call, setStartingCall, role }) => { - 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)); - 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]); - - useEffect(() => { - (async (): Promise => { - // If the call was successfully started, 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(); - connectDeferred.resolve(); - } catch (e) { - connectDeferred.reject(e); - } - } - })(); - }, [call, connectDeferred]); - - return ( -
- {connected ? null : } - {call !== null && ( - - )} -
- ); -}; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface JoinCallViewProps { room: Room; resizing: boolean; call: Call; + skipLobby?: boolean; role?: AriaRole; } -const JoinCallView: FC = ({ room, resizing, call, role }) => { +const JoinCallView: FC = ({ room, resizing, call, skipLobby, role }) => { 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 - 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(() => { + // We'll take this opportunity to tidy up our room state 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; + useEffect(() => { + // Always update the widget data so that we don't ignore "skipLobby" accidentally. + call.widget.data ??= {}; + call.widget.data.skipLobby = skipLobby; + }, [call.widget, skipLobby]); - facePile = ( -
- {_t("voip|n_people_joined", { count: members.length })} - -
- ); + useEffect(() => { + if (call.connectionState === ConnectionState.Disconnected) { + // immediately start the call + // (this will start the lobby view in the widget and connect to all required widget events) + call.start(); } - - lobby = ( - - {facePile} - + return (): void => { + // If we are connected the widget is sticky and we do not want to destroy the call. + if (!call.connected) call.destroy(); + }; + }, [call]); + const disconnectAllOtherCalls: () => Promise = useCallback(async () => { + // The stickyPromise has to resolve before the widget actually becomes sticky. + // We only let the widget become sticky after disconnecting all other active calls. + const calls = [...CallStore.instance.activeCalls].filter( + (call) => SdkContextClass.instance.roomViewStore.getRoomId() !== call.roomId, ); - } - + await Promise.all(calls.map(async (call) => await call.disconnect())); + }, []); return ( -
- {lobby} - {/* We render the widget even if we're disconnected, so it stays loaded */} +
= ({ room, resizing, call, role }) => waitForIframeLoad={call.widget.waitForIframeLoad} showMenubar={false} pointerEvents={resizing ? "none" : undefined} + stickyPromise={disconnectAllOtherCalls} />
); @@ -441,19 +89,21 @@ interface CallViewProps { * button will create a call if there isn't already one. */ waitForCall: boolean; + skipLobby?: boolean; role?: AriaRole; } -export const CallView: FC = ({ room, resizing, waitForCall, role }) => { +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 ( - - ); + useEffect(() => { + if (call === null && !waitForCall) { + ElementCall.create(room, skipLobby); + } + }, [call, room, skipLobby, waitForCall]); + if (call === null) { + return null; } else { - return ; + return ; } }; 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..5d8d567dfdc 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -123,7 +123,9 @@ 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 element call based. + const isECWidget = WidgetType.CALL.matches(widget?.type ?? ""); + const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned; const updateWidgetState = useCallback((): void => { setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top)); @@ -169,7 +171,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 +182,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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 43f6d6bbce4..57a36f2f092 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -479,6 +479,7 @@ "legal": "Legal", "light": "Light", "loading": "Loading…", + "lobby": "Lobby", "location": "Location", "low_priority": "Low priority", "matrix": "Matrix", @@ -492,10 +493,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" @@ -3793,7 +3790,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:", @@ -3880,7 +3876,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" }, diff --git a/src/models/Call.ts b/src/models/Call.ts index c2bb25bada2..243a1fe0c2c 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -32,12 +32,14 @@ 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"; 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"; @@ -54,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; @@ -77,7 +80,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", @@ -188,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 @@ -205,12 +213,16 @@ 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 { - this.connectionState = ConnectionState.Connecting; + public async start(): Promise { + this.connectionState = ConnectionState.WidgetLoading; const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = (await MediaDeviceHandler.getDevices())!; @@ -246,7 +258,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(); @@ -460,6 +472,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; @@ -539,7 +552,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); @@ -615,6 +629,11 @@ export class JitsiCall extends Call { await this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); + // 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.start(); + } }; } @@ -623,7 +642,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); @@ -652,8 +671,11 @@ 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. + 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()!, deviceId: client.getDeviceId()!, @@ -664,8 +686,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"); @@ -685,24 +705,46 @@ 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, + returnToLobby: 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; + } + if (returnToLobby !== undefined) { + overwrites.returnToLobby = returnToLobby; + } + 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 @@ -711,13 +753,39 @@ 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, + returnToLobby: returnToLobby ?? 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( @@ -739,6 +807,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") && @@ -752,10 +821,16 @@ 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, + isVideoRoom(room), + ); return new ElementCall(session, availableOrCreatedWidget, room.client); } } @@ -763,23 +838,20 @@ export class ElementCall extends Call { return null; } - public static async create(room: Room): Promise { - const isVideoRoom = - SettingsStore.getValue("feature_video_rooms") && - SettingsStore.getValue("feature_element_call_video_rooms") && - room.isCallRoom(); - ElementCall.createOrGetCallWidget(room.roomId, room.client); + public static async create(room: Room, skipLobby = false): Promise { + 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 (!isVideoRoom && existingRoomCallMembers.length == 0) { + if (!isVideoRoom(room) && existingRoomCallMembers.length == 0) { // send ringing event const content: ICallNotifyContent = { "application": "m.call", @@ -796,30 +868,64 @@ 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); + this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + + if (!this.widget.data?.skipLobby) { + // If we do not skip the lobby we need to wait until the widget has + // connected to matrixRTC. This is either observed through the session state + // or the MatrixRTCSessionManager session started event. + this.connectionState = ConnectionState.Lobby; + } + // TODO: if the widget informs us when the join button is clicked (widget action), so we can + // - set state to connecting + // - send call notify + 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, + MatrixRTCSessionManagerEvents.SessionStarted, + (roomId: string, session: MatrixRTCSession) => + this.session.callId === session.callId && roomId === this.roomId, + ); + } + this.sendCallNotify(); } 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}`); } } 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(); @@ -828,7 +934,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); @@ -844,7 +950,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 the call on hangup for video call rooms. if (roomId == this.roomId && !this.room.isCallRoom()) { this.destroy(); } @@ -883,6 +989,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.start(); + } }; private onTileLayout = async (ev: CustomEvent): Promise => { diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 8a3f712e6b2..83c91fdab79 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; + /** + * If we want the call to skip the lobby and immediately join + */ + skipLobby?: boolean; promptAskToJoin: boolean; @@ -462,6 +466,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 @@ -513,6 +518,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); @@ -775,6 +781,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/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/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 83278ec1079..75529bfc1f5 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; } // 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; // 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 } }, ); diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 1bd8dad4cc2..59093074179 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; 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 3e1167beb9d..37a26d4bc40 100644 --- a/test/components/views/messages/CallEvent-test.tsx +++ b/test/components/views/messages/CallEvent-test.tsx @@ -141,7 +141,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(); @@ -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 ed82f74630c..332d07556aa 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(), @@ -274,6 +278,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -407,6 +412,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -432,6 +438,7 @@ describe("LegacyRoomHeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, view_call: true, }), ); @@ -562,7 +569,20 @@ describe("LegacyRoomHeader", () => { mockEnabledSettings(["feature_group_calls"]); await withCall(async (call) => { - await call.connect(); + // 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.start(); + const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!; renderHeader({ viewingCall: true, activeCall: call }); diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index e2a54ae77c4..7aa911d13a8 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()); @@ -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/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 616274a66a5..3cf3b3fee5e 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,20 +236,37 @@ 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(), + call.start(), ]); await Promise.all([screen.findByText("Video"), call.disconnect()]); @@ -274,12 +292,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/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 7e7f8ee9cc8..2746200a2fa 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -38,8 +38,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); @@ -75,8 +73,10 @@ describe("CallView", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); - const renderView = async (): Promise => { - render(, { wrapper: TooltipProvider }); + const renderView = async (skipLobby = false): Promise => { + render(, { + wrapper: TooltipProvider, + }); await act(() => Promise.resolve()); // Let effects settle }; @@ -108,20 +108,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 */ @@ -166,40 +152,17 @@ describe("CallView", () => { expectAvatars([]); }); - it("connects to the call when the join button is pressed", async () => { - await renderView(); - const connectSpy = jest.spyOn(call, "connect"); - fireEvent.click(screen.getByRole("button", { name: "Join" })); + it("automatically connects to the call when skipLobby is true", async () => { + const connectSpy = jest.spyOn(call, "start"); + 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)!; @@ -214,117 +177,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); - }); - - 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 a43849c943a..0e13d347af6 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(); @@ -253,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, @@ -266,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", @@ -280,21 +281,63 @@ describe("JitsiCall", () => { 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.start(); + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await connect; 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 + 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)); + messaging.emit( + `action:${ElementWidgetActions.JoinCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + } + }); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + const connect = call.start(); + 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; + jest.useRealTimers(); + }); + 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(); }); @@ -302,56 +345,55 @@ 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(); + + 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: {} }), - ); - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), + await waitFor(() => { + expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected), + expect(callback).toHaveBeenNthCalledWith( + 2, + ConnectionState.WidgetLoading, + ConnectionState.Disconnected, ); - } - 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 () => { 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); }); + it("reconnects after disconnect in video rooms", async () => { + expect(call.connectionState).toBe(ConnectionState.Disconnected); + 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); @@ -377,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"])], @@ -391,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( @@ -418,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( @@ -448,11 +490,13 @@ 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.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], ]); @@ -464,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()], @@ -477,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); @@ -521,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, @@ -582,11 +626,62 @@ 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) => { + 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(500); + jest.advanceTimersByTime(500); + } + sessionConnect(); + const promise = call.start(); + 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(); ({ client, room, alice } = setUpClientRoomAndStores()); }); - afterEach(() => cleanUpClientRoomAndStores(client, room)); + afterEach(() => { + jest.useRealTimers(); + cleanUpClientRoomAndStores(client, room); + }); describe("get", () => { it("finds no calls", () => { @@ -700,6 +795,28 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("analyticsID")).toBe(""); + call.destroy(); + }); + + 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); + } + }; + 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("allowVoipWithNoMedia")).toBe("true"); + SettingsStore.getValue = originalGetValue; + call.destroy(); }); it("passes empty analyticsID if the id is not in the account data", async () => { @@ -729,7 +846,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; @@ -738,41 +855,18 @@ 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 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 + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connecting); + const connect = callConnectProcedure(call); + + expect(call.connectionState).toBe(ConnectionState.WidgetLoading); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await connect; @@ -780,12 +874,14 @@ 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(); + await expect(call.start()).rejects.toBeDefined(); }); it("fails to disconnect if the widget returns an error", async () => { - await call.connect(); + await callConnectProcedure(call); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -793,7 +889,7 @@ describe("ElementCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit( @@ -805,35 +901,35 @@ describe("ElementCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); + await callConnectProcedure(call); expect(call.connectionState).toBe(ConnectionState.Connected); - await call.disconnect(); + await callDisconnectionProcedure(call); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.connect(); + 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(); + 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(); + 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(); + await callConnectProcedure(call); expect(call.layout).toBe(Layout.Tile); messaging.emit( @@ -850,7 +946,7 @@ describe("ElementCall", () => { }); it("sets layout", async () => { - await call.connect(); + await callConnectProcedure(call); await call.setLayout(Layout.Spotlight); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); @@ -860,13 +956,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 callConnectProcedure(call); + await callDisconnectionProcedure(call); 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], @@ -887,7 +985,7 @@ describe("ElementCall", () => { }); it("emits events when layout changes", async () => { - await call.connect(); + await callConnectProcedure(call); const onLayout = jest.fn(); call.on(CallEvent.Layout, onLayout); @@ -905,10 +1003,10 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await call.connect(); + await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(); + await callDisconnectionProcedure(call); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -935,25 +1033,47 @@ 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(); }); + + 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", () => { let call: ElementCall; let widget: Widget; + let messaging: Mocked; let audioMutedSpy: jest.SpyInstance; let videoMutedSpy: jest.SpyInstance; @@ -968,49 +1088,71 @@ 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)); it("doesn't end the call when the last participant leaves", async () => { - await call.connect(); + await callConnectProcedure(call); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await call.disconnect(); + await callDisconnectionProcedure(call); 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 callConnectProcedure(call); + 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[]) { - 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, 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]); diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts index fce2b05a754..59ea954b9cf 100644 --- a/test/stores/widgets/StopGapWidget-test.ts +++ b/test/stores/widgets/StopGapWidget-test.ts @@ -17,7 +17,8 @@ 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 { waitFor } from "@testing-library/react"; import { stubClient, mkRoom, mkEvent } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -25,6 +26,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 +116,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; + } + }); + }; + 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(); + + waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); + }); +}); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 20aae4a64e4..d24ddae2495 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" }); @@ -167,6 +167,30 @@ describe("IncomingCallEvent", () => { expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, + skipLobby: false, + view_call: true, + }), + ); + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), + ); + + 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, }), );