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 testspull/28788/head^2
parent
54b79c7667
commit
ace6591f43
|
@ -73,7 +73,6 @@
|
||||||
@import "./structures/_ToastContainer.pcss";
|
@import "./structures/_ToastContainer.pcss";
|
||||||
@import "./structures/_UploadBar.pcss";
|
@import "./structures/_UploadBar.pcss";
|
||||||
@import "./structures/_UserMenu.pcss";
|
@import "./structures/_UserMenu.pcss";
|
||||||
@import "./structures/_VideoRoomView.pcss";
|
|
||||||
@import "./structures/_ViewSource.pcss";
|
@import "./structures/_ViewSource.pcss";
|
||||||
@import "./structures/auth/_CompleteSecurity.pcss";
|
@import "./structures/auth/_CompleteSecurity.pcss";
|
||||||
@import "./structures/auth/_Login.pcss";
|
@import "./structures/auth/_Login.pcss";
|
||||||
|
@ -347,7 +346,7 @@
|
||||||
@import "./views/user-onboarding/_UserOnboardingTask.pcss";
|
@import "./views/user-onboarding/_UserOnboardingTask.pcss";
|
||||||
@import "./views/verification/_VerificationShowSas.pcss";
|
@import "./views/verification/_VerificationShowSas.pcss";
|
||||||
@import "./views/voip/LegacyCallView/_LegacyCallViewButtons.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/_DialPad.pcss";
|
||||||
@import "./views/voip/_DialPadContextMenu.pcss";
|
@import "./views/voip/_DialPadContextMenu.pcss";
|
||||||
@import "./views/voip/_DialPadModal.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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 { containsEmoji } from '../../effects/utils';
|
||||||
import { CHAT_EFFECTS } from '../../effects';
|
import { CHAT_EFFECTS } from '../../effects';
|
||||||
import WidgetStore from "../../stores/WidgetStore";
|
import WidgetStore from "../../stores/WidgetStore";
|
||||||
import { VideoRoomView } from "./VideoRoomView";
|
import { CallView } from "../views/voip/CallView";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||||
import Notifier from "../../Notifier";
|
import Notifier from "../../Notifier";
|
||||||
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
||||||
|
@ -120,6 +120,7 @@ import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
|
||||||
import { LargeLoader } from './LargeLoader';
|
import { LargeLoader } from './LargeLoader';
|
||||||
import { VoiceBroadcastInfoEventType } from '../../voice-broadcast';
|
import { VoiceBroadcastInfoEventType } from '../../voice-broadcast';
|
||||||
import { isVideoRoom } from '../../utils/video-rooms';
|
import { isVideoRoom } from '../../utils/video-rooms';
|
||||||
|
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
let debuglog = function(msg: string) {};
|
let debuglog = function(msg: string) {};
|
||||||
|
@ -442,6 +443,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
||||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||||
|
|
||||||
|
CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls);
|
||||||
|
|
||||||
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
|
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
|
||||||
|
|
||||||
this.settingWatchers = [
|
this.settingWatchers = [
|
||||||
|
@ -514,7 +517,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private getMainSplitContentType = (room: Room) => {
|
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;
|
return MainSplitContentType.Call;
|
||||||
}
|
}
|
||||||
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
|
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
|
||||||
|
@ -544,6 +550,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = RoomViewStore.instance.getRoomId();
|
const roomId = RoomViewStore.instance.getRoomId();
|
||||||
|
const room = this.context.getRoom(roomId);
|
||||||
|
|
||||||
// This convoluted type signature ensures we get IntelliSense *and* correct typing
|
// This convoluted type signature ensures we get IntelliSense *and* correct typing
|
||||||
const newState: Partial<IRoomState> & Pick<IRoomState, any> = {
|
const newState: Partial<IRoomState> & Pick<IRoomState, any> = {
|
||||||
|
@ -561,13 +568,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
|
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
|
||||||
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
|
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
|
||||||
wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(),
|
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
|
initialEventId: null, // default to clearing this, will get set later in the method if needed
|
||||||
showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId),
|
showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId),
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialEventId = RoomViewStore.instance.getInitialEventId();
|
const initialEventId = RoomViewStore.instance.getInitialEventId();
|
||||||
if (initialEventId) {
|
if (initialEventId) {
|
||||||
const room = this.context.getRoom(roomId);
|
|
||||||
let initialEvent = room?.findEventById(initialEventId);
|
let initialEvent = room?.findEventById(initialEventId);
|
||||||
// The event does not exist in the current sync data
|
// The event does not exist in the current sync data
|
||||||
// We need to fetch it to know whether to route this request
|
// 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 = () => {
|
private getRoomId = () => {
|
||||||
// According to `onRoomViewStoreUpdate`, `state.roomId` can be null
|
// According to `onRoomViewStoreUpdate`, `state.roomId` can be null
|
||||||
// if we have a room alias we haven't resolved yet. To work around this,
|
// 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);
|
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState);
|
||||||
|
|
||||||
// cancel any pending calls to the throttled updated
|
// 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", {
|
const mainClasses = classNames("mx_RoomView", {
|
||||||
mx_RoomView_inCall: Boolean(activeCall),
|
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');
|
const showChatEffects = SettingsStore.getValue('showChatEffects');
|
||||||
|
@ -2366,9 +2386,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
</>;
|
</>;
|
||||||
break;
|
break;
|
||||||
case MainSplitContentType.Call: {
|
case MainSplitContentType.Call: {
|
||||||
mainSplitContentClassName = "mx_MainSplit_video";
|
mainSplitContentClassName = "mx_MainSplit_call";
|
||||||
mainSplitBody = <>
|
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 }
|
{ 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.
|
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 classNames from "classnames";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 { _t } from "../../../languageHandler";
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
||||||
import { useParticipants } from "../../../hooks/useCall";
|
|
||||||
import { CallStore } from "../../../stores/CallStore";
|
import { CallStore } from "../../../stores/CallStore";
|
||||||
import { Call } from "../../../models/Call";
|
|
||||||
import IconizedContextMenu, {
|
import IconizedContextMenu, {
|
||||||
IconizedContextMenuOption,
|
IconizedContextMenuOption,
|
||||||
IconizedContextMenuOptionList,
|
IconizedContextMenuOptionList,
|
||||||
} from "../context_menus/IconizedContextMenu";
|
} from "../context_menus/IconizedContextMenu";
|
||||||
import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
import { Alignment } from "../elements/Tooltip";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import FacePile from "../elements/FacePile";
|
import FacePile from "../elements/FacePile";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
|
@ -52,14 +56,14 @@ interface DeviceButtonProps {
|
||||||
const DeviceButton: FC<DeviceButtonProps> = ({
|
const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
|
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
|
||||||
}) => {
|
}) => {
|
||||||
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
|
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
|
||||||
let contextMenu;
|
const selectDevice = useCallback((device: MediaDeviceInfo) => {
|
||||||
if (menuDisplayed) {
|
setDevice(device);
|
||||||
const selectDevice = (device: MediaDeviceInfo) => {
|
closeMenu();
|
||||||
setDevice(device);
|
}, [setDevice, closeMenu]);
|
||||||
closeMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let contextMenu: JSX.Element | null = null;
|
||||||
|
if (showMenu) {
|
||||||
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
||||||
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
||||||
<IconizedContextMenuOptionList>
|
<IconizedContextMenuOptionList>
|
||||||
|
@ -77,12 +81,12 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
if (!devices.length) return null;
|
if (!devices.length) return null;
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
className={classNames("mx_CallLobby_deviceButtonWrapper", {
|
className={classNames("mx_CallView_deviceButtonWrapper", {
|
||||||
"mx_CallLobby_deviceButtonWrapper_muted": muted,
|
"mx_CallView_deviceButtonWrapper_muted": muted,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AccessibleTooltipButton
|
<AccessibleTooltipButton
|
||||||
className={`mx_CallLobby_deviceButton mx_CallLobby_deviceButton_${kind}`}
|
className={`mx_CallView_deviceButton mx_CallView_deviceButton_${kind}`}
|
||||||
title={muted ? mutedTitle : unmutedTitle}
|
title={muted ? mutedTitle : unmutedTitle}
|
||||||
alignment={Alignment.Top}
|
alignment={Alignment.Top}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
@ -90,10 +94,10 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
/>
|
/>
|
||||||
{ devices.length > 1 ? (
|
{ devices.length > 1 ? (
|
||||||
<ContextMenuButton
|
<ContextMenuButton
|
||||||
className="mx_CallLobby_deviceListButton"
|
className="mx_CallView_deviceListButton"
|
||||||
inputRef={buttonRef}
|
inputRef={buttonRef}
|
||||||
onClick={openMenu}
|
onClick={openMenu}
|
||||||
isExpanded={menuDisplayed}
|
isExpanded={showMenu}
|
||||||
label={deviceListLabel}
|
label={deviceListLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
@ -104,15 +108,15 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
|
|
||||||
const MAX_FACES = 8;
|
const MAX_FACES = 8;
|
||||||
|
|
||||||
interface Props {
|
interface LobbyProps {
|
||||||
room: Room;
|
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 [connecting, setConnecting] = useState(false);
|
||||||
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
||||||
const participants = useParticipants(call);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
|
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
|
||||||
|
@ -173,32 +177,20 @@ export const CallLobby: FC<Props> = ({ room, call }) => {
|
||||||
}
|
}
|
||||||
}, [videoStream]);
|
}, [videoStream]);
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
const onConnectClick = useCallback(async (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
try {
|
try {
|
||||||
// Disconnect from any other active calls first, since we don't yet support holding
|
await connect();
|
||||||
await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()));
|
|
||||||
await call.connect();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
}, [call, setConnecting]);
|
}, [connect, setConnecting]);
|
||||||
|
|
||||||
let facePile: JSX.Element | null = null;
|
return <div className="mx_CallView_lobby">
|
||||||
if (participants.size) {
|
{ children }
|
||||||
const shownMembers = [...participants].slice(0, MAX_FACES);
|
<div className="mx_CallView_preview">
|
||||||
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">
|
|
||||||
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
|
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
@ -207,7 +199,7 @@ export const CallLobby: FC<Props> = ({ room, call }) => {
|
||||||
playsInline
|
playsInline
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
/>
|
/>
|
||||||
<div className="mx_CallLobby_controls">
|
<div className="mx_CallView_controls">
|
||||||
<DeviceButton
|
<DeviceButton
|
||||||
kind="audio"
|
kind="audio"
|
||||||
devices={audioInputs}
|
devices={audioInputs}
|
||||||
|
@ -235,12 +227,152 @@ export const CallLobby: FC<Props> = ({ room, call }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_CallLobby_connectButton"
|
className="mx_CallView_connectButton"
|
||||||
kind="primary"
|
kind="primary"
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
onClick={connect}
|
onClick={onConnectClick}
|
||||||
>
|
>
|
||||||
{ _t("Join") }
|
{ _t("Join") }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div>;
|
</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?",
|
"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.",
|
"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",
|
"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 devices": "Audio devices",
|
||||||
"Audio input %(n)s": "Audio input %(n)s",
|
"Audio input %(n)s": "Audio input %(n)s",
|
||||||
"Mute microphone": "Mute microphone",
|
"Mute microphone": "Mute microphone",
|
||||||
|
@ -1057,6 +1055,8 @@
|
||||||
"Turn off camera": "Turn off camera",
|
"Turn off camera": "Turn off camera",
|
||||||
"Turn on camera": "Turn on camera",
|
"Turn on camera": "Turn on camera",
|
||||||
"Join": "Join",
|
"Join": "Join",
|
||||||
|
"%(count)s people joined|other": "%(count)s people joined",
|
||||||
|
"%(count)s people joined|one": "%(count)s person joined",
|
||||||
"Dial": "Dial",
|
"Dial": "Dial",
|
||||||
"You are presenting": "You are presenting",
|
"You are presenting": "You are presenting",
|
||||||
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
|
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
|
||||||
|
|
|
@ -79,7 +79,7 @@ export enum CallEvent {
|
||||||
|
|
||||||
interface CallEventHandlerMap {
|
interface CallEventHandlerMap {
|
||||||
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
|
[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;
|
[CallEvent.Destroy]: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,8 +129,9 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
return this._participants;
|
return this._participants;
|
||||||
}
|
}
|
||||||
protected set participants(value: Set<RoomMember>) {
|
protected set participants(value: Set<RoomMember>) {
|
||||||
|
const prevValue = this._participants;
|
||||||
this._participants = value;
|
this._participants = value;
|
||||||
this.emit(CallEvent.Participants, value);
|
this.emit(CallEvent.Participants, value, prevValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -601,6 +602,7 @@ export class ElementCall extends Call {
|
||||||
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
private participantsExpirationTimer: number | null = null;
|
private participantsExpirationTimer: number | null = null;
|
||||||
|
private terminationTimer: number | null = null;
|
||||||
|
|
||||||
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
|
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
|
||||||
// Splice together the Element Call URL for this call
|
// 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.room.on(RoomStateEvent.Update, this.onRoomState);
|
||||||
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
|
this.on(CallEvent.Participants, this.onParticipants);
|
||||||
this.updateParticipants();
|
this.updateParticipants();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -665,8 +668,12 @@ export class ElementCall extends Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async create(room: Room): Promise<void> {
|
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, {
|
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",
|
"m.type": "m.video",
|
||||||
}, randomString(24));
|
}, randomString(24));
|
||||||
}
|
}
|
||||||
|
@ -791,17 +798,45 @@ export class ElementCall extends Call {
|
||||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
|
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
|
||||||
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
||||||
this.off(CallEvent.ConnectionState, this.onConnectionState);
|
this.off(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
|
this.off(CallEvent.Participants, this.onParticipants);
|
||||||
|
|
||||||
if (this.participantsExpirationTimer !== null) {
|
if (this.participantsExpirationTimer !== null) {
|
||||||
clearTimeout(this.participantsExpirationTimer);
|
clearTimeout(this.participantsExpirationTimer);
|
||||||
this.participantsExpirationTimer = null;
|
this.participantsExpirationTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.terminationTimer !== null) {
|
||||||
|
clearTimeout(this.terminationTimer);
|
||||||
|
this.terminationTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
super.destroy();
|
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 (
|
if (
|
||||||
(state === ConnectionState.Connected && !isConnected(prevState))
|
(state === ConnectionState.Connected && !isConnected(prevState))
|
||||||
|| (state === ConnectionState.Disconnected && 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>) => {
|
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||||
|
|
|
@ -156,6 +156,16 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
||||||
return this.calls.get(roomId) ?? null;
|
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 onRoom = (room: Room) => this.updateRoom(room);
|
||||||
|
|
||||||
private onRoomState = (event: MatrixEvent, state: RoomState) => {
|
private onRoomState = (event: MatrixEvent, state: RoomState) => {
|
||||||
|
|
|
@ -50,6 +50,7 @@ import SettingsStore from "../settings/SettingsStore";
|
||||||
import { SlidingSyncManager } from "../SlidingSyncManager";
|
import { SlidingSyncManager } from "../SlidingSyncManager";
|
||||||
import { awaitRoomDownSync } from "../utils/RoomUpgrade";
|
import { awaitRoomDownSync } from "../utils/RoomUpgrade";
|
||||||
import { UPDATE_EVENT } from "./AsyncStore";
|
import { UPDATE_EVENT } from "./AsyncStore";
|
||||||
|
import { CallStore } from "./CallStore";
|
||||||
|
|
||||||
const NUM_JOIN_RETRY = 5;
|
const NUM_JOIN_RETRY = 5;
|
||||||
|
|
||||||
|
@ -286,6 +287,8 @@ export class RoomViewStore extends EventEmitter {
|
||||||
|
|
||||||
private async viewRoom(payload: ViewRoomPayload): Promise<void> {
|
private async viewRoom(payload: ViewRoomPayload): Promise<void> {
|
||||||
if (payload.room_id) {
|
if (payload.room_id) {
|
||||||
|
const room = MatrixClientPeg.get().getRoom(payload.room_id);
|
||||||
|
|
||||||
if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) {
|
if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) {
|
||||||
let activeSpace: ViewRoomEvent["activeSpace"];
|
let activeSpace: ViewRoomEvent["activeSpace"];
|
||||||
if (SpaceStore.instance.activeSpace === MetaSpace.Home) {
|
if (SpaceStore.instance.activeSpace === MetaSpace.Home) {
|
||||||
|
@ -303,10 +306,11 @@ export class RoomViewStore extends EventEmitter {
|
||||||
trigger: payload.metricsTrigger,
|
trigger: payload.metricsTrigger,
|
||||||
viaKeyboard: payload.metricsViaKeyboard,
|
viaKeyboard: payload.metricsViaKeyboard,
|
||||||
isDM: !!DMRoomMap.shared().getUserIdForRoomId(payload.room_id),
|
isDM: !!DMRoomMap.shared().getUserIdForRoomId(payload.room_id),
|
||||||
isSpace: MatrixClientPeg.get().getRoom(payload.room_id)?.isSpaceRoom(),
|
isSpace: room?.isSpaceRoom(),
|
||||||
activeSpace,
|
activeSpace,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) {
|
if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) {
|
||||||
if (this.state.subscribingRoomId && this.state.subscribingRoomId !== 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.
|
// 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 ?? [],
|
viaServers: payload.via_servers ?? [],
|
||||||
wasContextSwitch: payload.context_switch ?? false,
|
wasContextSwitch: payload.context_switch ?? false,
|
||||||
viewingCall: payload.view_call ?? (
|
viewingCall: payload.view_call ?? (
|
||||||
// Reset to false when switching rooms
|
payload.room_id === this.state.roomId
|
||||||
payload.room_id === this.state.roomId ? this.state.viewingCall : false
|
? 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 React from "react";
|
||||||
import { zip } from "lodash";
|
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 { mocked, Mocked } from "jest-mock";
|
||||||
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
|
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
@ -28,14 +28,18 @@ import type { ClientWidgetApi } from "matrix-widget-api";
|
||||||
import {
|
import {
|
||||||
stubClient,
|
stubClient,
|
||||||
mkRoomMember,
|
mkRoomMember,
|
||||||
MockedCall,
|
wrapInMatrixClientContext,
|
||||||
useMockedCalls,
|
useMockedCalls,
|
||||||
|
MockedCall,
|
||||||
setupAsyncStoreWithClient,
|
setupAsyncStoreWithClient,
|
||||||
} from "../../../test-utils";
|
} from "../../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
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 { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
||||||
import { CallStore } from "../../../../src/stores/CallStore";
|
import { CallStore } from "../../../../src/stores/CallStore";
|
||||||
|
import { Call, ConnectionState } from "../../../../src/models/Call";
|
||||||
|
|
||||||
|
const CallView = wrapInMatrixClientContext(_CallView);
|
||||||
|
|
||||||
describe("CallLobby", () => {
|
describe("CallLobby", () => {
|
||||||
useMockedCalls();
|
useMockedCalls();
|
||||||
|
@ -49,8 +53,6 @@ describe("CallLobby", () => {
|
||||||
|
|
||||||
let client: Mocked<MatrixClient>;
|
let client: Mocked<MatrixClient>;
|
||||||
let room: Room;
|
let room: Room;
|
||||||
let call: MockedCall;
|
|
||||||
let widget: Widget;
|
|
||||||
let alice: RoomMember;
|
let alice: RoomMember;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -71,61 +73,122 @@ describe("CallLobby", () => {
|
||||||
|
|
||||||
setupAsyncStoreWithClient(CallStore.instance, client);
|
setupAsyncStoreWithClient(CallStore.instance, client);
|
||||||
setupAsyncStoreWithClient(WidgetMessagingStore.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(() => {
|
afterEach(() => {
|
||||||
call.destroy();
|
|
||||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderLobby = async (): Promise<void> => {
|
const renderView = async (): Promise<void> => {
|
||||||
render(<CallLobby room={room} call={call} />);
|
render(<CallView room={room} resizing={false} waitForCall={false} />);
|
||||||
await act(() => Promise.resolve()); // Let effects settle
|
await act(() => Promise.resolve()); // Let effects settle
|
||||||
};
|
};
|
||||||
|
|
||||||
it("tracks participants", async () => {
|
describe("with an existing call", () => {
|
||||||
const bob = mkRoomMember(room.roomId, "@bob:example.org");
|
let call: MockedCall;
|
||||||
const carol = mkRoomMember(room.roomId, "@carol:example.org");
|
let widget: Widget;
|
||||||
|
|
||||||
const expectAvatars = (userIds: string[]) => {
|
beforeEach(() => {
|
||||||
const avatars = screen.queryAllByRole("button", { name: "Avatar" });
|
MockedCall.create(room, "1");
|
||||||
expect(userIds.length).toBe(avatars.length);
|
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)) {
|
widget = new Widget(call.widget);
|
||||||
fireEvent.focus(avatar!);
|
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
|
||||||
screen.getByRole("tooltip", { name: userId });
|
stop: () => {},
|
||||||
}
|
} as unknown as ClientWidgetApi);
|
||||||
};
|
});
|
||||||
|
|
||||||
await renderLobby();
|
afterEach(() => {
|
||||||
expect(screen.queryByLabelText(/joined/)).toBe(null);
|
cleanup(); // Unmount before we do any cleanup that might update the component
|
||||||
expectAvatars([]);
|
call.destroy();
|
||||||
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
|
});
|
||||||
|
|
||||||
act(() => { call.participants = new Set([alice]); });
|
it("calls clean on mount", async () => {
|
||||||
screen.getByText("1 person joined");
|
const cleanSpy = jest.spyOn(call, "clean");
|
||||||
expectAvatars([alice.userId]);
|
await renderView();
|
||||||
|
expect(cleanSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
act(() => { call.participants = new Set([alice, bob, carol]); });
|
it("shows lobby and keeps widget loaded when disconnected", async () => {
|
||||||
screen.getByText("3 people joined");
|
await renderView();
|
||||||
expectAvatars([alice.userId, bob.userId, carol.userId]);
|
screen.getByRole("button", { name: "Join" });
|
||||||
|
screen.getAllByText(/\bwidget\b/i);
|
||||||
|
});
|
||||||
|
|
||||||
act(() => { call.participants = new Set(); });
|
it("only shows widget when connected", async () => {
|
||||||
expect(screen.queryByLabelText(/joined/)).toBe(null);
|
await renderView();
|
||||||
expectAvatars([]);
|
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", () => {
|
describe("device buttons", () => {
|
||||||
it("hide when no devices are available", async () => {
|
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: /microphone/ })).toBe(null);
|
||||||
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
|
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
|
||||||
});
|
});
|
||||||
|
@ -139,7 +202,7 @@ describe("CallLobby", () => {
|
||||||
toJSON: () => {},
|
toJSON: () => {},
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
await renderLobby();
|
await renderView();
|
||||||
screen.getByRole("button", { name: /camera/ });
|
screen.getByRole("button", { name: /camera/ });
|
||||||
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
|
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
|
||||||
});
|
});
|
||||||
|
@ -162,20 +225,11 @@ describe("CallLobby", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await renderLobby();
|
await renderView();
|
||||||
screen.getByRole("button", { name: /microphone/ });
|
screen.getByRole("button", { name: /microphone/ });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
||||||
screen.getByRole("menuitem", { name: "Headphones" });
|
screen.getByRole("menuitem", { name: "Headphones" });
|
||||||
screen.getByRole("menuitem", { name: "Audio input 2" });
|
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, "getAudioInput").mockReturnValue("1");
|
||||||
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
|
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
|
||||||
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
|
const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
|
||||||
settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined,
|
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||||
|
settingName => enabledSettings.has(settingName) || undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const setUpClientRoomAndStores = (roomType: RoomType): {
|
const setUpClientRoomAndStores = (): {
|
||||||
client: Mocked<MatrixClient>;
|
client: Mocked<MatrixClient>;
|
||||||
room: Room;
|
room: Room;
|
||||||
alice: RoomMember;
|
alice: RoomMember;
|
||||||
|
@ -68,7 +69,6 @@ const setUpClientRoomAndStores = (roomType: RoomType): {
|
||||||
const room = new Room("!1:example.org", client, "@alice:example.org", {
|
const room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||||
});
|
});
|
||||||
jest.spyOn(room, "getType").mockReturnValue(roomType);
|
|
||||||
|
|
||||||
const alice = mkRoomMember(room.roomId, "@alice:example.org");
|
const alice = mkRoomMember(room.roomId, "@alice:example.org");
|
||||||
const bob = mkRoomMember(room.roomId, "@bob:example.org");
|
const bob = mkRoomMember(room.roomId, "@bob:example.org");
|
||||||
|
@ -165,7 +165,8 @@ describe("JitsiCall", () => {
|
||||||
let carol: RoomMember;
|
let carol: RoomMember;
|
||||||
|
|
||||||
beforeEach(() => {
|
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));
|
afterEach(() => cleanUpClientRoomAndStores(client, room));
|
||||||
|
@ -191,7 +192,7 @@ describe("JitsiCall", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("instance", () => {
|
describe("instance in a video room", () => {
|
||||||
let call: JitsiCall;
|
let call: JitsiCall;
|
||||||
let widget: Widget;
|
let widget: Widget;
|
||||||
let messaging: Mocked<ClientWidgetApi>;
|
let messaging: Mocked<ClientWidgetApi>;
|
||||||
|
@ -542,7 +543,7 @@ describe("ElementCall", () => {
|
||||||
let carol: RoomMember;
|
let carol: RoomMember;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall));
|
({ client, room, alice, bob, carol } = setUpClientRoomAndStores());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => cleanUpClientRoomAndStores(client, room));
|
afterEach(() => cleanUpClientRoomAndStores(client, room));
|
||||||
|
@ -569,7 +570,7 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("instance", () => {
|
describe("instance in a non-video room", () => {
|
||||||
let call: ElementCall;
|
let call: ElementCall;
|
||||||
let widget: Widget;
|
let widget: Widget;
|
||||||
let messaging: Mocked<ClientWidgetApi>;
|
let messaging: Mocked<ClientWidgetApi>;
|
||||||
|
@ -590,6 +591,10 @@ describe("ElementCall", () => {
|
||||||
|
|
||||||
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
||||||
|
|
||||||
|
it("has intent m.prompt", () => {
|
||||||
|
expect(call.groupCall.getContent()["m.intent"]).toBe("m.prompt");
|
||||||
|
});
|
||||||
|
|
||||||
it("connects muted", async () => {
|
it("connects muted", async () => {
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
audioMutedSpy.mockReturnValue(true);
|
audioMutedSpy.mockReturnValue(true);
|
||||||
|
@ -747,6 +752,59 @@ describe("ElementCall", () => {
|
||||||
expect(events).toEqual([new Set([alice]), new Set()]);
|
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", () => {
|
describe("clean", () => {
|
||||||
const aliceWeb: IMyDevice = {
|
const aliceWeb: IMyDevice = {
|
||||||
device_id: "aliceweb",
|
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
import { mkEvent } from "./test-utils";
|
import { mkEvent } from "./test-utils";
|
||||||
import { Call } from "../../src/models/Call";
|
import { Call, ElementCall, JitsiCall } from "../../src/models/Call";
|
||||||
|
|
||||||
export class MockedCall extends Call {
|
export class MockedCall extends Call {
|
||||||
private static EVENT_TYPE = "org.example.mocked_call";
|
private static EVENT_TYPE = "org.example.mocked_call";
|
||||||
|
@ -91,4 +91,6 @@ export class MockedCall extends Call {
|
||||||
*/
|
*/
|
||||||
export const useMockedCalls = () => {
|
export const useMockedCalls = () => {
|
||||||
Call.get = room => MockedCall.get(room);
|
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 New Issue