New group call experience: Starting and ending calls (#9318)
* Create m.room calls in video rooms, and m.prompt calls otherwise * Terminate a call when the last person leaves * Hook up the room header button to a unified CallView component * Write more tests
This commit is contained in:
parent
54b79c7667
commit
ace6591f43
15 changed files with 695 additions and 512 deletions
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
199
res/css/views/voip/_CallView.pcss
Normal file
199
res/css/views/voip/_CallView.pcss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<IRoomProps, IRoomState> {
|
|||
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<IRoomProps, IRoomState> {
|
|||
};
|
||||
|
||||
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<IRoomProps, IRoomState> {
|
|||
}
|
||||
|
||||
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<IRoomState> & Pick<IRoomState, any> = {
|
||||
|
@ -561,13 +568,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
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<IRoomProps, IRoomState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onActiveCalls = () => {
|
||||
if (this.state.roomId !== undefined && !CallStore.instance.hasActiveCall(this.state.roomId)) {
|
||||
// We disconnected from the call, so stop viewing it
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
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<IRoomProps, IRoomState> {
|
|||
);
|
||||
}
|
||||
|
||||
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<IRoomProps, IRoomState> {
|
|||
|
||||
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<IRoomProps, IRoomState> {
|
|||
</>;
|
||||
break;
|
||||
case MainSplitContentType.Call: {
|
||||
mainSplitContentClassName = "mx_MainSplit_video";
|
||||
mainSplitContentClassName = "mx_MainSplit_call";
|
||||
mainSplitBody = <>
|
||||
<VideoRoomView room={this.state.room} resizing={this.state.resizing} />
|
||||
<CallView
|
||||
room={this.state.room}
|
||||
resizing={this.state.resizing}
|
||||
waitForCall={isVideoRoom(this.state.room)}
|
||||
/>
|
||||
{ previewBar }
|
||||
</>;
|
||||
}
|
||||
|
|
|
@ -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<Props & { call: Call }> = ({ 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 <div className="mx_VideoRoomView">
|
||||
{ connected ? null : <CallLobby room={room} call={call} /> }
|
||||
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
|
||||
<AppTile
|
||||
app={call.widget}
|
||||
room={room}
|
||||
userId={cli.credentials.userId}
|
||||
creatorUserId={call.widget.creatorUserId}
|
||||
waitForIframeLoad={call.widget.waitForIframeLoad}
|
||||
showMenubar={false}
|
||||
pointerEvents={resizing ? "none" : undefined}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const VideoRoomView: FC<Props> = ({ room, resizing }) => {
|
||||
const call = useCall(room.roomId);
|
||||
return call ? <LoadedVideoRoomView room={room} resizing={resizing} call={call} /> : null;
|
||||
};
|
|
@ -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<DeviceButtonProps> = ({
|
||||
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 = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
||||
<IconizedContextMenuOptionList>
|
||||
|
@ -77,12 +81,12 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
|||
if (!devices.length) return null;
|
||||
|
||||
return <div
|
||||
className={classNames("mx_CallLobby_deviceButtonWrapper", {
|
||||
"mx_CallLobby_deviceButtonWrapper_muted": muted,
|
||||
className={classNames("mx_CallView_deviceButtonWrapper", {
|
||||
"mx_CallView_deviceButtonWrapper_muted": muted,
|
||||
})}
|
||||
>
|
||||
<AccessibleTooltipButton
|
||||
className={`mx_CallLobby_deviceButton mx_CallLobby_deviceButton_${kind}`}
|
||||
className={`mx_CallView_deviceButton mx_CallView_deviceButton_${kind}`}
|
||||
title={muted ? mutedTitle : unmutedTitle}
|
||||
alignment={Alignment.Top}
|
||||
onClick={toggle}
|
||||
|
@ -90,10 +94,10 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
|||
/>
|
||||
{ devices.length > 1 ? (
|
||||
<ContextMenuButton
|
||||
className="mx_CallLobby_deviceListButton"
|
||||
className="mx_CallView_deviceListButton"
|
||||
inputRef={buttonRef}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
isExpanded={showMenu}
|
||||
label={deviceListLabel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
@ -104,15 +108,15 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
|||
|
||||
const MAX_FACES = 8;
|
||||
|
||||
interface Props {
|
||||
interface LobbyProps {
|
||||
room: Room;
|
||||
call: Call;
|
||||
connect: () => Promise<void>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const CallLobby: FC<Props> = ({ room, call }) => {
|
||||
export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
||||
const participants = useParticipants(call);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
|
||||
|
@ -173,32 +177,20 @@ export const CallLobby: FC<Props> = ({ 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 = <div className="mx_CallLobby_participants">
|
||||
{ _t("%(count)s people joined", { count: participants.size }) }
|
||||
<FacePile members={shownMembers} faceSize={24} overflow={overflow} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_CallLobby">
|
||||
{ facePile }
|
||||
<div className="mx_CallLobby_preview">
|
||||
return <div className="mx_CallView_lobby">
|
||||
{ children }
|
||||
<div className="mx_CallView_preview">
|
||||
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
@ -207,7 +199,7 @@ export const CallLobby: FC<Props> = ({ room, call }) => {
|
|||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
<div className="mx_CallLobby_controls">
|
||||
<div className="mx_CallView_controls">
|
||||
<DeviceButton
|
||||
kind="audio"
|
||||
devices={audioInputs}
|
||||
|
@ -235,12 +227,152 @@ export const CallLobby: FC<Props> = ({ room, call }) => {
|
|||
</div>
|
||||
</div>
|
||||
<AccessibleButton
|
||||
className="mx_CallLobby_connectButton"
|
||||
className="mx_CallView_connectButton"
|
||||
kind="primary"
|
||||
disabled={connecting}
|
||||
onClick={connect}
|
||||
onClick={onConnectClick}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
interface StartCallViewProps {
|
||||
room: Room;
|
||||
resizing: boolean;
|
||||
call: Call | null;
|
||||
setStartingCall: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const StartCallView: FC<StartCallViewProps> = ({ 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<IDeferred<void>>();
|
||||
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 <div className="mx_CallView">
|
||||
{ connected ? null : <Lobby room={room} connect={connect} /> }
|
||||
{ call !== null && <AppTile
|
||||
app={call.widget}
|
||||
room={room}
|
||||
userId={cli.credentials.userId}
|
||||
creatorUserId={call.widget.creatorUserId}
|
||||
waitForIframeLoad={call.widget.waitForIframeLoad}
|
||||
showMenubar={false}
|
||||
pointerEvents={resizing ? "none" : undefined}
|
||||
/> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
interface JoinCallViewProps {
|
||||
room: Room;
|
||||
resizing: boolean;
|
||||
call: Call;
|
||||
}
|
||||
|
||||
const JoinCallView: FC<JoinCallViewProps> = ({ 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 = <div className="mx_CallView_participants">
|
||||
{ _t("%(count)s people joined", { count: participants.size }) }
|
||||
<FacePile members={shownMembers} faceSize={24} overflow={overflow} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
lobby = <Lobby room={room} connect={connect}>{ facePile }</Lobby>;
|
||||
}
|
||||
|
||||
return <div className="mx_CallView">
|
||||
{ lobby }
|
||||
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
|
||||
<AppTile
|
||||
app={call.widget}
|
||||
room={room}
|
||||
userId={cli.credentials.userId}
|
||||
creatorUserId={call.widget.creatorUserId}
|
||||
waitForIframeLoad={call.widget.waitForIframeLoad}
|
||||
showMenubar={false}
|
||||
pointerEvents={resizing ? "none" : undefined}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
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<CallViewProps> = ({ room, resizing, waitForCall }) => {
|
||||
const call = useCall(room.roomId);
|
||||
const [startingCall, setStartingCall] = useState(false);
|
||||
|
||||
if (call === null || startingCall) {
|
||||
if (waitForCall) return null;
|
||||
return <StartCallView room={room} resizing={resizing} call={call} setStartingCall={setStartingCall} />;
|
||||
} else {
|
||||
return <JoinCallView room={room} resizing={resizing} call={call} />;
|
||||
}
|
||||
};
|
|
@ -1046,8 +1046,6 @@
|
|||
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
|
||||
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> 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",
|
||||
|
|
|
@ -79,7 +79,7 @@ export enum CallEvent {
|
|||
|
||||
interface CallEventHandlerMap {
|
||||
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
|
||||
[CallEvent.Participants]: (participants: Set<RoomMember>) => void;
|
||||
[CallEvent.Participants]: (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => void;
|
||||
[CallEvent.Destroy]: () => void;
|
||||
}
|
||||
|
||||
|
@ -129,8 +129,9 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
return this._participants;
|
||||
}
|
||||
protected set participants(value: Set<RoomMember>) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<RoomMember>, prevParticipants: Set<RoomMember>) => {
|
||||
// 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<IWidgetApiRequest>) => {
|
||||
ev.preventDefault();
|
||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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<void> {
|
||||
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)
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
@ -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<MatrixClient>;
|
||||
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<void> => {
|
||||
render(<VideoRoomView room={room} resizing={false} />);
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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<MatrixClient>;
|
||||
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<void> => {
|
||||
render(<CallLobby room={room} call={call} />);
|
||||
const renderView = async (): Promise<void> => {
|
||||
render(<CallView room={room} resizing={false} waitForCall={false} />);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<MatrixClient>;
|
||||
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<ClientWidgetApi>;
|
||||
|
@ -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<ClientWidgetApi>;
|
||||
|
@ -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<boolean, []>;
|
||||
let videoMutedSpy: jest.SpyInstance<boolean, []>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue