diff --git a/res/css/_components.pcss b/res/css/_components.pcss index cfcf88a4d7..96e5a1a50f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -73,7 +73,6 @@ @import "./structures/_ToastContainer.pcss"; @import "./structures/_UploadBar.pcss"; @import "./structures/_UserMenu.pcss"; -@import "./structures/_VideoRoomView.pcss"; @import "./structures/_ViewSource.pcss"; @import "./structures/auth/_CompleteSecurity.pcss"; @import "./structures/auth/_Login.pcss"; @@ -347,7 +346,7 @@ @import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; -@import "./views/voip/_CallLobby.pcss"; +@import "./views/voip/_CallView.pcss"; @import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPadContextMenu.pcss"; @import "./views/voip/_DialPadModal.pcss"; diff --git a/res/css/structures/_VideoRoomView.pcss b/res/css/structures/_VideoRoomView.pcss deleted file mode 100644 index 6d758820bf..0000000000 --- a/res/css/structures/_VideoRoomView.pcss +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_VideoRoomView { - flex-grow: 1; - min-height: 0; - - display: flex; - flex-direction: column; - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); - - background-color: $header-panel-bg-color; - padding: 8px; - border-radius: 8px; - - .mx_AppTile { - width: auto; - height: 100%; - border: none; - } - - /* While the lobby is shown, the widget needs to stay loaded but hidden in the background */ - .mx_CallLobby ~ .mx_AppTile { - display: none; - } -} diff --git a/res/css/views/voip/_CallLobby.pcss b/res/css/views/voip/_CallLobby.pcss deleted file mode 100644 index 306ed8962b..0000000000 --- a/res/css/views/voip/_CallLobby.pcss +++ /dev/null @@ -1,174 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_CallLobby { - min-height: 0; - flex-grow: 1; - padding: $spacing-12; - color: $call-lobby-primary-content; - background-color: $call-lobby-background; - border-radius: 8px; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: $spacing-32; - - .mx_FacePile { - width: fit-content; - margin: $spacing-8 auto 0; - - .mx_FacePile_faces .mx_BaseAvatar_image { - border-color: $call-lobby-background; - } - } - - .mx_CallLobby_preview { - position: relative; - width: 100%; - max-width: 800px; - aspect-ratio: 1.5; - background-color: $call-lobby-system; - - border-radius: 20px; - overflow: hidden; - - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - .mx_BaseAvatar { - margin: $spacing-20; - - /* Override the explicit dimensions on the element so that this gets sized responsively */ - width: unset !important; - height: unset !important; - min-width: 0; - min-height: 0; - flex: 0 1 200px; - } - - video { - position: absolute; - top: 0; - width: 100%; - height: 100%; - object-fit: cover; - display: block; - transform: scaleX(-1); /* flip the image */ - background-color: black; - } - - .mx_CallLobby_controls { - position: absolute; - bottom: 0; - left: 0; - right: 0; - - background-color: rgba($call-lobby-background, 0.9); - - display: flex; - justify-content: center; - gap: $spacing-24; - - .mx_CallLobby_deviceButtonWrapper { - position: relative; - margin: 6px 0 10px; - - .mx_CallLobby_deviceButton { - $size: 50px; - - width: $size; - height: $size; - - background-color: $call-lobby-system; - border-radius: calc($size / 2); - - &::before { - content: ''; - display: inline-block; - mask-repeat: no-repeat; - mask-size: 20px; - mask-position: center; - background-color: $call-lobby-primary-content; - height: 100%; - width: 100%; - } - - &.mx_CallLobby_deviceButton_audio::before { - mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); - } - - &.mx_CallLobby_deviceButton_video::before { - mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); - } - } - - .mx_CallLobby_deviceListButton { - $size: 15px; - - position: absolute; - bottom: 0; - right: -2.5px; - width: $size; - height: $size; - - background-color: $call-lobby-system; - border-radius: calc($size / 2); - - &::before { - content: ''; - display: inline-block; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); - mask-size: $size; - mask-position: center; - background-color: $call-lobby-primary-content; - height: 100%; - width: 100%; - } - } - - &.mx_CallLobby_deviceButtonWrapper_muted { - .mx_CallLobby_deviceButton, - .mx_CallLobby_deviceListButton { - background-color: $call-lobby-primary-content; - - &::before { - background-color: $call-lobby-system; - } - } - - .mx_CallLobby_deviceButton { - &.mx_CallLobby_deviceButton_audio::before { - mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); - } - - &.mx_CallLobby_deviceButton_video::before { - mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); - } - } - } - } - } - } - - .mx_CallLobby_connectButton { - padding-left: 50px; - padding-right: 50px; - } -} diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_CallView.pcss new file mode 100644 index 0000000000..0e75ae7aea --- /dev/null +++ b/res/css/views/voip/_CallView.pcss @@ -0,0 +1,199 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_CallView { + flex-grow: 1; + min-height: 0; + + display: flex; + flex-direction: column; + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); + + background-color: $header-panel-bg-color; + padding: 8px; + border-radius: 8px; + + .mx_AppTile { + width: auto; + height: 100%; + border: none; + } + + /* While the lobby is shown, the widget needs to stay loaded but hidden in the background */ + .mx_CallView_lobby ~ .mx_AppTile { + display: none; + } + + .mx_CallView_lobby { + min-height: 0; + flex-grow: 1; + padding: $spacing-12; + color: $call-lobby-primary-content; + background-color: $call-lobby-background; + border-radius: 8px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-32; + + .mx_FacePile { + width: fit-content; + margin: $spacing-8 auto 0; + + .mx_FacePile_faces .mx_BaseAvatar_image { + border-color: $call-lobby-background; + } + } + + .mx_CallView_preview { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 1.5; + background-color: $call-lobby-system; + + border-radius: 20px; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .mx_BaseAvatar { + margin: $spacing-20; + + /* Override the explicit dimensions on the element so that this gets sized responsively */ + width: unset !important; + height: unset !important; + min-width: 0; + min-height: 0; + flex: 0 1 200px; + } + + video { + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scaleX(-1); /* flip the image */ + background-color: black; + } + + .mx_CallView_controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + + background-color: rgba($call-lobby-background, 0.9); + + display: flex; + justify-content: center; + gap: $spacing-24; + + .mx_CallView_deviceButtonWrapper { + position: relative; + margin: 6px 0 10px; + + .mx_CallView_deviceButton { + $size: 50px; + + width: $size; + height: $size; + + background-color: $call-lobby-system; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-repeat: no-repeat; + mask-size: 20px; + mask-position: center; + background-color: $call-lobby-primary-content; + height: 100%; + width: 100%; + } + + &.mx_CallView_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); + } + + &.mx_CallView_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); + } + } + + .mx_CallView_deviceListButton { + $size: 15px; + + position: absolute; + bottom: 0; + right: -2.5px; + width: $size; + height: $size; + + background-color: $call-lobby-system; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-size: $size; + mask-position: center; + background-color: $call-lobby-primary-content; + height: 100%; + width: 100%; + } + } + + &.mx_CallView_deviceButtonWrapper_muted { + .mx_CallView_deviceButton, + .mx_CallView_deviceListButton { + background-color: $call-lobby-primary-content; + + &::before { + background-color: $call-lobby-system; + } + } + + .mx_CallView_deviceButton { + &.mx_CallView_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); + } + + &.mx_CallView_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); + } + } + } + } + } + } + + .mx_CallView_connectButton { + padding-left: 50px; + padding-right: 50px; + } + } +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1c20195b68..7af7b3e2a4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -77,7 +77,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; import WidgetStore from "../../stores/WidgetStore"; -import { VideoRoomView } from "./VideoRoomView"; +import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -120,6 +120,7 @@ import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; +import { CallStore, CallStoreEvent } from "../../stores/CallStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -442,6 +443,8 @@ export class RoomView extends React.Component { WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls); + this.props.resizeNotifier.on("isResizing", this.onIsResizing); this.settingWatchers = [ @@ -514,7 +517,10 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room) => { - if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { + if ( + (SettingsStore.getValue("feature_group_calls") && RoomViewStore.instance.isViewingCall()) + || isVideoRoom(room) + ) { return MainSplitContentType.Call; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { @@ -544,6 +550,7 @@ export class RoomView extends React.Component { } const roomId = RoomViewStore.instance.getRoomId(); + const room = this.context.getRoom(roomId); // This convoluted type signature ensures we get IntelliSense *and* correct typing const newState: Partial & Pick = { @@ -561,13 +568,13 @@ export class RoomView extends React.Component { showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), }; const initialEventId = RoomViewStore.instance.getInitialEventId(); if (initialEventId) { - const room = this.context.getRoom(roomId); let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data // We need to fetch it to know whether to route this request @@ -693,6 +700,18 @@ export class RoomView extends React.Component { } }; + private onActiveCalls = () => { + if (this.state.roomId !== undefined && !CallStore.instance.hasActiveCall(this.state.roomId)) { + // We disconnected from the call, so stop viewing it + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.roomId, + view_call: false, + metricsTrigger: undefined, + }, true); // Synchronous so that CallView disappears immediately + } + }; + private getRoomId = () => { // According to `onRoomViewStoreUpdate`, `state.roomId` can be null // if we have a room alias we haven't resolved yet. To work around this, @@ -894,6 +913,7 @@ export class RoomView extends React.Component { ); } + CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls); LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated @@ -2324,7 +2344,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), - mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Call, + mx_RoomView_immersive: this.state.mainSplitContentType !== MainSplitContentType.Timeline, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); @@ -2366,9 +2386,13 @@ export class RoomView extends React.Component { ; break; case MainSplitContentType.Call: { - mainSplitContentClassName = "mx_MainSplit_video"; + mainSplitContentClassName = "mx_MainSplit_call"; mainSplitBody = <> - + { previewBar } ; } diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx deleted file mode 100644 index d08ee53a46..0000000000 --- a/src/components/structures/VideoRoomView.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, useContext, useEffect } from "react"; - -import type { Room } from "matrix-js-sdk/src/models/room"; -import type { Call } from "../../models/Call"; -import { useCall, useConnectionState } from "../../hooks/useCall"; -import { isConnected } from "../../models/Call"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; -import AppTile from "../views/elements/AppTile"; -import { CallLobby } from "../views/voip/CallLobby"; - -interface Props { - room: Room; - resizing: boolean; -} - -const LoadedVideoRoomView: FC = ({ room, resizing, call }) => { - const cli = useContext(MatrixClientContext); - const connected = isConnected(useConnectionState(call)); - - // We'll take this opportunity to tidy up our room state - useEffect(() => { call?.clean(); }, [call]); - - if (!call) return null; - - return
- { connected ? null : } - { /* We render the widget even if we're disconnected, so it stays loaded */ } - -
; -}; - -export const VideoRoomView: FC = ({ room, resizing }) => { - const call = useCall(room.roomId); - return call ? : null; -}; diff --git a/src/components/views/voip/CallLobby.tsx b/src/components/views/voip/CallView.tsx similarity index 51% rename from src/components/views/voip/CallLobby.tsx rename to src/components/views/voip/CallView.tsx index 39cef9e406..f85296f1d0 100644 --- a/src/components/views/voip/CallLobby.tsx +++ b/src/components/views/voip/CallView.tsx @@ -14,24 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, useState, useMemo, useRef, useEffect, useCallback } from "react"; +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 { Room } from "matrix-js-sdk/src/models/room"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { ConnectionState } from "../../../models/Call"; +import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; +import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; -import { useParticipants } from "../../../hooks/useCall"; import { CallStore } from "../../../stores/CallStore"; -import { Call } from "../../../models/Call"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, } from "../context_menus/IconizedContextMenu"; import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { Alignment } from "../elements/Tooltip"; -import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import FacePile from "../elements/FacePile"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -52,14 +56,14 @@ interface DeviceButtonProps { const DeviceButton: FC = ({ kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle, }) => { - const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); - let contextMenu; - if (menuDisplayed) { - const selectDevice = (device: MediaDeviceInfo) => { - setDevice(device); - closeMenu(); - }; + 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 = @@ -77,12 +81,12 @@ const DeviceButton: FC = ({ if (!devices.length) return null; return
= ({ /> { devices.length > 1 ? ( @@ -104,15 +108,15 @@ const DeviceButton: FC = ({ const MAX_FACES = 8; -interface Props { +interface LobbyProps { room: Room; - call: Call; + connect: () => Promise; + children?: ReactNode; } -export const CallLobby: FC = ({ room, call }) => { +export const Lobby: FC = ({ room, connect, children }) => { const [connecting, setConnecting] = useState(false); const me = useMemo(() => room.getMember(room.myUserId)!, [room]); - const participants = useParticipants(call); const videoRef = useRef(null); const [audioInputs, videoInputs] = useAsyncMemo(async () => { @@ -173,32 +177,20 @@ export const CallLobby: FC = ({ room, call }) => { } }, [videoStream]); - const connect = useCallback(async () => { + const onConnectClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); setConnecting(true); 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(); + await connect(); } catch (e) { logger.error(e); setConnecting(false); } - }, [call, setConnecting]); + }, [connect, setConnecting]); - let facePile: JSX.Element | null = null; - if (participants.size) { - const shownMembers = [...participants].slice(0, MAX_FACES); - const overflow = participants.size > shownMembers.length; - - facePile =
- { _t("%(count)s people joined", { count: participants.size }) } - -
; - } - - return
- { facePile } -
+ return
+ { children } +
; }; + +interface StartCallViewProps { + room: Room; + resizing: boolean; + call: Call | null; + setStartingCall: (value: boolean) => void; +} + +const StartCallView: FC = ({ room, resizing, call, setStartingCall }) => { + 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) => setConnected(isConnected(state)); + call.on(CallEvent.ConnectionState, onConnectionState); + return () => { call.off(CallEvent.ConnectionState, onConnectionState); }; + } + }, [call]); + + const connect = useCallback(async () => { + setStartingCall(true); + await ElementCall.create(room); + await connectDeferred.promise; + }, [room, setStartingCall, connectDeferred]); + + useEffect(() => { + (async () => { + // 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 && } +
; +}; + +interface JoinCallViewProps { + room: Room; + resizing: boolean; + call: Call; +} + +const JoinCallView: FC = ({ room, resizing, call }) => { + const cli = useContext(MatrixClientContext); + const connected = isConnected(useConnectionState(call)); + const participants = useParticipants(call); + + const connect = useCallback(async () => { + // 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(() => { call.clean(); }, [call]); + + let lobby: JSX.Element | null = null; + if (!connected) { + let facePile: JSX.Element | null = null; + if (participants.size) { + const shownMembers = [...participants].slice(0, MAX_FACES); + const overflow = participants.size > shownMembers.length; + + facePile =
+ { _t("%(count)s people joined", { count: participants.size }) } + +
; + } + + lobby = { facePile }; + } + + return
+ { lobby } + { /* We render the widget even if we're disconnected, so it stays loaded */ } + +
; +}; + +interface CallViewProps { + room: Room; + resizing: boolean; + /** + * If true, the view will be blank until a call appears. Otherwise, the join + * button will create a call if there isn't already one. + */ + waitForCall: boolean; +} + +export const CallView: FC = ({ room, resizing, waitForCall }) => { + const call = useCall(room.roomId); + const [startingCall, setStartingCall] = useState(false); + + if (call === null || startingCall) { + if (waitForCall) return null; + return ; + } else { + return ; + } +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9bd1dd8124..4feddc9e66 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1046,8 +1046,6 @@ "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", "Send as message": "Send as message", - "%(count)s people joined|other": "%(count)s people joined", - "%(count)s people joined|one": "%(count)s person joined", "Audio devices": "Audio devices", "Audio input %(n)s": "Audio input %(n)s", "Mute microphone": "Mute microphone", @@ -1057,6 +1055,8 @@ "Turn off camera": "Turn off camera", "Turn on camera": "Turn on camera", "Join": "Join", + "%(count)s people joined|other": "%(count)s people joined", + "%(count)s people joined|one": "%(count)s person joined", "Dial": "Dial", "You are presenting": "You are presenting", "%(sharerName)s is presenting": "%(sharerName)s is presenting", diff --git a/src/models/Call.ts b/src/models/Call.ts index bc8bb6a65a..b51a49cc9e 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -79,7 +79,7 @@ export enum CallEvent { interface CallEventHandlerMap { [CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void; - [CallEvent.Participants]: (participants: Set) => void; + [CallEvent.Participants]: (participants: Set, prevParticipants: Set) => void; [CallEvent.Destroy]: () => void; } @@ -129,8 +129,9 @@ export abstract class Call extends TypedEventEmitter) { + const prevValue = this._participants; this._participants = value; - this.emit(CallEvent.Participants, value); + this.emit(CallEvent.Participants, value, prevValue); } constructor( @@ -601,6 +602,7 @@ export class ElementCall extends Call { public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour private participantsExpirationTimer: number | null = null; + private terminationTimer: number | null = null; private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) { // Splice together the Element Call URL for this call @@ -631,6 +633,7 @@ export class ElementCall extends Call { this.room.on(RoomStateEvent.Update, this.onRoomState); this.on(CallEvent.ConnectionState, this.onConnectionState); + this.on(CallEvent.Participants, this.onParticipants); this.updateParticipants(); } @@ -665,8 +668,12 @@ export class ElementCall extends Call { } public static async create(room: Room): Promise { + const isVideoRoom = SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom(); + await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, { - "m.intent": "m.room", + "m.intent": isVideoRoom ? "m.room" : "m.prompt", "m.type": "m.video", }, randomString(24)); } @@ -791,17 +798,45 @@ export class ElementCall extends Call { WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!); this.room.off(RoomStateEvent.Update, this.onRoomState); this.off(CallEvent.ConnectionState, this.onConnectionState); + this.off(CallEvent.Participants, this.onParticipants); + if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); this.participantsExpirationTimer = null; } + if (this.terminationTimer !== null) { + clearTimeout(this.terminationTimer); + this.terminationTimer = null; + } super.destroy(); } - private onRoomState = () => this.updateParticipants(); + private get mayTerminate(): boolean { + return this.groupCall.getContent()["m.intent"] !== "m.room" + && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client); + } - private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => { + private async terminate(): Promise { + await this.client.sendStateEvent( + this.roomId, + ElementCall.CALL_EVENT_TYPE.name, + { ...this.groupCall.getContent(), "m.terminated": "Call ended" }, + this.groupCall.getStateKey(), + ); + } + + private onRoomState = () => { + this.updateParticipants(); + + // Destroy the call if it's been terminated + const newGroupCall = this.room.currentState.getStateEvents( + this.groupCall.getType(), this.groupCall.getStateKey()!, + ); + if ("m.terminated" in newGroupCall.getContent()) this.destroy(); + }; + + private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => { if ( (state === ConnectionState.Connected && !isConnected(prevState)) || (state === ConnectionState.Disconnected && isConnected(prevState)) @@ -810,6 +845,25 @@ export class ElementCall extends Call { } }; + private onParticipants = async (participants: Set, prevParticipants: Set) => { + // If the last participant disconnected, terminate the call + if (participants.size === 0 && prevParticipants.size > 0 && this.mayTerminate) { + if (prevParticipants.has(this.room.getMember(this.client.getUserId()!)!)) { + // If we were that last participant, do the termination ourselves + await this.terminate(); + } else { + // We don't appear to have been the last participant, but because of + // the potential for races, users lacking permission, and a myriad of + // other reasons, we can't rely on other clients to terminate the call. + // Since it's likely that other clients are using this same logic, we wait + // randomly between 2 and 8 seconds before terminating the call, to + // probabilistically reduce event spam. If someone else beats us to it, + // this timer will be automatically cleared upon the call's destruction. + this.terminationTimer = setTimeout(() => this.terminate(), Math.random() * 6000 + 2000); + } + } + }; + private onHangup = async (ev: CustomEvent) => { ev.preventDefault(); await this.messaging!.transport.reply(ev.detail, {}); // ack diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 40b73d3114..e8d2a49199 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -156,6 +156,16 @@ export class CallStore extends AsyncStoreWithClient<{}> { return this.calls.get(roomId) ?? null; } + /** + * Determines whether the given room has an active call. + * @param roomId The room's ID. + * @returns Whether the given room has an active call. + */ + public hasActiveCall(roomId: string): boolean { + const call = this.get(roomId); + return call !== null && this.activeCalls.has(call); + } + private onRoom = (room: Room) => this.updateRoom(room); private onRoomState = (event: MatrixEvent, state: RoomState) => { diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 3db9c08434..42443a295d 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -50,6 +50,7 @@ import SettingsStore from "../settings/SettingsStore"; import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { UPDATE_EVENT } from "./AsyncStore"; +import { CallStore } from "./CallStore"; const NUM_JOIN_RETRY = 5; @@ -286,6 +287,8 @@ export class RoomViewStore extends EventEmitter { private async viewRoom(payload: ViewRoomPayload): Promise { if (payload.room_id) { + const room = MatrixClientPeg.get().getRoom(payload.room_id); + if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; if (SpaceStore.instance.activeSpace === MetaSpace.Home) { @@ -303,10 +306,11 @@ export class RoomViewStore extends EventEmitter { trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, isDM: !!DMRoomMap.shared().getUserIdForRoomId(payload.room_id), - isSpace: MatrixClientPeg.get().getRoom(payload.room_id)?.isSpaceRoom(), + isSpace: room?.isSpaceRoom(), activeSpace, }); } + if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. @@ -359,8 +363,9 @@ export class RoomViewStore extends EventEmitter { viaServers: payload.via_servers ?? [], wasContextSwitch: payload.context_switch ?? false, viewingCall: payload.view_call ?? ( - // Reset to false when switching rooms - payload.room_id === this.state.roomId ? this.state.viewingCall : false + payload.room_id === this.state.roomId + ? this.state.viewingCall + : CallStore.instance.hasActiveCall(payload.room_id) ), }; diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx deleted file mode 100644 index f3839a4d2f..0000000000 --- a/test/components/structures/VideoRoomView-test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { render, screen, act, fireEvent, waitFor, cleanup } from "@testing-library/react"; -import { mocked, Mocked } from "jest-mock"; -import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; -import { Room } from "matrix-js-sdk/src/models/room"; -import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; -import { Widget } from "matrix-widget-api"; - -import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import type { ClientWidgetApi } from "matrix-widget-api"; -import type { Call } from "../../../src/models/Call"; -import { - stubClient, - mkRoomMember, - wrapInMatrixClientContext, - useMockedCalls, - MockedCall, - setupAsyncStoreWithClient, -} from "../../test-utils"; -import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import { VideoRoomView as UnwrappedVideoRoomView } from "../../../src/components/structures/VideoRoomView"; -import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; -import { CallStore } from "../../../src/stores/CallStore"; -import { ConnectionState } from "../../../src/models/Call"; - -const VideoRoomView = wrapInMatrixClientContext(UnwrappedVideoRoomView); - -describe("VideoRoomView", () => { - useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { - value: { - enumerateDevices: async () => [], - getUserMedia: () => null, - }, - }); - - let client: Mocked; - let room: Room; - let call: Call; - let widget: Widget; - let alice: RoomMember; - - beforeEach(() => { - stubClient(); - client = mocked(MatrixClientPeg.get()); - - room = new Room("!1:example.org", client, "@alice:example.org", { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - alice = mkRoomMember(room.roomId, "@alice:example.org"); - jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null); - - client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); - client.getRooms.mockReturnValue([room]); - client.reEmitter.reEmit(room, [RoomStateEvent.Events]); - - setupAsyncStoreWithClient(CallStore.instance, client); - setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); - - MockedCall.create(room, "1"); - call = CallStore.instance.get(room.roomId); - if (call === null) throw new Error("Failed to create call"); - - widget = new Widget(call.widget); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - stop: () => {}, - } as unknown as ClientWidgetApi); - }); - - afterEach(() => { - cleanup(); - call.destroy(); - client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - }); - - const renderView = async (): Promise => { - render(); - await act(() => Promise.resolve()); // Let effects settle - }; - - it("calls clean on mount", async () => { - const cleanSpy = jest.spyOn(call, "clean"); - await renderView(); - 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); - }); -}); diff --git a/test/components/views/voip/CallLobby-test.tsx b/test/components/views/voip/CallView-test.tsx similarity index 52% rename from test/components/views/voip/CallLobby-test.tsx rename to test/components/views/voip/CallView-test.tsx index 49bbede03d..827b4d29c1 100644 --- a/test/components/views/voip/CallLobby-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import { zip } from "lodash"; -import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, act, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { mocked, Mocked } from "jest-mock"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -28,14 +28,18 @@ import type { ClientWidgetApi } from "matrix-widget-api"; import { stubClient, mkRoomMember, - MockedCall, + wrapInMatrixClientContext, useMockedCalls, + MockedCall, setupAsyncStoreWithClient, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { CallLobby } from "../../../../src/components/views/voip/CallLobby"; +import { CallView as _CallView } from "../../../../src/components/views/voip/CallView"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { CallStore } from "../../../../src/stores/CallStore"; +import { Call, ConnectionState } from "../../../../src/models/Call"; + +const CallView = wrapInMatrixClientContext(_CallView); describe("CallLobby", () => { useMockedCalls(); @@ -49,8 +53,6 @@ describe("CallLobby", () => { let client: Mocked; let room: Room; - let call: MockedCall; - let widget: Widget; let alice: RoomMember; beforeEach(() => { @@ -71,61 +73,122 @@ describe("CallLobby", () => { setupAsyncStoreWithClient(CallStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); - - MockedCall.create(room, "1"); - call = CallStore.instance.get(room.roomId) as MockedCall; - - widget = new Widget(call.widget); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - stop: () => {}, - } as unknown as ClientWidgetApi); }); afterEach(() => { - call.destroy(); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); }); - const renderLobby = async (): Promise => { - render(); + const renderView = async (): Promise => { + render(); await act(() => Promise.resolve()); // Let effects settle }; - it("tracks participants", async () => { - const bob = mkRoomMember(room.roomId, "@bob:example.org"); - const carol = mkRoomMember(room.roomId, "@carol:example.org"); + describe("with an existing call", () => { + let call: MockedCall; + let widget: Widget; - const expectAvatars = (userIds: string[]) => { - const avatars = screen.queryAllByRole("button", { name: "Avatar" }); - expect(userIds.length).toBe(avatars.length); + beforeEach(() => { + MockedCall.create(room, "1"); + const maybeCall = CallStore.instance.get(room.roomId); + if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); + call = maybeCall; - for (const [userId, avatar] of zip(userIds, avatars)) { - fireEvent.focus(avatar!); - screen.getByRole("tooltip", { name: userId }); - } - }; + widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + }); - await renderLobby(); - expect(screen.queryByLabelText(/joined/)).toBe(null); - expectAvatars([]); + afterEach(() => { + cleanup(); // Unmount before we do any cleanup that might update the component + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + }); - act(() => { call.participants = new Set([alice]); }); - screen.getByText("1 person joined"); - expectAvatars([alice.userId]); + it("calls clean on mount", async () => { + const cleanSpy = jest.spyOn(call, "clean"); + await renderView(); + expect(cleanSpy).toHaveBeenCalled(); + }); - act(() => { call.participants = new Set([alice, bob, carol]); }); - screen.getByText("3 people joined"); - expectAvatars([alice.userId, bob.userId, carol.userId]); + it("shows lobby and keeps widget loaded when disconnected", async () => { + await renderView(); + screen.getByRole("button", { name: "Join" }); + screen.getAllByText(/\bwidget\b/i); + }); - act(() => { call.participants = new Set(); }); - expect(screen.queryByLabelText(/joined/)).toBe(null); - expectAvatars([]); + 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); + }); + + it("tracks participants", async () => { + const bob = mkRoomMember(room.roomId, "@bob:example.org"); + const carol = mkRoomMember(room.roomId, "@carol:example.org"); + + const expectAvatars = (userIds: string[]) => { + const avatars = screen.queryAllByRole("button", { name: "Avatar" }); + expect(userIds.length).toBe(avatars.length); + + for (const [userId, avatar] of zip(userIds, avatars)) { + fireEvent.focus(avatar!); + screen.getByRole("tooltip", { name: userId }); + } + }; + + await renderView(); + expect(screen.queryByLabelText(/joined/)).toBe(null); + expectAvatars([]); + + act(() => { call.participants = new Set([alice]); }); + screen.getByText("1 person joined"); + expectAvatars([alice.userId]); + + act(() => { call.participants = new Set([alice, bob, carol]); }); + screen.getByText("3 people joined"); + expectAvatars([alice.userId, bob.userId, carol.userId]); + + act(() => { call.participants = new Set(); }); + expect(screen.queryByLabelText(/joined/)).toBe(null); + 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" })); + await waitFor(() => expect(connectSpy).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 waitFor(() => expect(CallStore.instance.get(room.roomId)).not.toBeNull()); + const call = CallStore.instance.get(room.roomId)!; + + const widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected)); + + cleanup(); // Unmount before we do any cleanup that might update the component + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + }); }); describe("device buttons", () => { it("hide when no devices are available", async () => { - await renderLobby(); + await renderView(); expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); }); @@ -139,7 +202,7 @@ describe("CallLobby", () => { toJSON: () => {}, }]); - await renderLobby(); + await renderView(); screen.getByRole("button", { name: /camera/ }); expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null); }); @@ -162,20 +225,11 @@ describe("CallLobby", () => { }, ]); - await renderLobby(); + await renderView(); screen.getByRole("button", { name: /microphone/ }); fireEvent.click(screen.getByRole("button", { name: "Audio devices" })); screen.getByRole("menuitem", { name: "Headphones" }); screen.getByRole("menuitem", { name: "Audio input 2" }); }); }); - - describe("join button", () => { - it("works", async () => { - await renderLobby(); - const connectSpy = jest.spyOn(call, "connect"); - fireEvent.click(screen.getByRole("button", { name: "Join" })); - await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 }); - }); - }); }); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index fbbf22eca8..2ed56e3fcc 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -51,11 +51,12 @@ jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); -jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName => - settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined, +const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]); +jest.spyOn(SettingsStore, "getValue").mockImplementation( + settingName => enabledSettings.has(settingName) || undefined, ); -const setUpClientRoomAndStores = (roomType: RoomType): { +const setUpClientRoomAndStores = (): { client: Mocked; room: Room; alice: RoomMember; @@ -68,7 +69,6 @@ const setUpClientRoomAndStores = (roomType: RoomType): { const room = new Room("!1:example.org", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, }); - jest.spyOn(room, "getType").mockReturnValue(roomType); const alice = mkRoomMember(room.roomId, "@alice:example.org"); const bob = mkRoomMember(room.roomId, "@bob:example.org"); @@ -165,7 +165,8 @@ describe("JitsiCall", () => { let carol: RoomMember; beforeEach(() => { - ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.ElementVideo)); + ({ client, room, alice, bob, carol } = setUpClientRoomAndStores()); + jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo); }); afterEach(() => cleanUpClientRoomAndStores(client, room)); @@ -191,7 +192,7 @@ describe("JitsiCall", () => { }); }); - describe("instance", () => { + describe("instance in a video room", () => { let call: JitsiCall; let widget: Widget; let messaging: Mocked; @@ -542,7 +543,7 @@ describe("ElementCall", () => { let carol: RoomMember; beforeEach(() => { - ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall)); + ({ client, room, alice, bob, carol } = setUpClientRoomAndStores()); }); afterEach(() => cleanUpClientRoomAndStores(client, room)); @@ -569,7 +570,7 @@ describe("ElementCall", () => { }); }); - describe("instance", () => { + describe("instance in a non-video room", () => { let call: ElementCall; let widget: Widget; let messaging: Mocked; @@ -590,6 +591,10 @@ describe("ElementCall", () => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + it("has intent m.prompt", () => { + expect(call.groupCall.getContent()["m.intent"]).toBe("m.prompt"); + }); + it("connects muted", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); audioMutedSpy.mockReturnValue(true); @@ -747,6 +752,59 @@ describe("ElementCall", () => { expect(events).toEqual([new Set([alice]), new Set()]); }); + it("ends the call immediately if we're the last participant to leave", async () => { + await call.connect(); + const onDestroy = jest.fn(); + call.on(CallEvent.Destroy, onDestroy); + await call.disconnect(); + expect(onDestroy).toHaveBeenCalled(); + call.off(CallEvent.Destroy, onDestroy); + }); + + it("ends the call after a random delay if the last participant leaves without ending it", async () => { + // Bob connects + await client.sendStateEvent( + room.roomId, + ElementCall.MEMBER_EVENT_TYPE.name, + { + "m.expires_ts": 1000 * 60 * 10, + "m.calls": [{ + "m.call_id": call.groupCall.getStateKey()!, + "m.devices": [{ device_id: "bobweb", session_id: "1", feeds: [] }], + }], + }, + bob.userId, + ); + + const onDestroy = jest.fn(); + call.on(CallEvent.Destroy, onDestroy); + + // Bob disconnects + await client.sendStateEvent( + room.roomId, + ElementCall.MEMBER_EVENT_TYPE.name, + { + "m.expires_ts": 1000 * 60 * 10, + "m.calls": [{ + "m.call_id": call.groupCall.getStateKey()!, + "m.devices": [], + }], + }, + bob.userId, + ); + + // Nothing should happen for at least a second, to give Bob a chance + // to end the call on his own + jest.advanceTimersByTime(1000); + expect(onDestroy).not.toHaveBeenCalled(); + + // Within 10 seconds, our client should end the call on behalf of Bob + jest.advanceTimersByTime(9000); + expect(onDestroy).toHaveBeenCalled(); + + call.off(CallEvent.Destroy, onDestroy); + }); + describe("clean", () => { const aliceWeb: IMyDevice = { device_id: "aliceweb", @@ -848,4 +906,40 @@ describe("ElementCall", () => { }); }); }); + + describe("instance in a video room", () => { + let call: ElementCall; + let widget: Widget; + let audioMutedSpy: jest.SpyInstance; + let videoMutedSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + + jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall); + + await ElementCall.create(room); + const maybeCall = ElementCall.get(room); + if (maybeCall === null) throw new Error("Failed to create call"); + call = maybeCall; + + ({ widget, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + }); + + afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + + it("has intent m.room", () => { + expect(call.groupCall.getContent()["m.intent"]).toBe("m.room"); + }); + + it("doesn't end the call when the last participant leaves", async () => { + await call.connect(); + const onDestroy = jest.fn(); + call.on(CallEvent.Destroy, onDestroy); + await call.disconnect(); + expect(onDestroy).not.toHaveBeenCalled(); + call.off(CallEvent.Destroy, onDestroy); + }); + }); }); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 0020ab4600..f4549026af 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -19,7 +19,7 @@ import { MatrixWidgetType } from "matrix-widget-api"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { mkEvent } from "./test-utils"; -import { Call } from "../../src/models/Call"; +import { Call, ElementCall, JitsiCall } from "../../src/models/Call"; export class MockedCall extends Call { private static EVENT_TYPE = "org.example.mocked_call"; @@ -91,4 +91,6 @@ export class MockedCall extends Call { */ export const useMockedCalls = () => { Call.get = room => MockedCall.get(room); + JitsiCall.create = async room => MockedCall.create(room, "1"); + ElementCall.create = async room => MockedCall.create(room, "1"); };