Prepare for Element Call integration (#9224)

* Improve accessibility and testability of Tooltip

Adding a role to Tooltip was motivated by React Testing Library's
reliance on accessibility-related attributes to locate elements.

* Make the ReadyWatchingStore constructor safer

The ReadyWatchingStore constructor previously had a chance to
immediately call onReady, which was dangerous because it was potentially
calling the derived class's onReady at a point when the derived class
hadn't even finished construction yet. In normal usage, I guess this
never was a problem, but it was causing some of the tests I was writing
to crash. This is solved by separating out the onReady call into a start
method.

* Rename 1:1 call components to 'LegacyCall'

to reflect the fact that they're slated for removal, and to not clash
with the new Call code.

* Refactor VideoChannelStore into Call and CallStore

Call is an abstract class that currently only has a Jitsi
implementation, but this will make it easy to later add an Element Call
implementation.

* Remove WidgetReady, ClientReady, and ForceHangupCall hacks

These are no longer used by the new Jitsi call implementation, and can
be removed.

* yarn i18n

* Delete call map entries instead of inserting nulls

* Allow multiple active calls and consolidate call listeners

* Fix a race condition when creating a video room

* Un-hardcode the media device fallback labels

* Apply misc code review fixes

* yarn i18n

* Disconnect from calls more politely on logout

* Fix some strict mode errors

* Fix another updateRoom race condition
This commit is contained in:
Robin 2022-08-30 15:13:39 -04:00 committed by GitHub
parent 50f6986f6c
commit 0d6a550c33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2573 additions and 2157 deletions

View file

@ -117,6 +117,10 @@ module.exports = {
"@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
// The non-TypeScript rule produces false positives
"func-call-spacing": "off",
"@typescript-eslint/func-call-spacing": ["error"],
},
},
// temporary override for offending icon require files

View file

@ -752,7 +752,7 @@ legend {
cursor: pointer;
}
@define-mixin CallButton {
@define-mixin LegacyCallButton {
box-sizing: border-box;
font-weight: 600;
height: $font-24px;

View file

@ -97,9 +97,9 @@
@import "./views/avatars/_DecoratedRoomAvatar.pcss";
@import "./views/avatars/_WidgetAvatar.pcss";
@import "./views/beta/_BetaCard.pcss";
@import "./views/context_menus/_CallContextMenu.pcss";
@import "./views/context_menus/_DeviceContextMenu.pcss";
@import "./views/context_menus/_IconizedContextMenu.pcss";
@import "./views/context_menus/_LegacyCallContextMenu.pcss";
@import "./views/context_menus/_MessageContextMenu.pcss";
@import "./views/context_menus/_RoomGeneralContextMenu.pcss";
@import "./views/context_menus/_RoomNotificationContextMenu.pcss";
@ -207,13 +207,13 @@
@import "./views/elements/_Validation.pcss";
@import "./views/emojipicker/_EmojiPicker.pcss";
@import "./views/location/_LocationPicker.pcss";
@import "./views/messages/_CallEvent.pcss";
@import "./views/messages/_CreateEvent.pcss";
@import "./views/messages/_DateSeparator.pcss";
@import "./views/messages/_DisambiguatedProfile.pcss";
@import "./views/messages/_EventTileBubble.pcss";
@import "./views/messages/_HiddenBody.pcss";
@import "./views/messages/_JumpToDatePicker.pcss";
@import "./views/messages/_LegacyCallEvent.pcss";
@import "./views/messages/_MEmoteBody.pcss";
@import "./views/messages/_MFileBody.pcss";
@import "./views/messages/_MImageBody.pcss";
@ -282,13 +282,13 @@
@import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomTileCallSummary.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
@import "./views/rooms/_SearchBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss";
@import "./views/rooms/_Stickers.pcss";
@import "./views/rooms/_ThreadSummary.pcss";
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
@import "./views/rooms/_VideoRoomSummary.pcss";
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
@import "./views/rooms/_WhoIsTypingTile.pcss";
@import "./views/settings/_AvatarSetting.pcss";
@ -333,7 +333,7 @@
@import "./views/spaces/_SpacePublicShare.pcss";
@import "./views/terms/_InlineTermsAgreement.pcss";
@import "./views/toasts/_AnalyticsToast.pcss";
@import "./views/toasts/_IncomingCallToast.pcss";
@import "./views/toasts/_IncomingLegacyCallToast.pcss";
@import "./views/toasts/_NonUrgentEchoFailureToast.pcss";
@import "./views/typography/_Heading.pcss";
@import "./views/user-onboarding/_UserOnboardingButton.pcss";
@ -343,15 +343,15 @@
@import "./views/user-onboarding/_UserOnboardingPage.pcss";
@import "./views/user-onboarding/_UserOnboardingTask.pcss";
@import "./views/verification/_VerificationShowSas.pcss";
@import "./views/voip/CallView/_CallViewButtons.pcss";
@import "./views/voip/_CallPreview.pcss";
@import "./views/voip/_CallView.pcss";
@import "./views/voip/_CallViewForRoom.pcss";
@import "./views/voip/_CallViewHeader.pcss";
@import "./views/voip/_CallViewSidebar.pcss";
@import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss";
@import "./views/voip/_CallLobby.pcss";
@import "./views/voip/_DialPad.pcss";
@import "./views/voip/_DialPadContextMenu.pcss";
@import "./views/voip/_DialPadModal.pcss";
@import "./views/voip/_LegacyCallPreview.pcss";
@import "./views/voip/_LegacyCallView.pcss";
@import "./views/voip/_LegacyCallViewForRoom.pcss";
@import "./views/voip/_LegacyCallViewHeader.pcss";
@import "./views/voip/_LegacyCallViewSidebar.pcss";
@import "./views/voip/_PiPContainer.pcss";
@import "./views/voip/_VideoFeed.pcss";
@import "./views/voip/_VideoLobby.pcss";

View file

@ -34,7 +34,7 @@ limitations under the License.
}
/* While the lobby is shown, the widget needs to stay loaded but hidden in the background */
.mx_VideoLobby ~ .mx_AppTile {
.mx_CallLobby ~ .mx_AppTile {
display: none;
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallContextMenu_item {
.mx_LegacyCallContextMenu_item {
width: 205px;
height: 40px;
padding-left: 16px;

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallEvent_wrapper {
.mx_LegacyCallEvent_wrapper {
display: flex;
width: 100%;
.mx_CallEvent {
.mx_LegacyCallEvent {
display: flex;
flex-direction: row;
flex-wrap: wrap;
@ -35,7 +35,7 @@ limitations under the License.
width: 65%;
height: fit-content;
.mx_CallEvent_iconButton {
.mx_LegacyCallEvent_iconButton {
display: inline-flex;
&::before {
@ -50,74 +50,74 @@ limitations under the License.
}
}
.mx_CallEvent_silence::before {
.mx_LegacyCallEvent_silence::before {
mask-image: url('$(res)/img/voip/silence.svg');
}
.mx_CallEvent_unSilence::before {
.mx_LegacyCallEvent_unSilence::before {
mask-image: url('$(res)/img/voip/un-silence.svg');
}
&.mx_CallEvent_voice {
.mx_CallEvent_type_icon::before,
.mx_CallEvent_content_button_callBack span::before,
.mx_CallEvent_content_button_answer span::before {
&.mx_LegacyCallEvent_voice {
.mx_LegacyCallEvent_type_icon::before,
.mx_LegacyCallEvent_content_button_callBack span::before,
.mx_LegacyCallEvent_content_button_answer span::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
&.mx_CallEvent_rejected,
&.mx_CallEvent_noAnswer {
.mx_CallEvent_type_icon::before {
&.mx_LegacyCallEvent_rejected,
&.mx_LegacyCallEvent_noAnswer {
.mx_LegacyCallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/declined-voice.svg');
}
}
}
&.mx_CallEvent_video {
.mx_CallEvent_type_icon::before,
.mx_CallEvent_content_button_callBack span::before,
.mx_CallEvent_content_button_answer span::before {
&.mx_LegacyCallEvent_video {
.mx_LegacyCallEvent_type_icon::before,
.mx_LegacyCallEvent_content_button_callBack span::before,
.mx_LegacyCallEvent_content_button_answer span::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
&.mx_CallEvent_rejected,
&.mx_CallEvent_noAnswer {
.mx_CallEvent_type_icon::before {
&.mx_LegacyCallEvent_rejected,
&.mx_LegacyCallEvent_noAnswer {
.mx_LegacyCallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/declined-video.svg');
}
}
}
&.mx_CallEvent_missed {
&.mx_CallEvent_voice {
.mx_CallEvent_type_icon::before {
&.mx_LegacyCallEvent_missed {
&.mx_LegacyCallEvent_voice {
.mx_LegacyCallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/missed-voice.svg');
}
}
&.mx_CallEvent_video {
.mx_CallEvent_type_icon::before {
&.mx_LegacyCallEvent_video {
.mx_LegacyCallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/missed-video.svg');
}
}
}
.mx_CallEvent_info {
.mx_LegacyCallEvent_info {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
max-width: 100%;
.mx_CallEvent_info_basic {
.mx_LegacyCallEvent_info_basic {
display: flex;
flex-direction: column;
gap: $spacing-4;
margin-left: 10px; /* To match mx_CallEvent */
margin-left: 10px; /* To match mx_LegacyCallEvent */
margin-right: 10px;
min-width: 0;
.mx_CallEvent_sender {
.mx_LegacyCallEvent_sender {
font-weight: 600;
font-size: 1.5rem;
line-height: 1.8rem;
@ -128,7 +128,7 @@ limitations under the License.
text-overflow: ellipsis;
}
.mx_CallEvent_type {
.mx_LegacyCallEvent_type {
display: flex;
align-items: center;
font-weight: 400;
@ -136,7 +136,7 @@ limitations under the License.
font-size: 1.2rem;
line-height: $font-13px;
.mx_CallEvent_type_icon {
.mx_LegacyCallEvent_type_icon {
height: 13px;
width: 13px;
margin-right: 5px;
@ -155,18 +155,18 @@ limitations under the License.
}
}
.mx_CallEvent_content {
.mx_LegacyCallEvent_content {
display: flex;
flex-wrap: wrap;
align-items: center;
color: $secondary-content;
gap: $spacing-12; /* See mx_IncomingCallToast_buttons */
margin-inline-start: 42px; /* avatar (32px) + mx_CallEvent_info_basic margin (10px) */
gap: $spacing-12; /* See mx_IncomingLegacyCallToast_buttons */
margin-inline-start: 42px; /* avatar (32px) + mx_LegacyCallEvent_info_basic margin (10px) */
word-break: break-word;
max-width: fit-content;
.mx_CallEvent_content_button {
@mixin CallButton;
.mx_LegacyCallEvent_content_button {
@mixin LegacyCallButton;
padding: 0 $spacing-12;
span::before {
@ -177,25 +177,25 @@ limitations under the License.
}
}
.mx_CallEvent_content_button_reject {
.mx_LegacyCallEvent_content_button_reject {
span::before {
mask-image: url('$(res)/img/element-icons/call/hangup.svg');
}
}
.mx_CallEvent_content_tooltip {
.mx_LegacyCallEvent_content_tooltip {
margin-right: 5px;
}
}
&.mx_CallEvent_narrow {
&.mx_LegacyCallEvent_narrow {
flex-direction: column;
align-items: unset;
gap: $spacing-4 $spacing-16;
height: unset;
min-width: 290px;
.mx_CallEvent_iconButton {
.mx_LegacyCallEvent_iconButton {
position: absolute;
margin-right: 0;
top: 12px;
@ -205,7 +205,7 @@ limitations under the License.
display: flex;
}
.mx_CallEvent_info {
.mx_LegacyCallEvent_info {
align-items: unset;
}
}
@ -213,8 +213,8 @@ limitations under the License.
}
.mx_EventTile[data-layout="bubble"] {
.mx_EventTile_e2eIcon + .mx_CallEvent_wrapper {
.mx_CallEvent {
.mx_EventTile_e2eIcon + .mx_LegacyCallEvent_wrapper {
.mx_LegacyCallEvent {
position: relative;
/* 5px (gap) + 14px (e2e icon size * mask-size) + 9px (margin-left of e2e icon) */
@ -224,9 +224,9 @@ limitations under the License.
}
.mx_EventTile_leftAlignedBubble {
.mx_CallEvent_wrapper {
.mx_CallEvent {
&.mx_CallEvent_narrow {
.mx_LegacyCallEvent_wrapper {
.mx_LegacyCallEvent {
&.mx_LegacyCallEvent_narrow {
gap: $spacing-8 $spacing-4;
}
}
@ -234,8 +234,8 @@ limitations under the License.
}
.mx_IRCLayout {
.mx_CallEvent_wrapper {
.mx_CallEvent {
.mx_LegacyCallEvent_wrapper {
.mx_LegacyCallEvent {
margin-inline-start: $spacing-4; /* display green line */
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VideoRoomSummary {
.mx_VideoRoomSummary_indicator {
.mx_RoomTileCallSummary {
.mx_RoomTileCallSummary_text {
&::before {
display: inline-block;
vertical-align: text-bottom;
@ -28,7 +28,7 @@ limitations under the License.
margin-right: 4px;
}
&.mx_VideoRoomSummary_indicator_active {
&.mx_RoomTileCallSummary_text_active {
color: $accent;
&::before {
@ -37,7 +37,7 @@ limitations under the License.
}
}
.mx_VideoRoomSummary_participants::before {
.mx_RoomTileCallSummary_participants::before {
display: inline-block;
vertical-align: text-bottom;
content: '';

View file

@ -15,17 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_IncomingCallToast {
.mx_IncomingLegacyCallToast {
display: flex;
flex-direction: row;
pointer-events: initial; /* restore pointer events so the user can accept/decline */
.mx_IncomingCallToast_content {
.mx_IncomingLegacyCallToast_content {
display: flex;
flex-direction: column;
margin-left: 8px;
.mx_CallEvent_caller {
.mx_LegacyCallEvent_caller {
font-weight: bold;
font-size: $font-15px;
line-height: $font-18px;
@ -40,7 +40,7 @@ limitations under the License.
max-width: 200px;
}
.mx_CallEvent_type {
.mx_LegacyCallEvent_type {
font-size: $font-12px;
line-height: $font-15px;
color: $tertiary-content;
@ -52,7 +52,7 @@ limitations under the License.
flex-direction: row;
align-items: center;
.mx_CallEvent_type_icon {
.mx_LegacyCallEvent_type_icon {
height: 16px;
width: 16px;
margin-right: 6px;
@ -69,28 +69,28 @@ limitations under the License.
}
}
&.mx_IncomingCallToast_content_voice {
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
&.mx_IncomingLegacyCallToast_content_voice {
.mx_LegacyCallEvent_type .mx_LegacyCallEvent_type_icon::before,
.mx_IncomingLegacyCallToast_buttons .mx_IncomingLegacyCallToast_button_accept span::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}
&.mx_IncomingCallToast_content_video {
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
&.mx_IncomingLegacyCallToast_content_video {
.mx_LegacyCallEvent_type .mx_LegacyCallEvent_type_icon::before,
.mx_IncomingLegacyCallToast_buttons .mx_IncomingLegacyCallToast_button_accept span::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}
.mx_IncomingCallToast_buttons {
.mx_IncomingLegacyCallToast_buttons {
margin-top: 8px;
display: flex;
flex-direction: row;
gap: 12px;
.mx_IncomingCallToast_button {
@mixin CallButton;
.mx_IncomingLegacyCallToast_button {
@mixin LegacyCallButton;
padding: 0px 8px;
flex-shrink: 0;
flex-grow: 1;
@ -100,13 +100,13 @@ limitations under the License.
padding: 8px 0;
}
&.mx_IncomingCallToast_button_accept span::before {
&.mx_IncomingLegacyCallToast_button_accept span::before {
mask-size: 13px;
width: 13px;
height: 13px;
}
&.mx_IncomingCallToast_button_decline span::before {
&.mx_IncomingLegacyCallToast_button_decline span::before {
mask-image: url('$(res)/img/element-icons/call/hangup.svg');
mask-size: 16px;
width: 16px;
@ -116,7 +116,7 @@ limitations under the License.
}
}
.mx_IncomingCallToast_iconButton {
.mx_IncomingLegacyCallToast_iconButton {
display: flex;
height: 20px;
width: 20px;
@ -133,11 +133,11 @@ limitations under the License.
}
}
.mx_IncomingCallToast_silence::before {
.mx_IncomingLegacyCallToast_silence::before {
mask-image: url('$(res)/img/voip/silence.svg');
}
.mx_IncomingCallToast_unSilence::before {
.mx_IncomingLegacyCallToast_unSilence::before {
mask-image: url('$(res)/img/voip/un-silence.svg');
}
}

View file

@ -18,13 +18,13 @@ limitations under the License.
/* data-whatintent makes more sense here semantically but then the tooltip would stay visible without the button */
/* which looks broken, so we match the behaviour of tooltips which is fine too. */
[data-whatinput="mouse"] .mx_CallViewButtons.mx_CallViewButtons_hidden {
[data-whatinput="mouse"] .mx_LegacyCallViewButtons.mx_LegacyCallViewButtons_hidden {
opacity: 0.001; /* opacity 0 can cause a re-layout */
pointer-events: none;
}
.mx_CallViewButtons {
--CallViewButtons_dropdownButton-size: 16px;
.mx_LegacyCallViewButtons {
--LegacyCallViewButtons_dropdownButton-size: 16px;
position: absolute;
display: flex;
@ -35,7 +35,7 @@ limitations under the License.
z-index: 200; /* To be above _all_ feeds */
gap: 18px;
.mx_CallViewButtons_button {
.mx_LegacyCallViewButtons_button {
cursor: pointer;
background-color: $call-view-button-on-background;
@ -66,9 +66,9 @@ limitations under the License.
width: 24px;
}
&.mx_CallViewButtons_dropdownButton {
width: var(--CallViewButtons_dropdownButton-size);
height: var(--CallViewButtons_dropdownButton-size);
&.mx_LegacyCallViewButtons_dropdownButton {
width: var(--LegacyCallViewButtons_dropdownButton-size);
height: var(--LegacyCallViewButtons_dropdownButton-size);
position: absolute;
right: 0;
@ -80,28 +80,28 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/message/chevron-up.svg');
}
&.mx_CallViewButtons_dropdownButton_collapsed::before {
&.mx_LegacyCallViewButtons_dropdownButton_collapsed::before {
transform: rotate(180deg);
}
}
/* State buttons */
&.mx_CallViewButtons_button_on {
&.mx_LegacyCallViewButtons_button_on {
background-color: $call-view-button-on-background;
&::before {
background-color: $call-view-button-on-foreground;
}
&.mx_CallViewButtons_button_mic::before {
&.mx_LegacyCallViewButtons_button_mic::before {
mask-image: url('$(res)/img/voip/call-view/mic-on.svg');
}
&.mx_CallViewButtons_button_vid::before {
&.mx_LegacyCallViewButtons_button_vid::before {
mask-image: url('$(res)/img/voip/call-view/cam-on.svg');
}
&.mx_CallViewButtons_button_screensharing {
&.mx_LegacyCallViewButtons_button_screensharing {
background-color: $accent;
&::before {
@ -110,27 +110,27 @@ limitations under the License.
}
}
&.mx_CallViewButtons_button_sidebar::before {
&.mx_LegacyCallViewButtons_button_sidebar::before {
mask-image: url('$(res)/img/voip/call-view/sidebar-on.svg');
}
}
&.mx_CallViewButtons_button_off {
&.mx_LegacyCallViewButtons_button_off {
background-color: $call-view-button-off-background;
&::before {
background-color: $call-view-button-off-foreground;
}
&.mx_CallViewButtons_button_mic::before {
&.mx_LegacyCallViewButtons_button_mic::before {
mask-image: url('$(res)/img/voip/call-view/mic-off.svg');
}
&.mx_CallViewButtons_button_vid::before {
&.mx_LegacyCallViewButtons_button_vid::before {
mask-image: url('$(res)/img/voip/call-view/cam-off.svg');
}
&.mx_CallViewButtons_button_screensharing {
&.mx_LegacyCallViewButtons_button_screensharing {
background-color: $call-view-button-on-background;
&::before {
@ -139,7 +139,7 @@ limitations under the License.
}
}
&.mx_CallViewButtons_button_sidebar {
&.mx_LegacyCallViewButtons_button_sidebar {
background-color: $call-view-button-on-background;
&::before {
@ -151,11 +151,11 @@ limitations under the License.
/* State buttons */
/* Stateless buttons */
&.mx_CallViewButtons_dialpad::before {
&.mx_LegacyCallViewButtons_dialpad::before {
mask-image: url('$(res)/img/voip/call-view/dialpad.svg');
}
&.mx_CallViewButtons_button_hangup {
&.mx_LegacyCallViewButtons_button_hangup {
background-color: $alert;
&::before {
@ -164,13 +164,13 @@ limitations under the License.
}
}
&.mx_CallViewButtons_button_more::before {
&.mx_LegacyCallViewButtons_button_more::before {
mask-image: url('$(res)/img/voip/call-view/more.svg');
}
/* Stateless buttons */
/* Invisible state */
&.mx_CallViewButtons_button_invisible {
&.mx_LegacyCallViewButtons_button_invisible {
visibility: hidden;
pointer-events: none;
position: absolute;

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VideoLobby {
.mx_CallLobby {
min-height: 0;
flex-grow: 1;
padding: $spacing-12;
color: $video-lobby-primary-content;
background-color: $video-lobby-background;
color: $call-lobby-primary-content;
background-color: $call-lobby-background;
border-radius: 8px;
display: flex;
@ -33,16 +33,16 @@ limitations under the License.
margin: $spacing-8 auto 0;
.mx_FacePile_faces .mx_BaseAvatar_image {
border-color: $video-lobby-background;
border-color: $call-lobby-background;
}
}
.mx_VideoLobby_preview {
.mx_CallLobby_preview {
position: relative;
width: 100%;
max-width: 800px;
aspect-ratio: 1.5;
background-color: $video-lobby-system;
background-color: $call-lobby-system;
border-radius: 20px;
overflow: hidden;
@ -74,29 +74,29 @@ limitations under the License.
background-color: black;
}
.mx_VideoLobby_controls {
.mx_CallLobby_controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: rgba($video-lobby-background, 0.9);
background-color: rgba($call-lobby-background, 0.9);
display: flex;
justify-content: center;
gap: $spacing-24;
.mx_VideoLobby_deviceButtonWrapper {
.mx_CallLobby_deviceButtonWrapper {
position: relative;
margin: 6px 0 10px;
.mx_VideoLobby_deviceButton {
.mx_CallLobby_deviceButton {
$size: 50px;
width: $size;
height: $size;
background-color: $video-lobby-primary-content;
background-color: $call-lobby-system;
border-radius: calc($size / 2);
&::before {
@ -105,21 +105,21 @@ limitations under the License.
mask-repeat: no-repeat;
mask-size: 20px;
mask-position: center;
background-color: $video-lobby-system;
background-color: $call-lobby-primary-content;
height: 100%;
width: 100%;
}
&.mx_VideoLobby_deviceButton_audio::before {
mask-image: url('$(res)/img/voip/call-view/mic-off.svg');
&.mx_CallLobby_deviceButton_audio::before {
mask-image: url('$(res)/img/voip/call-view/mic-on.svg');
}
&.mx_VideoLobby_deviceButton_video::before {
mask-image: url('$(res)/img/voip/call-view/cam-off.svg');
&.mx_CallLobby_deviceButton_video::before {
mask-image: url('$(res)/img/voip/call-view/cam-on.svg');
}
}
.mx_VideoLobby_deviceListButton {
.mx_CallLobby_deviceListButton {
$size: 15px;
position: absolute;
@ -128,7 +128,7 @@ limitations under the License.
width: $size;
height: $size;
background-color: $video-lobby-primary-content;
background-color: $call-lobby-system;
border-radius: calc($size / 2);
&::before {
@ -137,29 +137,29 @@ limitations under the License.
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
mask-size: $size;
mask-position: center;
background-color: $video-lobby-system;
background-color: $call-lobby-primary-content;
height: 100%;
width: 100%;
}
}
&.mx_VideoLobby_deviceButtonWrapper_active {
.mx_VideoLobby_deviceButton,
.mx_VideoLobby_deviceListButton {
background-color: $video-lobby-system;
&.mx_CallLobby_deviceButtonWrapper_muted {
.mx_CallLobby_deviceButton,
.mx_CallLobby_deviceListButton {
background-color: $call-lobby-primary-content;
&::before {
background-color: $video-lobby-primary-content;
background-color: $call-lobby-system;
}
}
.mx_VideoLobby_deviceButton {
&.mx_VideoLobby_deviceButton_audio::before {
mask-image: url('$(res)/img/voip/call-view/mic-on.svg');
.mx_CallLobby_deviceButton {
&.mx_CallLobby_deviceButton_audio::before {
mask-image: url('$(res)/img/voip/call-view/mic-off.svg');
}
&.mx_VideoLobby_deviceButton_video::before {
mask-image: url('$(res)/img/voip/call-view/cam-on.svg');
&.mx_CallLobby_deviceButton_video::before {
mask-image: url('$(res)/img/voip/call-view/cam-off.svg');
}
}
}
@ -167,7 +167,7 @@ limitations under the License.
}
}
.mx_VideoLobby_joinButton {
.mx_CallLobby_connectButton {
padding-left: 50px;
padding-right: 50px;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallPreview {
.mx_LegacyCallPreview {
position: fixed;
left: 0;
top: 0;

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallView {
.mx_LegacyCallView {
border-radius: 8px;
background-color: $dark-panel-bg-color;
padding-left: 8px;
@ -24,7 +24,7 @@ limitations under the License.
/* XXX: PiPContainer sets pointer-events: none - should probably be set back in a better place */
pointer-events: initial;
.mx_CallView_toast {
.mx_LegacyCallView_toast {
position: absolute;
top: 74px;
@ -38,7 +38,7 @@ limitations under the License.
background-color: #17191c;
}
.mx_CallView_content_wrapper {
.mx_LegacyCallView_content_wrapper {
display: flex;
justify-content: center;
@ -47,7 +47,7 @@ limitations under the License.
overflow: hidden;
.mx_CallView_content {
.mx_LegacyCallView_content {
position: relative;
display: flex;
@ -65,12 +65,12 @@ limitations under the License.
background-color: $call-view-content-background;
.mx_CallView_status {
.mx_LegacyCallView_status {
z-index: 50;
color: $accent-fg-color;
}
.mx_CallView_avatarsContainer {
.mx_LegacyCallView_avatarsContainer {
display: flex;
flex-direction: row;
align-items: center;
@ -82,7 +82,7 @@ limitations under the License.
}
}
.mx_CallView_holdBackground {
.mx_LegacyCallView_holdBackground {
position: absolute;
left: 0;
right: 0;
@ -107,7 +107,7 @@ limitations under the License.
}
}
&.mx_CallView_content_hold .mx_CallView_status {
&.mx_LegacyCallView_content_hold .mx_LegacyCallView_status {
font-weight: bold;
text-align: center;
@ -123,7 +123,7 @@ limitations under the License.
background-size: cover;
}
.mx_CallView_pip &::before {
.mx_LegacyCallView_pip &::before {
width: 30px;
height: 30px;
}
@ -131,7 +131,7 @@ limitations under the License.
}
}
&:not(.mx_CallView_sidebar) .mx_CallView_content {
&:not(.mx_LegacyCallView_sidebar) .mx_LegacyCallView_content {
padding: 0;
width: 100%;
height: 100%;
@ -145,7 +145,7 @@ limitations under the License.
}
}
&.mx_CallView_pip {
&.mx_LegacyCallView_pip {
width: 320px;
padding-bottom: 8px;
@ -154,16 +154,16 @@ limitations under the License.
background-color: $system;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2);
.mx_CallViewButtons {
.mx_LegacyCallViewButtons {
bottom: 13px;
.mx_CallViewButtons_button {
.mx_LegacyCallViewButtons_button {
width: 34px;
height: 34px;
&.mx_CallViewButtons_dropdownButton {
width: var(--CallViewButtons_dropdownButton-size);
height: var(--CallViewButtons_dropdownButton-size);
&.mx_LegacyCallViewButtons_dropdownButton {
width: var(--LegacyCallViewButtons_dropdownButton-size);
height: var(--LegacyCallViewButtons_dropdownButton-size);
}
&::before {
@ -173,12 +173,12 @@ limitations under the License.
}
}
.mx_CallView_content {
.mx_LegacyCallView_content {
min-height: 180px;
}
}
&.mx_CallView_large {
&.mx_LegacyCallView_large {
display: flex;
flex-direction: column;
align-items: center;
@ -193,7 +193,7 @@ limitations under the License.
margin-bottom: 10px;
}
&.mx_CallView_belowWidget {
&.mx_LegacyCallView_belowWidget {
margin-top: 0;
}
}

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallViewForRoom {
.mx_LegacyCallViewForRoom {
overflow: hidden;
.mx_CallViewForRoom_ResizeWrapper {
.mx_LegacyCallViewForRoom_ResizeWrapper {
display: flex;
&:hover .mx_CallViewForRoom_ResizeHandle {
&:hover .mx_LegacyCallViewForRoom_ResizeHandle {
/* Need to use important to override element style attributes */
/* set by re-resizable */
width: 100% !important;

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallViewHeader {
.mx_LegacyCallViewHeader {
height: 44px;
display: flex;
flex-direction: row;
@ -24,18 +24,18 @@ limitations under the License.
flex-shrink: 0;
width: 100%;
&.mx_CallViewHeader_pip {
&.mx_LegacyCallViewHeader_pip {
cursor: pointer;
}
}
.mx_CallViewHeader_text {
.mx_LegacyCallViewHeader_text {
font-size: 1.2rem;
font-weight: bold;
vertical-align: middle;
}
.mx_CallViewHeader_secondaryCallInfo {
.mx_LegacyCallViewHeader_secondaryCallInfo {
&::before {
content: '·';
margin-left: 6px;
@ -43,13 +43,13 @@ limitations under the License.
}
}
.mx_CallViewHeader_controls {
.mx_LegacyCallViewHeader_controls {
margin-left: auto;
display: flex;
gap: 5px;
}
.mx_CallViewHeader_button {
.mx_LegacyCallViewHeader_button {
display: inline-block;
vertical-align: middle;
cursor: pointer;
@ -66,32 +66,32 @@ limitations under the License.
mask-position: center;
}
&.mx_CallViewHeader_button_fullscreen {
&.mx_LegacyCallViewHeader_button_fullscreen {
&::before {
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
}
}
&.mx_CallViewHeader_button_pin {
&.mx_LegacyCallViewHeader_button_pin {
&::before {
mask-image: url('$(res)/img/element-icons/room/pin-upright.svg');
}
}
&.mx_CallViewHeader_button_expand {
&.mx_LegacyCallViewHeader_button_expand {
&::before {
mask-image: url('$(res)/img/element-icons/call/expand.svg');
}
}
}
.mx_CallViewHeader_callInfo {
.mx_LegacyCallViewHeader_callInfo {
margin-left: 12px;
margin-right: 16px;
overflow: hidden;
}
.mx_CallViewHeader_roomName {
.mx_LegacyCallViewHeader_roomName {
font-weight: bold;
font-size: 12px;
line-height: initial;
@ -102,11 +102,11 @@ limitations under the License.
white-space: nowrap;
}
.mx_CallView_secondaryCall_roomName {
.mx_LegacyCallView_secondaryCall_roomName {
margin-left: 4px;
}
.mx_CallViewHeader_icon {
.mx_LegacyCallViewHeader_icon {
display: inline-block;
margin-right: 6px;
height: 16px;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallViewSidebar {
.mx_LegacyCallViewSidebar {
position: absolute;
right: 10px;
@ -41,7 +41,7 @@ limitations under the License.
}
}
&.mx_CallViewSidebar_pipMode {
&.mx_LegacyCallViewSidebar_pipMode {
top: 16px;
bottom: unset;
justify-content: flex-end;

View file

@ -188,9 +188,9 @@ $call-view-content-background: $quinary-content;
$video-feed-secondary-background: $system;
$video-lobby-system: $system;
$video-lobby-background: $background;
$video-lobby-primary-content: $primary-content;
$call-lobby-system: $system;
$call-lobby-background: $background;
$call-lobby-primary-content: $primary-content;
/* ******************** */
/* Location sharing */

View file

@ -119,9 +119,9 @@ $call-view-content-background: $quinary-content;
$video-feed-secondary-background: $system;
$video-lobby-system: $system;
$video-lobby-background: $background;
$video-lobby-primary-content: $primary-content;
$call-lobby-system: $system;
$call-lobby-background: $background;
$call-lobby-primary-content: $primary-content;
$roomlist-filter-active-bg-color: $panel-actions;
$roomlist-bg-color: $header-panel-bg-color;

View file

@ -180,9 +180,9 @@ $call-view-content-background: #21262C;
$video-feed-secondary-background: #394049; /* XXX: Color from dark theme */
/* All of these are from dark theme */
$video-lobby-system: #21262C;
$video-lobby-background: #15191E;
$video-lobby-primary-content: #FFFFFF;
$call-lobby-system: #21262C;
$call-lobby-background: #15191E;
$call-lobby-primary-content: #FFFFFF;
$username-variant1-color: #368bd6;
$username-variant2-color: #ac3ba8;

View file

@ -287,9 +287,9 @@ $video-feed-secondary-background: #394049; /* XXX: Color from dark theme */
$voipcall-plinth-color: $system;
/* All of these are from dark theme */
$video-lobby-system: #21262C;
$video-lobby-background: #15191E;
$video-lobby-primary-content: #FFFFFF;
$call-lobby-system: #21262C;
$call-lobby-background: #15191E;
$call-lobby-primary-content: #FFFFFF;
/* ******************** */
/* One-off colors */

View file

@ -33,7 +33,7 @@ import { Notifier } from "../Notifier";
import type { Renderer } from "react-dom";
import RightPanelStore from "../stores/right-panel/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
import LegacyCallHandler from "../LegacyCallHandler";
import UserActivity from "../UserActivity";
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
@ -89,7 +89,7 @@ declare global {
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxCallHandler: CallHandler;
mxLegacyCallHandler: LegacyCallHandler;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;

View file

@ -54,7 +54,7 @@ import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/
import SdkConfig from './SdkConfig';
import { ensureDMExists } from './createRoom';
import { Container, WidgetLayoutStore } from './stores/widgets/WidgetLayoutStore';
import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from './toasts/IncomingLegacyCallToast';
import ToastStore from './stores/ToastStore';
import Resend from './Resend';
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
@ -100,7 +100,7 @@ interface ThirdpartyLookupResponse {
fields: ThirdpartyLookupResponseFields;
}
export enum CallHandlerEvent {
export enum LegacyCallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
@ -108,11 +108,11 @@ export enum CallHandlerEvent {
}
/**
* CallHandler manages all currently active calls. It should be used for
* LegacyCallHandler manages all currently active calls. It should be used for
* placing, answering, rejecting and hanging up calls. It also handles ringing,
* PSTN support and other things.
*/
export default class CallHandler extends EventEmitter {
export default class LegacyCallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
@ -130,11 +130,11 @@ export default class CallHandler extends EventEmitter {
private silencedCalls = new Set<string>(); // callIds
public static get instance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler();
if (!window.mxLegacyCallHandler) {
window.mxLegacyCallHandler = new LegacyCallHandler();
}
return window.mxCallHandler;
return window.mxLegacyCallHandler;
}
/*
@ -186,7 +186,7 @@ export default class CallHandler extends EventEmitter {
public silenceCall(callId: string): void {
this.silencedCalls.add(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
// Don't pause audio if we have calls which are still ringing
if (this.areAnyCallsUnsilenced()) return;
@ -195,7 +195,7 @@ export default class CallHandler extends EventEmitter {
public unSilenceCall(callId: string): void {
this.silencedCalls.delete(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.play(AudioID.Ring);
}
@ -311,7 +311,7 @@ export default class CallHandler extends EventEmitter {
return;
}
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
const mappedRoomId = LegacyCallHandler.instance.roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
logger.log(
"Got incoming call for room " + mappedRoomId +
@ -389,7 +389,7 @@ export default class CallHandler extends EventEmitter {
}
public play(audioId: AudioID): void {
const logPrefix = `CallHandler.play(${audioId}):`;
const logPrefix = `LegacyCallHandler.play(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
@ -424,7 +424,7 @@ export default class CallHandler extends EventEmitter {
}
public pause(audioId: AudioID): void {
const logPrefix = `CallHandler.pause(${audioId}):`;
const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
@ -688,32 +688,32 @@ export default class CallHandler extends EventEmitter {
}
private setCallState(call: MatrixCall, status: CallState): void {
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
const mappedRoomId = LegacyCallHandler.instance.roomIdForCall(call);
logger.log(
`Call state in ${mappedRoomId} changed to ${status}`,
);
const toastKey = getIncomingCallToastKey(call.callId);
const toastKey = getIncomingLegacyCallToastKey(call.callId);
if (status === CallState.Ringing) {
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
component: IncomingLegacyCallToast,
bodyClassName: "mx_IncomingLegacyCallToast",
props: { call },
});
} else {
ToastStore.sharedInstance().dismissToast(toastKey);
}
this.emit(CallHandlerEvent.CallState, mappedRoomId, status);
this.emit(LegacyCallHandlerEvent.CallState, mappedRoomId, status);
}
private removeCallForRoom(roomId: string): void {
logger.log("Removing call for room ", roomId);
this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.emit(LegacyCallHandlerEvent.CallsChanged, this.calls);
}
private showICEFallbackPrompt(): void {
@ -1115,9 +1115,9 @@ export default class CallHandler extends EventEmitter {
// Should we always emit CallsChanged too?
if (changedRooms) {
this.emit(CallHandlerEvent.CallChangeRoom, call);
this.emit(LegacyCallHandlerEvent.CallChangeRoom, call);
} else {
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.emit(LegacyCallHandlerEvent.CallsChanged, this.calls);
}
}
}

View file

@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import { PosthogAnalytics } from "./PosthogAnalytics";
import CallHandler from './CallHandler';
import LegacyCallHandler from './LegacyCallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import { _t } from "./languageHandler";
@ -59,8 +59,6 @@ import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialo
import { setSentryUser } from "./sentry";
import SdkConfig from "./SdkConfig";
import { DialogOpener } from "./utils/DialogOpener";
import VideoChannelStore from "./stores/VideoChannelStore";
import { fixStuckDevices } from "./utils/VideoChannelUtils";
import { Action } from "./dispatcher/actions";
import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler";
import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
@ -808,7 +806,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.instance.start();
CallHandler.instance.start();
LegacyCallHandler.instance.start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
@ -840,11 +838,6 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
// Now that we have a MatrixClientPeg, update the Jitsi info
Jitsi.getInstance().start();
// In case we disconnected uncleanly from a video room, clean up the stuck device
if (VideoChannelStore.instance.roomId) {
fixStuckDevices(MatrixClientPeg.get().getRoom(VideoChannelStore.instance.roomId), false);
}
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({ action: 'client_started' });
@ -932,7 +925,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
*/
export function stopMatrixClient(unsetClient = true): void {
Notifier.stop();
CallHandler.instance.stop();
LegacyCallHandler.instance.stop();
UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset();
Presence.stop();

View file

@ -137,4 +137,18 @@ export default class MediaDeviceHandler extends EventEmitter {
case MediaDeviceKindEnum.VideoInput: return this.getVideoInput();
}
}
public static get startWithAudioMuted(): boolean {
return SettingsStore.getValue("audioInputMuted");
}
public static set startWithAudioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public static get startWithVideoMuted(): boolean {
return SettingsStore.getValue("videoInputMuted");
}
public static set startWithVideoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
}

View file

@ -44,7 +44,7 @@ import { RoomViewStore } from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import CallHandler from "./CallHandler";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
/*
@ -397,7 +397,7 @@ export const Notifier = {
_evaluateEvent: function(ev: MatrixEvent) {
let roomId = ev.getRoomId();
if (CallHandler.instance.getSupportsVirtualRooms()) {
if (LegacyCallHandler.instance.getSupportsVirtualRooms()) {
// Attempt to translate a virtual room to a native one
const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId);
if (nativeRoomId) {

View file

@ -52,7 +52,7 @@ import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import { UIComponent, UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects";
import CallHandler from "./CallHandler";
import LegacyCallHandler from "./LegacyCallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
@ -1183,7 +1183,7 @@ export const Commands = [
description: _td("Switches to this room's virtual room, if it has one"),
category: CommandCategories.advanced,
isEnabled(): boolean {
return CallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom();
return LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom();
},
runFn: (roomId) => {
return success((async () => {
@ -1212,7 +1212,7 @@ export const Commands = [
return success((async () => {
if (isPhoneNumber) {
const results = await CallHandler.instance.pstnLookup(this.state.value);
const results = await LegacyCallHandler.instance.pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
throw newTranslatableError("Unable to find Matrix ID for phone number");
}
@ -1269,7 +1269,7 @@ export const Commands = [
category: CommandCategories.other,
isEnabled: () => !isCurrentLocalRoom(),
runFn: function(roomId, args) {
const call = CallHandler.instance.getCallForRoom(roomId);
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
}
@ -1284,7 +1284,7 @@ export const Commands = [
category: CommandCategories.other,
isEnabled: () => !isCurrentLocalRoom(),
runFn: function(roomId, args) {
const call = CallHandler.instance.getCallForRoom(roomId);
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
}

View file

@ -21,7 +21,7 @@ import { EventType } from 'matrix-js-sdk/src/@types/event';
import { ensureVirtualRoomExists } from './createRoom';
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import CallHandler from './CallHandler';
import LegacyCallHandler from './LegacyCallHandler';
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import { findDMForUser } from './utils/dm/findDMForUser';
@ -39,7 +39,7 @@ export default class VoipUserMapper {
}
private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.instance.sipVirtualLookup(userId);
const results = await LegacyCallHandler.instance.sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
}
@ -118,11 +118,11 @@ export default class VoipUserMapper {
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.instance.getSupportsVirtualRooms()) return;
if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.instance.sipNativeLookup(inviterId);
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId);
if (result.length === 0) {
return;
}

View file

@ -21,7 +21,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
@ -325,7 +325,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.instance.getSupportsPstnProtocol()) {
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}

View file

@ -19,10 +19,10 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { EventEmitter } from 'events';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
export enum CallEventGrouperEvent {
export enum LegacyCallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
@ -44,10 +44,10 @@ export enum CustomCallState {
Missed = "missed",
}
export function buildCallEventGroupers(
callEventGroupers: Map<string, CallEventGrouper>,
export function buildLegacyCallEventGroupers(
callEventGroupers: Map<string, LegacyCallEventGrouper>,
events?: MatrixEvent[],
): Map<string, CallEventGrouper> {
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) {
@ -57,10 +57,10 @@ export function buildCallEventGroupers(
const callId = ev.getContent().call_id;
if (!newCallEventGroupers.has(callId)) {
if (callEventGroupers.has(callId)) {
// reuse the CallEventGrouper object where possible
// reuse the LegacyCallEventGrouper object where possible
newCallEventGroupers.set(callId, callEventGroupers.get(callId));
} else {
newCallEventGroupers.set(callId, new CallEventGrouper());
newCallEventGroupers.set(callId, new LegacyCallEventGrouper());
}
}
newCallEventGroupers.get(callId).add(ev);
@ -68,7 +68,7 @@ export function buildCallEventGroupers(
return newCallEventGroupers;
}
export default class CallEventGrouper extends EventEmitter {
export default class LegacyCallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
@ -76,8 +76,10 @@ export default class CallEventGrouper extends EventEmitter {
constructor() {
super();
CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
}
private get invite(): MatrixEvent {
@ -138,31 +140,31 @@ export default class CallEventGrouper extends EventEmitter {
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.instance.isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
const newState = LegacyCallHandler.instance.isCallSilenced(this.callId);
this.emit(LegacyCallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
this.emit(LegacyCallEventGrouperEvent.LengthChanged, length);
};
public answerCall = (): void => {
CallHandler.instance.answerCall(this.roomId);
LegacyCallHandler.instance.answerCall(this.roomId);
};
public rejectCall = (): void => {
CallHandler.instance.hangupOrReject(this.roomId, true);
LegacyCallHandler.instance.hangupOrReject(this.roomId, true);
};
public callBack = (): void => {
CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
LegacyCallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
const silenced = CallHandler.instance.isCallSilenced(this.callId);
const silenced = LegacyCallHandler.instance.isCallSilenced(this.callId);
silenced ?
CallHandler.instance.unSilenceCall(this.callId) :
CallHandler.instance.silenceCall(this.callId);
LegacyCallHandler.instance.unSilenceCall(this.callId) :
LegacyCallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
@ -182,13 +184,13 @@ export default class CallEventGrouper extends EventEmitter {
else if (this.hangup) this.state = CallState.Ended;
else if (this.invite && this.call) this.state = CallState.Connecting;
}
this.emit(CallEventGrouperEvent.StateChanged, this.state);
this.emit(LegacyCallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
this.call = CallHandler.instance.getCallById(this.callId);
this.call = LegacyCallHandler.instance.getCallById(this.callId);
this.setCallListeners();
this.setState();
};

View file

@ -51,8 +51,8 @@ import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import AudioFeedArrayForLegacyCall from '../views/voip/AudioFeedArrayForLegacyCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
@ -146,7 +146,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.instance.getAllActiveCalls(),
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -163,7 +163,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -195,7 +195,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
this._matrixClient.removeListener(ClientEvent.Sync, this.onSync);
this._matrixClient.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
@ -207,7 +207,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
private onCallState = (): void => {
const activeCalls = CallHandler.instance.getAllActiveCalls();
const activeCalls = LegacyCallHandler.instance.getAllActiveCalls();
if (activeCalls === this.state.activeCalls) return;
this.setState({ activeCalls });
};
@ -658,7 +658,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
<AudioFeedArrayForLegacyCall call={call} key={call.callId} />
);
});

View file

@ -114,7 +114,7 @@ import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { showSpaceInvite } from "../../utils/space";
import AccessibleButton from "../views/elements/AccessibleButton";
import { ActionPayload } from "../../dispatcher/payloads";
@ -128,7 +128,7 @@ import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStart
import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
import VideoChannelStore from "../../stores/VideoChannelStore";
import { CallStore } from "../../stores/CallStore";
import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
@ -576,9 +576,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
CallHandler.instance.hangupAllCalls();
if (VideoChannelStore.instance.connected) VideoChannelStore.instance.setDisconnected();
Lifecycle.logout();
LegacyCallHandler.instance.hangupAllCalls();
Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()))
.finally(() => Lifecycle.logout());
break;
case 'require_registration':
startAnyRegistrationFlow(payload as any);

View file

@ -40,7 +40,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import HistoryTile from "../views/rooms/HistoryTile";
import defaultDispatcher from '../../dispatcher/dispatcher';
import CallEventGrouper from "./CallEventGrouper";
import LegacyCallEventGrouper from "./LegacyCallEventGrouper";
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import GenericEventListSummary from '../views/elements/GenericEventListSummary';
@ -188,7 +188,7 @@ interface IProps {
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
callEventGroupers: Map<string, CallEventGrouper>;
callEventGroupers: Map<string, LegacyCallEventGrouper>;
}
interface IState {

View file

@ -45,7 +45,7 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import dis, { defaultDispatcher } from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
@ -78,7 +78,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 { VideoRoomView } from "./VideoRoomView";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
@ -810,7 +810,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
callState: callState,
});
CallHandler.instance.on(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState);
window.addEventListener('beforeunload', this.onPageUnload);
}
@ -847,7 +847,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
// update the scroll map before we get unmounted
if (this.state.roomId) {
@ -896,7 +896,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
}
CallHandler.instance.off(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState);
// cancel any pending calls to the throttled updated
this.updateRoomMembers.cancel();
@ -1655,7 +1655,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onCallPlaced = (type: CallType): void => {
CallHandler.instance.placeCall(this.state.room?.roomId, type);
LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => {
@ -1872,7 +1872,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) {
return null;
}
return CallHandler.instance.getCallForRoom(this.state.room.roomId);
return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId);
}
// this has to be a proper method rather than an unnamed function,

View file

@ -52,7 +52,7 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import CallEventGrouper, { buildCallEventGroupers } from "./CallEventGrouper";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
@ -240,8 +240,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readReceiptActivityTimer: Timer;
private readMarkerActivityTimer: Timer;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
constructor(props, context) {
super(props, context);
@ -493,7 +493,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.timelineWindow.unpaginate(count, backwards);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
events,
liveEvents,
@ -555,7 +555,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
debuglog("paginate complete backwards:"+backwards+"; success:"+r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
[paginatingKey]: false,
[canPaginateKey]: r,
@ -686,7 +686,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) { return; }
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
@ -855,7 +855,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// TODO: We should restrict this to only events in our timeline,
// but possibly the event tile itself should just update when this
// happens to save us re-rendering the whole timeline.
this.buildCallEventGroupers(this.state.events);
this.buildLegacyCallEventGroupers(this.state.events);
this.forceUpdate();
};
@ -1405,7 +1405,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
onLoaded();
} else {
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
this.buildCallEventGroupers();
this.buildLegacyCallEventGroupers();
this.setState({
events: [],
liveEvents: [],
@ -1426,7 +1426,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return;
const state = this.getEvents();
this.buildCallEventGroupers(state.events);
this.buildLegacyCallEventGroupers(state.events);
this.setState(state);
}
@ -1707,8 +1707,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
eventType: EventType | string,
) => this.props.timelineSet.relations?.getChildEventsForEvent(eventId, relationType, eventType);
private buildCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildCallEventGroupers(this.callEventGroupers, events);
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
render() {

View file

@ -14,79 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useContext, useState, useMemo, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Room } from "matrix-js-sdk/src/models/room";
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 { useEventEmitter } from "../../hooks/useEventEmitter";
import WidgetUtils from "../../utils/WidgetUtils";
import { addVideoChannel, getVideoChannel, fixStuckDevices } from "../../utils/VideoChannelUtils";
import WidgetStore, { IApp } from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
import AppTile from "../views/elements/AppTile";
import VideoLobby from "../views/voip/VideoLobby";
import { CallLobby } from "../views/voip/CallLobby";
interface IProps {
interface Props {
room: Room;
resizing: boolean;
}
const VideoRoomView: FC<IProps> = ({ room, resizing }) => {
const LoadedVideoRoomView: FC<Props & { call: Call }> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext);
const store = VideoChannelStore.instance;
const connected = isConnected(useConnectionState(call));
// In case we mount before the WidgetStore knows about our Jitsi widget
const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient));
const [widgetLoaded, setWidgetLoaded] = useState(false);
useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => {
if (roomId === null) setWidgetStoreReady(true);
if (roomId === null || roomId === room.roomId) {
setWidgetLoaded(Boolean(getVideoChannel(room.roomId)));
}
});
// We'll take this opportunity to tidy up our room state
useEffect(() => { call?.clean(); }, [call]);
const app: IApp = useMemo(() => {
if (widgetStoreReady) {
const app = getVideoChannel(room.roomId);
if (!app) {
logger.warn(`No video channel for room ${room.roomId}`);
// Since widgets in video rooms are mutable, we'll take this opportunity to
// reinstate the Jitsi widget in case another client removed it
if (WidgetUtils.canUserModifyWidgets(room.roomId)) {
addVideoChannel(room.roomId, room.name);
}
}
return app;
}
}, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// We'll also take this opportunity to fix any stuck devices.
// The linter thinks that store.connected should be a dependency, but we explicitly
// *only* want this to happen at mount to avoid racing with normal device updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fixStuckDevices(room, store.connected); }, [room]);
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));
useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false));
if (!app) return null;
if (!call) return null;
return <div className="mx_VideoRoomView">
{ connected ? null : <VideoLobby room={room} /> }
{ connected ? null : <CallLobby room={room} call={call} /> }
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
<AppTile
app={app}
app={call.widget}
room={room}
userId={cli.credentials.userId}
creatorUserId={app.creatorUserId}
waitForIframeLoad={app.waitForIframeLoad}
creatorUserId={call.widget.creatorUserId}
waitForIframeLoad={call.widget.waitForIframeLoad}
showMenubar={false}
pointerEvents={resizing ? "none" : null}
pointerEvents={resizing ? "none" : undefined}
/>
</div>;
};
export default VideoRoomView;
export const VideoRoomView: FC<Props> = ({ room, resizing }) => {
const call = useCall(room.roomId);
return call ? <LoadedVideoRoomView room={room} resizing={resizing} call={call} /> : null;
};

View file

@ -20,13 +20,13 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { _t } from '../../../languageHandler';
import ContextMenu, { IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
export default class CallContextMenu extends React.Component<IProps> {
export default class LegacyCallContextMenu extends React.Component<IProps> {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
@ -42,13 +42,13 @@ export default class CallContextMenu extends React.Component<IProps> {
};
onUnholdClick = () => {
CallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
LegacyCallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
};
onTransferClick = () => {
CallHandler.instance.showTransferDialog(this.props.call);
LegacyCallHandler.instance.showTransferDialog(this.props.call);
this.props.onFinished();
};
@ -58,13 +58,13 @@ export default class CallContextMenu extends React.Component<IProps> {
let transferItem;
if (this.props.call.opponentCanBeTransferred()) {
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
transferItem = <MenuItem className="mx_LegacyCallContextMenu_item" onClick={this.onTransferClick}>
{ _t("Transfer") }
</MenuItem>;
}
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
<MenuItem className="mx_LegacyCallContextMenu_item" onClick={handler}>
{ holdUnholdCaption }
</MenuItem>
{ transferItem }

View file

@ -56,7 +56,7 @@ import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
import UserIdentifierCustomisations from '../../../customisations/UserIdentifier';
import CopyableText from "../elements/CopyableText";
import { ScreenName } from '../../../PosthogTrackers';
@ -510,13 +510,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return;
}
CallHandler.instance.startTransferToMatrixID(
LegacyCallHandler.instance.startTransferToMatrixID(
this.props.call,
targetIds[0],
this.state.consultFirst,
);
} else {
CallHandler.instance.startTransferToPhoneNumber(
LegacyCallHandler.instance.startTransferToPhoneNumber(
this.props.call,
this.state.dialPadValue,
this.state.consultFirst,

View file

@ -36,10 +36,9 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget";
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
import { IApp } from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { OwnProfileStore } from '../../../stores/OwnProfileStore';
@ -305,7 +304,6 @@ export default class AppTile extends React.Component<IProps, IState> {
private setupSgListeners() {
this.sgWidget.on("preparing", this.onWidgetPreparing);
this.sgWidget.on("ready", this.onWidgetReady);
// emits when the capabilities have been set up or changed
this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
@ -313,7 +311,6 @@ export default class AppTile extends React.Component<IProps, IState> {
private stopSgListeners() {
if (!this.sgWidget) return;
this.sgWidget.off("preparing", this.onWidgetPreparing);
this.sgWidget.off("ready", this.onWidgetReady);
this.sgWidget.off("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
@ -393,7 +390,7 @@ export default class AppTile extends React.Component<IProps, IState> {
}
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) {
CallHandler.instance.hangupCallApp(this.props.room.roomId);
LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId);
}
// Delete the widget from the persisted store for good measure.
@ -407,12 +404,6 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setState({ loading: false });
};
private onWidgetReady = (): void => {
if (WidgetType.JITSI.matches(this.props.app.type)) {
this.sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
};
private onWidgetCapabilitiesNotified = (): void => {
this.setState({
requiresClient: this.sgWidget.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient),

View file

@ -181,7 +181,7 @@ export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
style.display = this.props.visible ? "block" : "none";
const tooltip = (
<div className={tooltipClasses} style={style}>
<div role="tooltip" className={tooltipClasses} style={style}>
<div className="mx_Tooltip_chevron" />
{ this.props.label }
</div>

View file

@ -21,7 +21,10 @@ import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
import LegacyCallEventGrouper, {
LegacyCallEventGrouperEvent,
CustomCallState,
} from '../../structures/LegacyCallEventGrouper';
import AccessibleButton from '../elements/AccessibleButton';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
@ -32,7 +35,7 @@ const MAX_NON_NARROW_WIDTH = 450 / 70 * 100;
interface IProps {
mxEvent: MatrixEvent;
callEventGrouper: CallEventGrouper;
callEventGrouper: LegacyCallEventGrouper;
timestamp?: JSX.Element;
}
@ -43,7 +46,7 @@ interface IState {
length: number;
}
export default class CallEvent extends React.PureComponent<IProps, IState> {
export default class LegacyCallEvent extends React.PureComponent<IProps, IState> {
private wrapperElement = createRef<HTMLDivElement>();
private resizeObserver: ResizeObserver;
@ -59,18 +62,18 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
componentDidMount() {
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.wrapperElement.current && this.resizeObserver.observe(this.wrapperElement.current);
}
componentWillUnmount() {
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver.disconnect();
}
@ -97,7 +100,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
private renderCallBackButton(text: string): JSX.Element {
return (
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_callBack"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_callBack"
onClick={this.props.callEventGrouper.callBack}
kind="primary"
>
@ -108,9 +111,9 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
private renderSilenceIcon(): JSX.Element {
const silenceClass = classNames({
"mx_CallEvent_iconButton": true,
"mx_CallEvent_unSilence": this.state.silenced,
"mx_CallEvent_silence": !this.state.silenced,
"mx_LegacyCallEvent_iconButton": true,
"mx_LegacyCallEvent_unSilence": this.state.silenced,
"mx_LegacyCallEvent_silence": !this.state.silenced,
});
return (
@ -130,17 +133,17 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ silenceIcon }
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_reject"
onClick={this.props.callEventGrouper.rejectCall}
kind="danger"
>
<span> { _t("Decline") } </span>
</AccessibleButton>
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_answer"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_answer"
onClick={this.props.callEventGrouper.answerCall}
kind="primary"
>
@ -156,7 +159,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
if (gotRejected) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Call declined") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -175,14 +178,14 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
text += " • " + formatCallTime(duration);
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ text }
{ this.props.timestamp }
</div>
);
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("No answer") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -212,10 +215,10 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
<InfoTooltip
tooltip={reason}
className="mx_CallEvent_content_tooltip"
className="mx_LegacyCallEvent_content_tooltip"
kind={InfoTooltipKind.Warning}
/>
{ _t("Connection failed") }
@ -226,7 +229,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CallState.Connected) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
<Clock seconds={this.state.length} aria-live="off" />
{ this.props.timestamp }
</div>
@ -234,7 +237,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CallState.Connecting) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Connecting") }
{ this.props.timestamp }
</div>
@ -242,7 +245,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CustomCallState.Missed) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -251,7 +254,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("The call is in an unknown state!") }
{ this.props.timestamp }
</div>
@ -266,13 +269,13 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
const callState = this.state.callState;
const hangupReason = this.props.callEventGrouper.hangupReason;
const content = this.renderContent(callState);
const className = classNames("mx_CallEvent", {
mx_CallEvent_voice: isVoice,
mx_CallEvent_video: !isVoice,
mx_CallEvent_narrow: this.state.narrow,
mx_CallEvent_missed: callState === CustomCallState.Missed,
mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
const className = classNames("mx_LegacyCallEvent", {
mx_LegacyCallEvent_voice: isVoice,
mx_LegacyCallEvent_video: !isVoice,
mx_LegacyCallEvent_narrow: this.state.narrow,
mx_LegacyCallEvent_missed: callState === CustomCallState.Missed,
mx_LegacyCallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
mx_LegacyCallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
});
let silenceIcon;
if (this.state.narrow && this.state.callState === CallState.Ringing) {
@ -280,21 +283,21 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_wrapper" ref={this.wrapperElement}>
<div className="mx_LegacyCallEvent_wrapper" ref={this.wrapperElement}>
<div className={className}>
{ silenceIcon }
<div className="mx_CallEvent_info">
<div className="mx_LegacyCallEvent_info">
<MemberAvatar
member={event.sender}
width={32}
height={32}
/>
<div className="mx_CallEvent_info_basic">
<div className="mx_CallEvent_sender">
<div className="mx_LegacyCallEvent_info_basic">
<div className="mx_LegacyCallEvent_sender">
{ sender }
</div>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
<div className="mx_LegacyCallEvent_type">
<div className="mx_LegacyCallEvent_type_icon" />
{ callType }
</div>
</div>

View file

@ -27,7 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import LegacyCallViewForRoom from '../voip/LegacyCallViewForRoom';
import { objectHasDiff } from "../../../utils/objects";
interface IProps {
@ -123,7 +123,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
render() {
const callView = (
<CallViewForRoom
<LegacyCallViewForRoom
roomId={this.props.room.roomId}
resizeNotifier={this.props.resizeNotifier}
showApps={this.props.showApps}

View file

@ -47,7 +47,7 @@ import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
import CallEventGrouper from "../../structures/CallEventGrouper";
import LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
import PlatformPeg from '../../../PlatformPeg';
@ -200,8 +200,8 @@ interface IProps {
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
// CallEventGrouper for this event
callEventGrouper?: CallEventGrouper;
// LegacyCallEventGrouper for this event
callEventGrouper?: LegacyCallEventGrouper;
// Symbol of the root node
as?: string;

View file

@ -19,11 +19,11 @@ import React, { createRef } from "react";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import type { Call } from "../../../models/Call";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import defaultDispatcher from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
@ -45,8 +45,9 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomViewStore } from "../../../stores/RoomViewStore";
import VideoRoomSummary from "./VideoRoomSummary";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
interface IProps {
room: Room;
@ -61,6 +62,7 @@ interface IState {
selected: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
call: Call | null;
messagePreview?: string;
}
@ -79,7 +81,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
private isVideoRoom: boolean;
constructor(props: IProps) {
super(props);
@ -88,6 +89,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.get(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
};
@ -95,7 +97,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom();
}
private onRoomNameUpdate = (room: Room) => {
@ -154,6 +155,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.get(this.props.room.roomId) });
}
public componentWillUnmount() {
@ -166,6 +172,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
}
private onAction = (payload: ActionPayload) => {
@ -185,6 +192,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
};
private onCallChanged = (call: Call, roomId: string) => {
if (roomId === this.props.room?.roomId) this.setState({ call });
};
private async generatePreview() {
if (!this.showMessagePreview) {
return null;
@ -362,10 +373,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
let subtitle;
if (this.isVideoRoom) {
if (this.state.call) {
subtitle = (
<div className="mx_RoomTile_subtitle">
<VideoRoomSummary room={this.props.room} />
<RoomTileCallSummary call={this.state.call} />
</div>
);
} else if (this.showMessagePreview && this.state.messagePreview) {

View file

@ -16,66 +16,56 @@ limitations under the License.
import React, { FC } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import type { Call } from "../../../models/Call";
import { _t, TranslatedString } from "../../../languageHandler";
import {
ConnectionState,
useConnectionState,
useConnectedMembers,
useJitsiParticipants,
} from "../../../utils/VideoChannelUtils";
import { useConnectionState, useParticipants } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
interface IProps {
room: Room;
interface Props {
call: Call;
}
const VideoRoomSummary: FC<IProps> = ({ room }) => {
const connectionState = useConnectionState(room);
const videoMembers = useConnectedMembers(room, connectionState === ConnectionState.Connected);
const jitsiParticipants = useJitsiParticipants(room);
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
let indicator: TranslatedString;
let text: TranslatedString;
let active: boolean;
let participantCount: number;
switch (connectionState) {
case ConnectionState.Disconnected:
indicator = _t("Video");
text = _t("Video");
active = false;
participantCount = videoMembers.size;
break;
case ConnectionState.Connecting:
indicator = _t("Joining…");
text = _t("Joining…");
active = true;
participantCount = videoMembers.size;
break;
case ConnectionState.Connected:
indicator = _t("Joined");
case ConnectionState.Disconnecting:
text = _t("Joined");
active = true;
participantCount = jitsiParticipants.length;
break;
}
return <span className="mx_VideoRoomSummary">
return <span className="mx_RoomTileCallSummary">
<span
className={classNames(
"mx_VideoRoomSummary_indicator",
{ "mx_VideoRoomSummary_indicator_active": active },
"mx_RoomTileCallSummary_text",
{ "mx_RoomTileCallSummary_text_active": active },
)}
>
{ indicator }
{ text }
</span>
{ participantCount ? <>
{ participants.size ? <>
{ " · " }
<span
className="mx_VideoRoomSummary_participants"
aria-label={_t("%(count)s participants", { count: participantCount })}
className="mx_RoomTileCallSummary_participants"
aria-label={_t("%(count)s participants", { count: participants.size })}
>
{ participantCount }
{ participants.size }
</span>
</> : null }
</span>;
};
export default VideoRoomSummary;

View file

@ -26,7 +26,7 @@ import DateSeparator from "../messages/DateSeparator";
import EventTile from "./EventTile";
import { shouldFormContinuation } from "../../structures/MessagePanel";
import { wantsDateSeparator } from "../../../DateUtils";
import CallEventGrouper, { buildCallEventGroupers } from "../../structures/CallEventGrouper";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "../../structures/LegacyCallEventGrouper";
import { haveRendererForEvent } from "../../../events/EventTileFactory";
interface IProps {
@ -44,17 +44,17 @@ export default class SearchResultTile extends React.Component<IProps> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
constructor(props, context) {
super(props, context);
this.buildCallEventGroupers(this.props.searchResult.context.getTimeline());
this.buildLegacyCallEventGroupers(this.props.searchResult.context.getTimeline());
}
private buildCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildCallEventGroupers(this.callEventGroupers, events);
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
public render() {

View file

@ -28,7 +28,7 @@ interface IState {
feeds: Array<CallFeed>;
}
export default class AudioFeedArrayForCall extends React.Component<IProps, IState> {
export default class AudioFeedArrayForLegacyCall extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);

View file

@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useState, useMemo, useRef, useEffect } from "react";
import React, { FC, useState, useMemo, useRef, useEffect, 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 { _t } from "../../../languageHandler";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useConnectedMembers } from "../../../utils/VideoChannelUtils";
import VideoChannelStore from "../../../stores/VideoChannelStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import { useParticipants } from "../../../hooks/useCall";
import { CallStore } from "../../../stores/CallStore";
import { Call } from "../../../models/Call";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
@ -34,25 +36,22 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import FacePile from "../elements/FacePile";
import MemberAvatar from "../avatars/MemberAvatar";
interface IDeviceButtonProps {
interface DeviceButtonProps {
kind: string;
devices: MediaDeviceInfo[];
setDevice: (device: MediaDeviceInfo) => void;
deviceListLabel: string;
active: boolean;
fallbackDeviceLabel: (n: number) => string;
muted: boolean;
disabled: boolean;
toggle: () => void;
activeTitle: string;
inactiveTitle: string;
unmutedTitle: string;
mutedTitle: string;
}
const DeviceButton: FC<IDeviceButtonProps> = ({
kind, devices, setDevice, deviceListLabel, active, disabled, toggle, activeTitle, inactiveTitle,
const DeviceButton: FC<DeviceButtonProps> = ({
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
}) => {
// Depending on permissions, the browser might not let us know device labels,
// in which case there's nothing helpful we can display
const labelledDevices = useMemo(() => devices.filter(d => d.label.length), [devices]);
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
@ -61,13 +60,13 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
closeMenu();
};
const buttonRect = buttonRef.current.getBoundingClientRect();
const buttonRect = buttonRef.current!.getBoundingClientRect();
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
{ labelledDevices.map(d =>
{ devices.map((d, index) =>
<IconizedContextMenuOption
key={d.deviceId}
label={d.label}
label={d.label || fallbackDeviceLabel(index + 1)}
onClick={() => selectDevice(d)}
/>,
) }
@ -78,21 +77,20 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
if (!devices.length) return null;
return <div
className={classNames({
"mx_VideoLobby_deviceButtonWrapper": true,
"mx_VideoLobby_deviceButtonWrapper_active": active,
className={classNames("mx_CallLobby_deviceButtonWrapper", {
"mx_CallLobby_deviceButtonWrapper_muted": muted,
})}
>
<AccessibleTooltipButton
className={`mx_VideoLobby_deviceButton mx_VideoLobby_deviceButton_${kind}`}
title={active ? activeTitle : inactiveTitle}
className={`mx_CallLobby_deviceButton mx_CallLobby_deviceButton_${kind}`}
title={muted ? mutedTitle : unmutedTitle}
alignment={Alignment.Top}
onClick={toggle}
disabled={disabled}
/>
{ labelledDevices.length > 1 ? (
{ devices.length > 1 ? (
<ContextMenuButton
className="mx_VideoLobby_deviceListButton"
className="mx_CallLobby_deviceListButton"
inputRef={buttonRef}
onClick={openMenu}
isExpanded={menuDisplayed}
@ -106,57 +104,65 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
const MAX_FACES = 8;
const VideoLobby: FC<{ room: Room }> = ({ room }) => {
const store = VideoChannelStore.instance;
interface Props {
room: Room;
call: Call;
}
export const CallLobby: FC<Props> = ({ room, call }) => {
const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId), [room]);
const connectedMembers = useConnectedMembers(room, false);
const videoRef = useRef<HTMLVideoElement>();
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const participants = useParticipants(call);
const videoRef = useRef<HTMLVideoElement>(null);
const devices = useAsyncMemo(async () => {
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
try {
return await navigator.mediaDevices.enumerateDevices();
const devices = await MediaDeviceHandler.getDevices();
return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
} catch (e) {
logger.warn(`Failed to get media device list: ${e}`);
return [];
logger.warn(`Failed to get media device list`, e);
return [[], []];
}
}, [], []);
const audioDevices = useMemo(() => devices.filter(d => d.kind === "audioinput"), [devices]);
const videoDevices = useMemo(() => devices.filter(d => d.kind === "videoinput"), [devices]);
}, [], [[], []]);
const [selectedAudioDevice, selectAudioDevice] = useState<MediaDeviceInfo>(null);
const [selectedVideoDevice, selectVideoDevice] = useState<MediaDeviceInfo>(null);
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
const audioDevice = selectedAudioDevice ?? audioDevices[0];
const videoDevice = selectedVideoDevice ?? videoDevices[0];
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
}, []);
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
setVideoInputId(device.deviceId);
}, []);
const [audioActive, setAudioActive] = useState(!store.audioMuted);
const [videoActive, setVideoActive] = useState(!store.videoMuted);
const toggleAudio = () => {
store.audioMuted = audioActive;
setAudioActive(!audioActive);
};
const toggleVideo = () => {
store.videoMuted = videoActive;
setVideoActive(!videoActive);
};
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
const toggleAudio = useCallback(() => {
MediaDeviceHandler.startWithAudioMuted = !audioMuted;
setAudioMuted(!audioMuted);
}, [audioMuted, setAudioMuted]);
const toggleVideo = useCallback(() => {
MediaDeviceHandler.startWithVideoMuted = !videoMuted;
setVideoMuted(!videoMuted);
}, [videoMuted, setVideoMuted]);
const videoStream = useAsyncMemo(async () => {
if (videoDevice && videoActive) {
if (videoInputId && !videoMuted) {
try {
return await navigator.mediaDevices.getUserMedia({
video: { deviceId: videoDevice.deviceId },
video: { deviceId: videoInputId },
});
} catch (e) {
logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`);
logger.error(`Failed to get stream for device ${videoInputId}`, e);
}
}
return null;
}, [videoDevice, videoActive]);
}, [videoInputId, videoMuted]);
useEffect(() => {
if (videoStream) {
const videoElement = videoRef.current;
const videoElement = videoRef.current!;
videoElement.srcObject = videoStream;
videoElement.play();
@ -167,67 +173,69 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => {
}
}, [videoStream]);
const connect = async () => {
const connect = useCallback(async () => {
setConnecting(true);
try {
await store.connect(
room.roomId, audioActive ? audioDevice : null, videoActive ? videoDevice : null,
);
// Disconnect from any other active calls first, since we don't yet support holding
await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()));
await call.connect();
} catch (e) {
logger.error(e);
setConnecting(false);
}
};
}, [call, setConnecting]);
let facePile;
if (connectedMembers.size) {
const shownMembers = [...connectedMembers].slice(0, MAX_FACES);
const overflow = connectedMembers.size > shownMembers.length;
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_VideoLobby_connectedMembers">
{ _t("%(count)s people joined", { count: connectedMembers.size }) }
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_VideoLobby">
return <div className="mx_CallLobby">
{ facePile }
<div className="mx_VideoLobby_preview">
<div className="mx_CallLobby_preview">
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
<video
ref={videoRef}
style={{ visibility: videoActive ? null : "hidden" }}
style={{ visibility: videoMuted ? "hidden" : undefined }}
muted
playsInline
disablePictureInPicture
/>
<div className="mx_VideoLobby_controls">
<div className="mx_CallLobby_controls">
<DeviceButton
kind="audio"
devices={audioDevices}
setDevice={selectAudioDevice}
devices={audioInputs}
setDevice={setAudioInput}
deviceListLabel={_t("Audio devices")}
active={audioActive}
fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })}
muted={audioMuted}
disabled={connecting}
toggle={toggleAudio}
activeTitle={_t("Mute microphone")}
inactiveTitle={_t("Unmute microphone")}
unmutedTitle={_t("Mute microphone")}
mutedTitle={_t("Unmute microphone")}
/>
<DeviceButton
kind="video"
devices={videoDevices}
setDevice={selectVideoDevice}
devices={videoInputs}
setDevice={setVideoInput}
deviceListLabel={_t("Video devices")}
active={videoActive}
fallbackDeviceLabel={n => _t("Video input %(n)s", { n })}
muted={videoMuted}
disabled={connecting}
toggle={toggleVideo}
activeTitle={_t("Turn off camera")}
inactiveTitle={_t("Turn on camera")}
unmutedTitle={_t("Turn off camera")}
mutedTitle={_t("Turn on camera")}
/>
</div>
</div>
<AccessibleButton
className="mx_VideoLobby_joinButton"
className="mx_CallLobby_connectButton"
kind="primary"
disabled={connecting}
onClick={connect}
@ -236,5 +244,3 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => {
</AccessibleButton>
</div>;
};
export default VideoLobby;

View file

@ -21,7 +21,7 @@ import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
interface IProps {
onFinished: (boolean) => void;
@ -78,7 +78,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
};
onDialPress = async () => {
CallHandler.instance.dialNumber(this.state.value);
LegacyCallHandler.instance.dialNumber(this.state.value);
this.props.onFinished(true);
};

View file

@ -23,7 +23,7 @@ import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler';
import VideoFeed from './VideoFeed';
@ -32,9 +32,9 @@ import AccessibleButton from '../elements/AccessibleButton';
import { avatarUrlForMember } from '../../../Avatar';
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
import Modal from '../../../Modal';
import CallViewSidebar from './CallViewSidebar';
import CallViewHeader from './CallView/CallViewHeader';
import CallViewButtons from "./CallView/CallViewButtons";
import LegacyCallViewSidebar from './LegacyCallViewSidebar';
import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader';
import LegacyCallViewButtons from "./LegacyCallView/LegacyCallViewButtons";
import PlatformPeg from "../../../PlatformPeg";
import { ActionPayload } from "../../../dispatcher/payloads";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
@ -47,7 +47,7 @@ interface IProps {
// Another ongoing call to display information about
secondaryCall?: MatrixCall;
// a callback which is called when the content in the CallView changes
// a callback which is called when the content in the LegacyCallView changes
// in a way that is likely to cause a resize.
onResize?: (event: Event) => void;
@ -57,7 +57,7 @@ interface IProps {
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// Used for dragging the PiP CallView
// Used for dragging the PiP LegacyCallView
onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
showApps?: boolean;
@ -104,15 +104,15 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
export default class CallView extends React.Component<IProps, IState> {
export default class LegacyCallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentWrapperRef = createRef<HTMLDivElement>();
private buttonsRef = createRef<CallViewButtons>();
private buttonsRef = createRef<LegacyCallViewButtons>();
constructor(props: IProps) {
super(props);
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(this.props.call.getFeeds());
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(this.props.call.getFeeds());
this.state = {
isLocalOnHold: this.props.call.isLocalOnHold(),
@ -146,7 +146,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
static getDerivedStateFromProps(props: IProps): Partial<IState> {
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(props.call.getFeeds());
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(props.call.getFeeds());
return {
primaryFeed: primary,
@ -209,7 +209,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onFeedsChanged = (newFeeds: Array<CallFeed>): void => {
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(newFeeds);
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(newFeeds);
this.setState({
primaryFeed: primary,
secondaryFeed: secondary,
@ -310,8 +310,8 @@ export default class CallView extends React.Component<IProps, IState> {
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
// Note that this assumes we always have a LegacyCallView on screen at any given time
// LegacyCallHandler would probably be a better place for this
private onNativeKeyDown = (ev): void => {
let handled = false;
@ -339,17 +339,17 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.instance.roomIdForCall(this.props.call);
CallHandler.instance.setActiveCallRoomId(userFacingRoomId);
const userFacingRoomId = LegacyCallHandler.instance.roomIdForCall(this.props.call);
LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId);
};
private onTransferClick = (): void => {
const transfereeCall = CallHandler.instance.getTransfereeForCallId(this.props.call.callId);
const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(this.props.call.callId);
this.props.call.transferToCall(transfereeCall);
};
private onHangupClick = (): void => {
CallHandler.instance.hangupOrReject(CallHandler.instance.roomIdForCall(this.props.call));
LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call));
};
private onToggleSidebar = (): void => {
@ -380,7 +380,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
return (
<CallViewButtons
<LegacyCallViewButtons
ref={this.buttonsRef}
call={call}
pipMode={pipMode}
@ -431,7 +431,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
return (
<div className="mx_CallView_toast">
<div className="mx_LegacyCallView_toast">
{ text }
</div>
);
@ -443,7 +443,7 @@ export default class CallView extends React.Component<IProps, IState> {
const callRoom = MatrixClientPeg.get().getRoom(call.roomId);
const avatarSize = pipMode ? 76 : 160;
const transfereeCall = CallHandler.instance.getTransfereeForCallId(call.callId);
const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(call.callId);
const isOnHold = isLocalOnHold || isRemoteOnHold;
let secondaryFeedElement: React.ReactNode;
@ -460,23 +460,23 @@ export default class CallView extends React.Component<IProps, IState> {
}
if (transfereeCall || isOnHold) {
const containerClasses = classNames("mx_CallView_content", {
mx_CallView_content_hold: isOnHold,
const containerClasses = classNames("mx_LegacyCallView_content", {
mx_LegacyCallView_content_hold: isOnHold,
});
const backgroundAvatarUrl = avatarUrlForMember(call.getOpponentMember(), 1024, 1024, 'crop');
let holdTransferContent: React.ReactNode;
if (transfereeCall) {
const transferTargetRoom = MatrixClientPeg.get().getRoom(
CallHandler.instance.roomIdForCall(call),
LegacyCallHandler.instance.roomIdForCall(call),
);
const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person");
const transfereeRoom = MatrixClientPeg.get().getRoom(
CallHandler.instance.roomIdForCall(transfereeCall),
LegacyCallHandler.instance.roomIdForCall(transfereeCall),
);
const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person");
holdTransferContent = <div className="mx_CallView_status">
holdTransferContent = <div className="mx_LegacyCallView_status">
{ _t(
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
{
@ -494,7 +494,7 @@ export default class CallView extends React.Component<IProps, IState> {
let onHoldText: React.ReactNode;
if (isRemoteOnHold) {
onHoldText = _t(
CallHandler.instance.hasAnyUnheldCall()
LegacyCallHandler.instance.hasAnyUnheldCall()
? _td("You held the call <a>Switch</a>")
: _td("You held the call <a>Resume</a>"),
{},
@ -511,7 +511,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
holdTransferContent = (
<div className="mx_CallView_status">
<div className="mx_LegacyCallView_status">
{ onHoldText }
</div>
);
@ -519,16 +519,16 @@ export default class CallView extends React.Component<IProps, IState> {
return (
<div className={containerClasses} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_holdBackground" style={{ backgroundImage: 'url(' + backgroundAvatarUrl + ')' }} />
<div className="mx_LegacyCallView_holdBackground" style={{ backgroundImage: 'url(' + backgroundAvatarUrl + ')' }} />
{ holdTransferContent }
</div>
);
} else if (call.noIncomingFeeds()) {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_CallView_avatarsContainer">
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_avatarsContainer">
<div
className="mx_CallView_avatarContainer"
className="mx_LegacyCallView_avatarContainer"
style={{ width: avatarSize, height: avatarSize }}
>
<RoomAvatar
@ -538,14 +538,14 @@ export default class CallView extends React.Component<IProps, IState> {
/>
</div>
</div>
<div className="mx_CallView_status">{ _t("Connecting") }</div>
<div className="mx_LegacyCallView_status">{ _t("Connecting") }</div>
{ secondaryFeedElement }
</div>
);
} else if (pipMode) {
return (
<div
className="mx_CallView_content"
className="mx_LegacyCallView_content"
onMouseMove={this.onMouseMove}
>
<VideoFeed
@ -559,7 +559,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
} else if (secondaryFeed) {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<VideoFeed
feed={primaryFeed}
call={call}
@ -572,7 +572,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
} else {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<VideoFeed
feed={primaryFeed}
call={call}
@ -580,7 +580,7 @@ export default class CallView extends React.Component<IProps, IState> {
onResize={onResize}
primary={true}
/>
{ sidebarShown && <CallViewSidebar
{ sidebarShown && <LegacyCallViewSidebar
feeds={sidebarFeeds}
call={call}
pipMode={pipMode}
@ -604,27 +604,27 @@ export default class CallView extends React.Component<IProps, IState> {
} = this.state;
const client = MatrixClientPeg.get();
const callRoomId = CallHandler.instance.roomIdForCall(call);
const secondaryCallRoomId = CallHandler.instance.roomIdForCall(secondaryCall);
const callRoomId = LegacyCallHandler.instance.roomIdForCall(call);
const secondaryCallRoomId = LegacyCallHandler.instance.roomIdForCall(secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
const callViewClasses = classNames({
mx_CallView: true,
mx_CallView_pip: pipMode,
mx_CallView_large: !pipMode,
mx_CallView_sidebar: sidebarShown && sidebarFeeds.length !== 0 && !pipMode,
mx_CallView_belowWidget: showApps, // css to correct the margins if the call is below the AppsDrawer.
mx_LegacyCallView: true,
mx_LegacyCallView_pip: pipMode,
mx_LegacyCallView_large: !pipMode,
mx_LegacyCallView_sidebar: sidebarShown && sidebarFeeds.length !== 0 && !pipMode,
mx_LegacyCallView_belowWidget: showApps, // css to correct the margins if the call is below the AppsDrawer.
});
return <div className={callViewClasses}>
<CallViewHeader
<LegacyCallViewHeader
onPipMouseDown={onMouseDownOnHeader}
pipMode={pipMode}
callRooms={[callRoom, secCallRoom]}
onMaximize={this.onMaximizeClick}
/>
<div className="mx_CallView_content_wrapper" ref={this.contentWrapperRef}>
<div className="mx_LegacyCallView_content_wrapper" ref={this.contentWrapperRef}>
{ this.renderToast() }
{ this.renderContent() }
{ this.renderCallControls() }

View file

@ -21,7 +21,7 @@ import classNames from "classnames";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import CallContextMenu from "../../context_menus/CallContextMenu";
import LegacyCallContextMenu from "../../context_menus/LegacyCallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import { Alignment } from "../../elements/Tooltip";
import {
@ -49,7 +49,7 @@ interface IButtonProps extends Omit<React.ComponentProps<typeof AccessibleToolti
onClick: (event: React.MouseEvent) => void;
}
const CallViewToggleButton: React.FC<IButtonProps> = ({
const LegacyCallViewToggleButton: React.FC<IButtonProps> = ({
children,
state: isOn,
className,
@ -57,9 +57,9 @@ const CallViewToggleButton: React.FC<IButtonProps> = ({
offLabel,
...props
}) => {
const classes = classNames("mx_CallViewButtons_button", className, {
mx_CallViewButtons_button_on: isOn,
mx_CallViewButtons_button_off: !isOn,
const classes = classNames("mx_LegacyCallViewButtons_button", className, {
mx_LegacyCallViewButtons_button_on: isOn,
mx_LegacyCallViewButtons_button_off: !isOn,
});
return (
@ -78,12 +78,12 @@ interface IDropdownButtonProps extends IButtonProps {
deviceKinds: MediaDeviceKindEnum[];
}
const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceKinds, ...props }) => {
const LegacyCallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceKinds, ...props }) => {
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
const [hoveringDropdown, setHoveringDropdown] = useState(false);
const classes = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_dropdownButton", {
mx_CallViewButtons_dropdownButton_collapsed: !menuDisplayed,
const classes = classNames("mx_LegacyCallViewButtons_button", "mx_LegacyCallViewButtons_dropdownButton", {
mx_LegacyCallViewButtons_dropdownButton_collapsed: !menuDisplayed,
});
const onClick = (event: React.MouseEvent): void => {
@ -92,8 +92,8 @@ const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceK
};
return (
<CallViewToggleButton inputRef={buttonRef} forceHide={menuDisplayed || hoveringDropdown} state={state} {...props}>
<CallViewToggleButton
<LegacyCallViewToggleButton inputRef={buttonRef} forceHide={menuDisplayed || hoveringDropdown} state={state} {...props}>
<LegacyCallViewToggleButton
className={classes}
onClick={onClick}
onHover={(hovering) => setHoveringDropdown(hovering)}
@ -105,7 +105,7 @@ const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceK
onFinished={closeMenu}
deviceKinds={deviceKinds}
/> }
</CallViewToggleButton>
</LegacyCallViewToggleButton>
);
};
@ -141,7 +141,7 @@ interface IState {
showMoreMenu: boolean;
}
export default class CallViewButtons extends React.Component<IProps, IState> {
export default class LegacyCallViewButtons extends React.Component<IProps, IState> {
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
@ -212,8 +212,8 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
};
public render(): JSX.Element {
const callControlsClasses = classNames("mx_CallViewButtons", {
mx_CallViewButtons_hidden: !this.state.visible,
const callControlsClasses = classNames("mx_LegacyCallViewButtons", {
mx_LegacyCallViewButtons_hidden: !this.state.visible,
});
let dialPad;
@ -236,7 +236,7 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
contextMenu = <LegacyCallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
@ -258,45 +258,45 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
{ contextMenu }
{ this.props.buttonsVisibility.dialpad && <ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_dialpad"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
/> }
<CallViewDropdownButton
<LegacyCallViewDropdownButton
state={!this.props.buttonsState.micMuted}
className="mx_CallViewButtons_button_mic"
className="mx_LegacyCallViewButtons_button_mic"
onLabel={_t("Mute the microphone")}
offLabel={_t("Unmute the microphone")}
onClick={this.props.handlers.onMicMuteClick}
deviceKinds={[MediaDeviceKindEnum.AudioInput, MediaDeviceKindEnum.AudioOutput]}
/>
{ this.props.buttonsVisibility.vidMute && <CallViewDropdownButton
{ this.props.buttonsVisibility.vidMute && <LegacyCallViewDropdownButton
state={!this.props.buttonsState.vidMuted}
className="mx_CallViewButtons_button_vid"
className="mx_LegacyCallViewButtons_button_vid"
onLabel={_t("Stop the camera")}
offLabel={_t("Start the camera")}
onClick={this.props.handlers.onVidMuteClick}
deviceKinds={[MediaDeviceKindEnum.VideoInput]}
/> }
{ this.props.buttonsVisibility.screensharing && <CallViewToggleButton
{ this.props.buttonsVisibility.screensharing && <LegacyCallViewToggleButton
state={this.props.buttonsState.screensharing}
className="mx_CallViewButtons_button_screensharing"
className="mx_LegacyCallViewButtons_button_screensharing"
onLabel={_t("Stop sharing your screen")}
offLabel={_t("Start sharing your screen")}
onClick={this.props.handlers.onScreenshareClick}
/> }
{ this.props.buttonsVisibility.sidebar && <CallViewToggleButton
{ this.props.buttonsVisibility.sidebar && <LegacyCallViewToggleButton
state={this.props.buttonsState.sidebarShown}
className="mx_CallViewButtons_button_sidebar"
className="mx_LegacyCallViewButtons_button_sidebar"
onLabel={_t("Hide sidebar")}
offLabel={_t("Show sidebar")}
onClick={this.props.handlers.onToggleSidebarClick}
/> }
{ this.props.buttonsVisibility.contextMenu && <ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_more"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
@ -304,7 +304,7 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
alignment={Alignment.Top}
/> }
<AccessibleTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_hangup"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_hangup"
onClick={this.props.handlers.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}

View file

@ -21,26 +21,26 @@ import { _t } from '../../../../languageHandler';
import RoomAvatar from '../../avatars/RoomAvatar';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
interface CallControlsProps {
interface LegacyCallControlsProps {
onExpand?: () => void;
onPin?: () => void;
onMaximize?: () => void;
}
const CallViewHeaderControls: React.FC<CallControlsProps> = ({ onExpand, onPin, onMaximize }) => {
return <div className="mx_CallViewHeader_controls">
const LegacyCallViewHeaderControls: React.FC<LegacyCallControlsProps> = ({ onExpand, onPin, onMaximize }) => {
return <div className="mx_LegacyCallViewHeader_controls">
{ onMaximize && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_fullscreen"
onClick={onMaximize}
title={_t("Fill Screen")}
/> }
{ onPin && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_pin"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_pin"
onClick={onPin}
title={_t("Pin")}
/> }
{ onExpand && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_expand"
onClick={onExpand}
title={_t("Return to call")}
/> }
@ -52,15 +52,15 @@ interface ISecondaryCallInfoProps {
}
const SecondaryCallInfo: React.FC<ISecondaryCallInfoProps> = ({ callRoom }) => {
return <span className="mx_CallViewHeader_secondaryCallInfo">
return <span className="mx_LegacyCallViewHeader_secondaryCallInfo">
<RoomAvatar room={callRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
<span className="mx_LegacyCallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: callRoom.name }) }
</span>
</span>;
};
interface CallViewHeaderProps {
interface LegacyCallViewHeaderProps {
pipMode: boolean;
callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
@ -69,7 +69,7 @@ interface CallViewHeaderProps {
onMaximize?: () => void;
}
const CallViewHeader: React.FC<CallViewHeaderProps> = ({
const LegacyCallViewHeader: React.FC<LegacyCallViewHeaderProps> = ({
pipMode = false,
callRooms = [],
onPipMouseDown,
@ -81,25 +81,25 @@ const CallViewHeader: React.FC<CallViewHeaderProps> = ({
const callRoomName = callRoom.name;
if (!pipMode) {
return <div className="mx_CallViewHeader">
<div className="mx_CallViewHeader_icon" />
<span className="mx_CallViewHeader_text">{ _t("Call") }</span>
<CallViewHeaderControls onMaximize={onMaximize} />
return <div className="mx_LegacyCallViewHeader">
<div className="mx_LegacyCallViewHeader_icon" />
<span className="mx_LegacyCallViewHeader_text">{ _t("Call") }</span>
<LegacyCallViewHeaderControls onMaximize={onMaximize} />
</div>;
}
return (
<div
className="mx_CallViewHeader mx_CallViewHeader_pip"
className="mx_LegacyCallViewHeader mx_LegacyCallViewHeader_pip"
onMouseDown={onPipMouseDown}
>
<RoomAvatar room={callRoom} height={32} width={32} />
<div className="mx_CallViewHeader_callInfo">
<div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
<div className="mx_LegacyCallViewHeader_callInfo">
<div className="mx_LegacyCallViewHeader_roomName">{ callRoomName }</div>
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
</div>
<CallViewHeaderControls onExpand={onExpand} onPin={onPin} onMaximize={onMaximize} />
<LegacyCallViewHeaderControls onExpand={onExpand} onPin={onPin} onMaximize={onMaximize} />
</div>
);
};
export default CallViewHeader;
export default LegacyCallViewHeader;

View file

@ -18,8 +18,8 @@ import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react';
import { Resizable } from "re-resizable";
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import CallView from './CallView';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import LegacyCallView from './LegacyCallView';
import ResizeNotifier from "../../../utils/ResizeNotifier";
interface IProps {
@ -32,14 +32,14 @@ interface IProps {
}
interface IState {
call: MatrixCall;
call: MatrixCall | null;
}
/*
* Wrapper for CallView that always display the call in a given room,
* Wrapper for LegacyCallView that always display the call in a given room,
* or nothing if there is no call in that room.
*/
export default class CallViewForRoom extends React.Component<IProps, IState> {
export default class LegacyCallViewForRoom extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
@ -48,13 +48,13 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
}
public componentDidMount() {
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCall);
CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
}
public componentWillUnmount() {
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCall);
CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
}
private updateCall = () => {
@ -64,8 +64,8 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
}
};
private getCall(): MatrixCall {
const call = CallHandler.instance.getCallForRoom(this.props.roomId);
private getCall(): MatrixCall | null {
const call = LegacyCallHandler.instance.getCallForRoom(this.props.roomId);
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
@ -87,7 +87,7 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
if (!this.state.call) return null;
return (
<div className="mx_CallViewForRoom">
<div className="mx_LegacyCallViewForRoom">
<Resizable
minHeight={380}
maxHeight="80vh"
@ -104,10 +104,10 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
onResizeStart={this.onResizeStart}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_CallViewForRoom_ResizeWrapper"
handleClasses={{ bottom: "mx_CallViewForRoom_ResizeHandle" }}
className="mx_LegacyCallViewForRoom_ResizeWrapper"
handleClasses={{ bottom: "mx_LegacyCallViewForRoom_ResizeHandle" }}
>
<CallView
<LegacyCallView
call={this.state.call}
pipMode={false}
showApps={this.props.showApps}

View file

@ -27,7 +27,7 @@ interface IProps {
pipMode: boolean;
}
export default class CallViewSidebar extends React.Component<IProps> {
export default class LegacyCallViewSidebar extends React.Component<IProps> {
render() {
const feeds = this.props.feeds.map((feed) => {
return (
@ -41,8 +41,8 @@ export default class CallViewSidebar extends React.Component<IProps> {
);
});
const className = classNames("mx_CallViewSidebar", {
mx_CallViewSidebar_pipMode: this.props.pipMode,
const className = classNames("mx_LegacyCallViewSidebar", {
mx_LegacyCallViewSidebar_pipMode: this.props.pipMode,
});
return (

View file

@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room";
import CallView from "./CallView";
import LegacyCallView from "./LegacyCallView";
import { RoomViewStore } from '../../../stores/RoomViewStore';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -31,7 +31,7 @@ import PictureInPictureDragger from './PictureInPictureDragger';
import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import { Container, WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import CallViewHeader from './CallView/CallViewHeader';
import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader';
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore';
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@ -81,7 +81,7 @@ const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room, IApp]
// The primary will be the one not on hold, or an arbitrary one
// if they're all on hold)
function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] {
const calls = CallHandler.instance.getAllActiveCallsForPip(roomId);
const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId);
let primary: MatrixCall = null;
let secondaries: MatrixCall[] = [];
@ -110,7 +110,7 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall
}
/**
* PipView shows a small version of the CallView or a sticky widget hovering over the UI in 'picture-in-picture'
* PipView shows a small version of the LegacyCallView or a sticky widget hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
* and all widgets that are active but not shown in any other possible container.
*/
@ -139,8 +139,8 @@ export default class PipView extends React.Component<IProps, IState> {
}
public componentDidMount() {
CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCalls);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId);
@ -154,8 +154,8 @@ export default class PipView extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
this.roomStoreToken?.remove();
SettingsStore.unwatchSetting(this.settingsWatcherRef);
@ -308,7 +308,7 @@ export default class PipView extends React.Component<IProps, IState> {
if (this.state.primaryCall) {
pipContent = ({ onStartMoving, onResize }) =>
<CallView
<LegacyCallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
@ -329,7 +329,7 @@ export default class PipView extends React.Component<IProps, IState> {
pipContent = ({ onStartMoving, _onResize }) =>
<div className={pipViewClasses}>
<CallViewHeader
<LegacyCallViewHeader
onPipMouseDown={(event) => { onStartMoving(event); this.onStartMoving.bind(this)(); }}
pipMode={pipMode}
callRooms={[roomForWidget]}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import SpaceStore from "./stores/spaces/SpaceStore";
import { makeSpaceParentEvent } from "./utils/space";
import { VIDEO_CHANNEL_MEMBER, addVideoChannel } from "./utils/VideoChannelUtils";
import { JitsiCall } from "./models/Call";
import { Action } from "./dispatcher/actions";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import Spinner from "./components/views/elements/Spinner";
@ -131,8 +131,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.roomType === RoomType.ElementVideo) {
createOpts.power_level_content_override = {
events: {
// Allow all users to send video member updates
[VIDEO_CHANNEL_MEMBER]: 0,
// Allow all users to send call membership updates
[JitsiCall.MEMBER_EVENT_TYPE]: 0,
// Make widgets immutable, even to admins
"im.vector.modular.widgets": 200,
// Annoyingly, we have to reiterate all the defaults here
@ -239,7 +239,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
let modal;
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
let roomId;
let roomId: string;
let room: Promise<Room>;
return client.createRoom(createOpts).catch(function(err) {
// NB This checks for the Synapse-specific error condition of a room creation
// having been denied because the requesting user wanted to publish the room,
@ -254,32 +255,43 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
}
}).finally(function() {
if (modal) modal.close();
}).then(function(res) {
}).then(async res => {
roomId = res.room_id;
if (opts.dmUserId) {
return Rooms.setDMRoom(roomId, opts.dmUserId);
} else {
return Promise.resolve();
}
room = new Promise(resolve => {
const storedRoom = client.getRoom(roomId);
if (storedRoom) {
resolve(storedRoom);
} else {
// The room hasn't arrived down sync yet
const onRoom = (emittedRoom: Room) => {
if (emittedRoom.roomId === roomId) {
resolve(emittedRoom);
client.off(ClientEvent.Room, onRoom);
}
};
client.on(ClientEvent.Room, onRoom);
}
});
if (opts.dmUserId) await Rooms.setDMRoom(roomId, opts.dmUserId);
}).then(() => {
if (opts.parentSpace) {
return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], opts.suggested);
}
}).then(async () => {
if (opts.roomType === RoomType.ElementVideo) {
// Set up video rooms with a Jitsi widget
await addVideoChannel(roomId, createOpts.name);
// Set up video rooms with a Jitsi call
await JitsiCall.create(await room);
// Reset our power level back to admin so that the widget becomes immutable
const room = client.getRoom(roomId);
const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId(), 100, plEvent);
const plEvent = (await room)?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
}
}).then(function() {
// NB createRoom doesn't block on the client seeing the echo that the
// room has been created, so we race here with the client knowing that
// the room exists, causing things like
// https://github.com/vector-im/vector-web/issues/1813
// NB we haven't necessarily blocked on the room promise, so we race
// here with the client knowing that the room exists, causing things
// like https://github.com/vector-im/vector-web/issues/1813
// Even if we were to block on the echo, servers tend to split the room
// state over multiple syncs so we can't atomically know when we have the
// entire thing.

View file

@ -22,12 +22,12 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import EditorStateTransfer from "../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../utils/permalinks/Permalinks";
import CallEventGrouper from "../components/structures/CallEventGrouper";
import LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper";
import { GetRelationsForEvent } from "../components/views/rooms/EventTile";
import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent";
import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion";
import CallEvent from "../components/views/messages/CallEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import RoomCreate from "../components/views/messages/RoomCreate";
@ -57,7 +57,7 @@ export interface EventTileTypeProps {
editState?: EditorStateTransfer;
replacingEventId?: string;
permalinkCreator: RoomPermalinkCreator;
callEventGrouper?: CallEventGrouper;
callEventGrouper?: LegacyCallEventGrouper;
isSeeingThroughMessageHiddenForModeration?: boolean;
timestamp?: JSX.Element;
maxImageHeight?: number; // pixels
@ -71,8 +71,8 @@ type FactoryMap = Record<string, Factory>;
const MessageEventFactory: Factory = (ref, props) => <MessageEvent ref={ref} {...props} />;
const KeyVerificationConclFactory: Factory = (ref, props) => <MKeyVerificationConclusion ref={ref} {...props} />;
const CallEventFactory: Factory<FactoryProps & { callEventGrouper: CallEventGrouper }> = (ref, props) => (
<CallEvent ref={ref} {...props} />
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (
<LegacyCallEvent ref={ref} {...props} />
);
const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
const VerificationReqFactory: Factory = (ref, props) => <MKeyVerificationRequest ref={ref} {...props} />;
@ -89,7 +89,7 @@ const EVENT_TILE_TYPES: FactoryMap = {
[M_POLL_START.altName]: MessageEventFactory,
[EventType.KeyVerificationCancel]: KeyVerificationConclFactory,
[EventType.KeyVerificationDone]: KeyVerificationConclFactory,
[EventType.CallInvite]: CallEventFactory, // note that this requires a special factory type
[EventType.CallInvite]: LegacyCallEventFactory, // note that this requires a special factory type
};
const STATE_EVENT_TILE_TYPES: FactoryMap = {

46
src/hooks/useCall.ts Normal file
View file

@ -0,0 +1,46 @@
/*
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 { useState, useCallback } from "react";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { Call, ConnectionState } from "../models/Call";
import { useTypedEventEmitterState } from "./useEventEmitter";
import { CallEvent } from "../models/Call";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import { useEventEmitter } from "./useEventEmitter";
export const useCall = (roomId: string): Call | null => {
const [call, setCall] = useState(() => CallStore.instance.get(roomId));
useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => {
if (forRoomId === roomId) setCall(call);
});
return call;
};
export const useConnectionState = (call: Call): ConnectionState =>
useTypedEventEmitterState(
call,
CallEvent.ConnectionState,
useCallback(state => state ?? call.connectionState, [call]),
);
export const useParticipants = (call: Call): Set<RoomMember> =>
useTypedEventEmitterState(
call,
CallEvent.Participants,
useCallback(state => state ?? call.participants, [call]),
);

View file

@ -16,40 +16,6 @@
"Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss",
"Call Failed": "Call Failed",
"User Busy": "User Busy",
"The user you called is busy.": "The user you called is busy.",
"The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
"Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK",
"Unable to access microphone": "Unable to access microphone",
"Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access webcam / microphone": "Unable to access webcam / microphone",
"Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Already in call": "Already in call",
"You're already in a call with this person.": "You're already in a call with this person.",
"Calls are unsupported": "Calls are unsupported",
"You cannot place calls in this browser.": "You cannot place calls in this browser.",
"Connectivity to the server has been lost": "Connectivity to the server has been lost",
"You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Unable to transfer call": "Unable to transfer call",
"Transfer Failed": "Transfer Failed",
"Failed to transfer call": "Failed to transfer call",
"Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.",
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
"Upload Failed": "Upload Failed",
@ -92,6 +58,40 @@
"This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.",
"Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.",
"Trust": "Trust",
"Call Failed": "Call Failed",
"User Busy": "User Busy",
"The user you called is busy.": "The user you called is busy.",
"The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
"Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK",
"Unable to access microphone": "Unable to access microphone",
"Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access webcam / microphone": "Unable to access webcam / microphone",
"Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Already in call": "Already in call",
"You're already in a call with this person.": "You're already in a call with this person.",
"Calls are unsupported": "Calls are unsupported",
"You cannot place calls in this browser.": "You cannot place calls in this browser.",
"Connectivity to the server has been lost": "Connectivity to the server has been lost",
"You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Unable to transfer call": "Unable to transfer call",
"Transfer Failed": "Transfer Failed",
"Failed to transfer call": "Failed to transfer call",
"Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"We couldn't log you in": "We couldn't log you in",
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.",
"Try again": "Try again",
@ -1039,6 +1039,18 @@
"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",
"Unmute microphone": "Unmute microphone",
"Video devices": "Video devices",
"Video input %(n)s": "Video input %(n)s",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Join": "Join",
"Dial": "Dial",
"You are presenting": "You are presenting",
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
"Your camera is turned off": "Your camera is turned off",
@ -1049,16 +1061,6 @@
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"Dial": "Dial",
"%(count)s people joined|other": "%(count)s people joined",
"%(count)s people joined|one": "%(count)s person joined",
"Audio devices": "Audio devices",
"Mute microphone": "Mute microphone",
"Unmute microphone": "Unmute microphone",
"Video devices": "Video devices",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Join": "Join",
"Dialpad": "Dialpad",
"Mute the microphone": "Mute the microphone",
"Unmute the microphone": "Unmute the microphone",
@ -1972,6 +1974,11 @@
"%(count)s unread messages.|other": "%(count)s unread messages.",
"%(count)s unread messages.|one": "1 unread message.",
"Unread messages.": "Unread messages.",
"Video": "Video",
"Joining…": "Joining…",
"Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
"This room has already been upgraded.": "This room has already been upgraded.",
"This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.",
@ -1993,11 +2000,6 @@
"Open thread": "Open thread",
"Jump to first unread message.": "Jump to first unread message.",
"Mark all as read": "Mark all as read",
"Video": "Video",
"Joining…": "Joining…",
"Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Unable to access your microphone": "Unable to access your microphone",
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
"No microphone found": "No microphone found",
@ -2155,17 +2157,6 @@
"%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.",
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Call declined": "Call declined",
"Call back": "Call back",
"No answer": "No answer",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s": "Unknown failure: %(reason)s",
"Retry": "Retry",
"Missed call": "Missed call",
"The call is in an unknown state!": "The call is in an unknown state!",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -2196,6 +2187,17 @@
"Message pending moderation": "Message pending moderation",
"Pick a date to jump to": "Pick a date to jump to",
"Go": "Go",
"Call declined": "Call declined",
"Call back": "Call back",
"No answer": "No answer",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s": "Unknown failure: %(reason)s",
"Retry": "Retry",
"Missed call": "Missed call",
"The call is in an unknown state!": "The call is in an unknown state!",
"Error processing audio message": "Error processing audio message",
"View live location": "View live location",
"React": "React",
@ -3018,11 +3020,11 @@
"Observe only": "Observe only",
"No verification requests found": "No verification requests found",
"There was an error finding this widget.": "There was an error finding this widget.",
"Resume": "Resume",
"Hold": "Hold",
"Input devices": "Input devices",
"Output devices": "Output devices",
"Cameras": "Cameras",
"Resume": "Resume",
"Hold": "Hold",
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
"Open in OpenStreetMap": "Open in OpenStreetMap",
"Forward": "Forward",

539
src/models/Call.ts Normal file
View file

@ -0,0 +1,539 @@
/*
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 { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { IWidgetApiRequest } from "matrix-widget-api";
import type EventEmitter from "events";
import type { IMyDevice } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
import type { IApp } from "../stores/WidgetStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType";
import { ElementWidgetActions } from "../stores/widgets/ElementWidgetActions";
import WidgetStore from "../stores/WidgetStore";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "../stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidgetStore";
const TIMEOUT_MS = 16000;
// Waits until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
let listener: (...args) => void;
const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener);
});
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener);
if (timedOut) throw new Error("Timed out");
};
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
export const isConnected = (state: ConnectionState): boolean =>
state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
export enum CallEvent {
ConnectionState = "connection_state",
Participants = "participants",
Destroy = "destroy",
}
interface CallEventHandlerMap {
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
[CallEvent.Participants]: (participants: Set<RoomMember>) => void;
[CallEvent.Destroy]: () => void;
}
interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
/**
* A group call accessed through a widget.
*/
export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
private _messaging: ClientWidgetApi | null = null;
/**
* The widget's messaging, or null if disconnected.
*/
protected get messaging(): ClientWidgetApi | null {
return this._messaging;
}
private set messaging(value: ClientWidgetApi | null) {
this._messaging = value;
}
public get roomId(): string {
return this.widget.roomId;
}
private _connectionState: ConnectionState = ConnectionState.Disconnected;
public get connectionState(): ConnectionState {
return this._connectionState;
}
protected set connectionState(value: ConnectionState) {
const prevValue = this._connectionState;
this._connectionState = value;
this.emit(CallEvent.ConnectionState, value, prevValue);
}
public get connected(): boolean {
return isConnected(this.connectionState);
}
private _participants = new Set<RoomMember>();
public get participants(): Set<RoomMember> {
return this._participants;
}
protected set participants(value: Set<RoomMember>) {
this._participants = value;
this.emit(CallEvent.Participants, value);
}
constructor(
/**
* The widget used to access this call.
*/
public readonly widget: IApp,
) {
super();
}
/**
* Gets the call associated with the given room, if any.
* @param {Room} room The room.
* @returns {Call | null} The call.
*/
public static get(room: Room): Call | null {
// There's currently only one implementation
return JitsiCall.get(room);
}
/**
* Performs a routine check of the call's associated room state, cleaning up
* any data left over from an unclean disconnection.
*/
public abstract clean(): Promise<void>;
/**
* Contacts the widget to connect to the call.
* @param {MediaDeviceInfo | null} audioDevice The audio input to use, or
* null to start muted.
* @param {MediaDeviceInfo | null} audioDevice The video input to use, or
* null to start muted.
*/
protected abstract performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void>;
/**
* Contacts the widget to disconnect from the call.
*/
protected abstract performDisconnection(): Promise<void>;
/**
* Connects the user to the call using the media devices set in
* MediaDeviceHandler. The widget associated with the call must be active
* for this to succeed.
*/
public async connect(): Promise<void> {
this.connectionState = ConnectionState.Connecting;
const {
[MediaDeviceKindEnum.AudioInput]: audioInputs,
[MediaDeviceKindEnum.VideoInput]: videoInputs,
} = await MediaDeviceHandler.getDevices();
let audioInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithAudioMuted) {
const deviceId = MediaDeviceHandler.getAudioInput();
audioInput = audioInputs.find(d => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
}
let videoInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithVideoMuted) {
const deviceId = MediaDeviceHandler.getVideoInput();
videoInput = videoInputs.find(d => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
}
const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid);
if (!this.messaging) {
// The widget might still be initializing, so wait for it
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.StoreMessaging,
(uid: string, widgetApi: ClientWidgetApi) => {
if (uid === this.widgetUid) {
this.messaging = widgetApi;
return true;
}
return false;
},
);
} catch (e) {
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
}
}
try {
await this.performConnection(audioInput, videoInput);
} catch (e) {
this.connectionState = ConnectionState.Disconnected;
throw e;
}
this.connectionState = ConnectionState.Connected;
}
/**
* Disconnects the user from the call.
*/
public async disconnect(): Promise<void> {
if (this.connectionState !== ConnectionState.Connected) throw new Error("Not connected");
this.connectionState = ConnectionState.Disconnecting;
await this.performDisconnection();
this.setDisconnected();
}
/**
* Manually marks the call as disconnected and cleans up.
*/
public setDisconnected() {
this.messaging = null;
this.connectionState = ConnectionState.Disconnected;
}
/**
* Stops all internal timers and tasks to prepare for garbage collection.
*/
public destroy() {
if (this.connected) this.setDisconnected();
this.emit(CallEvent.Destroy);
}
}
/**
* A group call using Jitsi as a backend.
*/
export class JitsiCall extends Call {
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private room: Room = this.client.getRoom(this.roomId)!;
private resendDevicesTimer: number | null = null;
private participantsExpirationTimer: number | null = null;
private constructor(widget: IApp, private readonly client: MatrixClient) {
super(widget);
this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.updateParticipants();
}
public static get(room: Room): JitsiCall | null {
const apps = WidgetStore.instance.getApps(room.roomId);
// The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null;
}
public static async create(room: Room): Promise<void> {
await WidgetUtils.addJitsiWidget(room.roomId, CallType.Video, "Group call", true, room.name);
}
private updateParticipants() {
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
const members = new Set<RoomMember>();
const now = Date.now();
let allExpireAt = Infinity;
for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
const member = this.room.getMember(e.getStateKey()!);
const content = e.getContent<JitsiCallMemberContent>();
let devices = Array.isArray(content.devices) ? content.devices : [];
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
// Apply local echo for the disconnected case
if (!this.connected && member?.userId === this.client.getUserId()) {
devices = devices.filter(d => d !== this.client.getDeviceId());
}
// Must have a connected device, be unexpired, and still be joined to the room
if (devices.length && expiresAt > now && member?.membership === "join") {
members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
}
}
// Apply local echo for the connected case
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
this.participants = members;
if (allExpireAt < Infinity) {
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
}
}
// Helper method that updates our member state with the devices returned by
// the given function. If it returns null, the update is skipped.
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
if (this.room.getMyMembership() !== "join") return;
const devicesState = this.room.currentState.getStateEvents(
JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
);
const devices = devicesState?.getContent<JitsiCallMemberContent>().devices ?? [];
const newDevices = fn(devices);
if (newDevices) {
const content: JitsiCallMemberContent = {
devices: newDevices,
expires_ts: Date.now() + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
};
await this.client.sendStateEvent(
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
);
}
}
private async addOurDevice(): Promise<void> {
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
}
private async removeOurDevice(): Promise<void> {
await this.updateDevices(devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.client.getDeviceId());
return Array.from(devicesSet);
});
}
public async clean(): Promise<void> {
const now = Date.now();
const { devices: myDevices } = await this.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
// Clean up our member state by filtering out logged out devices,
// inactive devices, and our own device (if we're disconnected)
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts
&& !(d === this.client.getDeviceId() && !this.connected)
&& (now - device.last_seen_ts) < JitsiCall.STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
const listener = (uid: string) => {
if (uid === this.widgetUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = () => {
cleanup();
resolve();
};
const cleanup = () => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(CallEvent.ConnectionState, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(CallEvent.ConnectionState, done);
});
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we need
// to add the hangup listener now rather than later
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
try {
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (this.messaging!.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
}
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
}
protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try {
await Promise.all([request, response]);
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected() {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
super.setDisconnected();
}
public destroy() {
this.room.off(RoomStateEvent.Update, this.updateParticipants);
this.on(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
if (this.resendDevicesTimer !== null) {
clearInterval(this.resendDevicesTimer);
this.resendDevicesTimer = null;
}
super.destroy();
}
private onRoomState = () => this.updateParticipants();
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) {
this.updateParticipants();
// Tell others that we're connected, by adding our device to room state
await this.addOurDevice();
// Re-add this device every so often so our video member event doesn't become stale
this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`);
await this.addOurDevice();
}, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
} else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
this.updateParticipants();
clearInterval(this.resendDevicesTimer);
this.resendDevicesTimer = null;
// Tell others that we're disconnected, by removing our device from room state
await this.removeOurDevice();
}
};
private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.messaging!.transport.send(ElementWidgetActions.TileLayout, {});
};
private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
private onMyMembership = async (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private beforeUnload = () => this.setDisconnected();
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (this.connectionState === ConnectionState.Connecting) {
await waitForEvent(this, CallEvent.ConnectionState);
}
await this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
};
}

View file

@ -962,9 +962,9 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false,
},
"videoChannelRoomId": {
"activeCallRoomIds": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: null,
default: [],
},
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,

View file

@ -449,7 +449,12 @@ export default class SettingsStore {
*/
/* eslint-enable valid-jsdoc */
public static async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise<void> {
public static async setValue(
settingName: string,
roomId: string | null,
level: SettingLevel,
value: any,
): Promise<void> {
// Verify that the setting is actually a setting
const setting = SETTINGS[settingName];
if (!setting) {

View file

@ -44,6 +44,10 @@ export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<
})(dispatcher);
}
public async start(): Promise<void> {
await this.readyStore.start();
}
get matrixClient(): MatrixClient {
return this.readyStore.mxClient;
}

View file

@ -45,7 +45,11 @@ interface IState {
* reported.
*/
export default class AutoRageshakeStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new AutoRageshakeStore();
private static readonly internalInstance = (() => {
const instance = new AutoRageshakeStore();
instance.start();
return instance;
})();
private constructor() {
super(defaultDispatcher, {

View file

@ -37,7 +37,11 @@ interface IState {
}
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new BreadcrumbsStore();
private static readonly internalInstance = (() => {
const instance = new BreadcrumbsStore();
instance.start();
return instance;
})();
private waitingRooms: { roomId: string, addedTs: number }[] = [];

185
src/stores/CallStore.ts Normal file
View file

@ -0,0 +1,185 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Call, CallEvent, ConnectionState } from "../models/Call";
export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Call = "call",
// Signals a change in the active calls
ActiveCalls = "active_calls",
}
export class CallStore extends AsyncStoreWithClient<{}> {
private static _instance: CallStore;
public static get instance(): CallStore {
if (!this._instance) {
this._instance = new CallStore();
this._instance.start();
}
return this._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
protected async onReady(): Promise<any> {
// We assume that the calls present in a room are a function of room
// state and room widgets, so we initialize the room map here and then
// update it whenever those change
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
this.matrixClient.on(ClientEvent.Room, this.onRoom);
this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
// If the room ID of a previously connected call is still in settings at
// this time, that's a sign that we failed to disconnect from it
// properly, and need to clean up after ourselves
const uncleanlyDisconnectedRoomIds = SettingsStore.getValue<string[]>("activeCallRoomIds");
if (uncleanlyDisconnectedRoomIds.length) {
await Promise.all([
...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => {
logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`);
await this.get(uncleanlyDisconnectedRoomId)?.clean();
}),
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []),
]);
}
}
protected async onNotReady(): Promise<any> {
for (const [call, listenerMap] of this.callListeners) {
// It's important that we remove the listeners before destroying the
// call, because otherwise the call's onDestroy callback would fire
// and immediately repopulate the map
for (const [event, listener] of listenerMap) call.off(event, listener);
call.destroy();
}
this.callListeners.clear();
this.calls.clear();
this.activeCalls = new Set();
this.matrixClient.off(ClientEvent.Room, this.onRoom);
this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
}
private _activeCalls: Set<Call> = new Set();
/**
* The calls to which the user is currently connected.
*/
public get activeCalls(): Set<Call> {
return this._activeCalls;
}
private set activeCalls(value: Set<Call>) {
this._activeCalls = value;
this.emit(CallStoreEvent.ActiveCalls, value);
// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, [...value].map(call => call.roomId));
}
private calls = new Map<string, Call>(); // Key is room ID
private callListeners = new Map<Call, Map<CallEvent, (...args: unknown[]) => unknown>>();
private updateRoom(room: Room) {
if (!this.calls.has(room.roomId)) {
const call = Call.get(room);
if (call) {
const onConnectionState = (state: ConnectionState) => {
if (state === ConnectionState.Connected) {
this.activeCalls = new Set([...this.activeCalls, call]);
} else if (state === ConnectionState.Disconnected) {
this.activeCalls = new Set([...this.activeCalls].filter(c => c !== call));
}
};
const onDestroy = () => {
this.calls.delete(room.roomId);
for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener);
this.updateRoom(room);
};
call.on(CallEvent.ConnectionState, onConnectionState);
call.on(CallEvent.Destroy, onDestroy);
this.calls.set(room.roomId, call);
this.callListeners.set(call, new Map<CallEvent, (...args: unknown[]) => unknown>([
[CallEvent.ConnectionState, onConnectionState],
[CallEvent.Destroy, onDestroy],
]));
}
this.emit(CallStoreEvent.Call, call, room.roomId);
}
}
/**
* Gets the call associated with the given room, if any.
* @param {string} roomId The room's ID.
* @returns {Call | null} The call.
*/
public get(roomId: string): Call | null {
return this.calls.get(roomId) ?? null;
}
private onRoom = (room: Room) => this.updateRoom(room);
private onRoomState = (event: MatrixEvent, state: RoomState) => {
// If there's already a call stored for this room, it's understood to
// still be valid until destroyed
if (!this.calls.has(state.roomId)) {
const room = this.matrixClient.getRoom(state.roomId);
// State events can arrive before the room does, when creating a room
if (room !== null) this.updateRoom(room);
}
};
private onWidgets = (roomId: string | null) => {
if (roomId === null) {
// This store happened to start before the widget store was done
// loading all rooms, so we need to initialize each room again
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
} else {
const room = this.matrixClient.getRoom(roomId);
// Widget updates can arrive before the room does, empirically
if (room !== null) this.updateRoom(room);
}
};
}

View file

@ -30,10 +30,14 @@ interface IState {
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new ModalWidgetStore();
private modalInstance: IHandle<void[]> = null;
private openSourceWidgetId: string = null;
private openSourceWidgetRoomId: string = null;
private static readonly internalInstance = (() => {
const instance = new ModalWidgetStore();
instance.start();
return instance;
})();
private modalInstance: IHandle<void[]> | null = null;
private openSourceWidgetId: string | null = null;
private openSourceWidgetRoomId: string | null = null;
private constructor() {
super(defaultDispatcher, {});

View file

@ -92,7 +92,11 @@ const getLocallyCreatedBeaconEventIds = (): string[] => {
return ids;
};
export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
private static internalInstance = new OwnBeaconStore();
private static readonly internalInstance = (() => {
const instance = new OwnBeaconStore();
instance.start();
return instance;
})();
// users beacons, keyed by event type
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<BeaconIdentifier>>();

View file

@ -37,7 +37,11 @@ const KEY_DISPLAY_NAME = "mx_profile_displayname";
const KEY_AVATAR_URL = "mx_profile_avatar_url";
export class OwnProfileStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new OwnProfileStore();
private static readonly internalInstance = (() => {
const instance = new OwnProfileStore();
instance.start();
return instance;
})();
private monitoredUser: User;

View file

@ -26,18 +26,19 @@ import { Action } from "../dispatcher/actions";
export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable {
protected matrixClient: MatrixClient;
private readonly dispatcherRef: string;
private dispatcherRef: string | null = null;
constructor(protected readonly dispatcher: Dispatcher<ActionPayload>) {
super();
}
public async start(): Promise<void> {
this.dispatcherRef = this.dispatcher.register(this.onAction);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// noinspection JSIgnoredPromiseFromCall
this.onReady();
const matrixClient = MatrixClientPeg.get();
if (matrixClient) {
this.matrixClient = matrixClient;
await this.onReady();
}
}
@ -50,7 +51,7 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro
}
public destroy() {
this.dispatcher.unregister(this.dispatcherRef);
if (this.dispatcherRef !== null) this.dispatcher.unregister(this.dispatcherRef);
}
protected async onReady() {

View file

@ -1,355 +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 EventEmitter from "events";
import { logger } from "matrix-js-sdk/src/logger";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
export enum VideoChannelEvent {
StartConnect = "start_connect",
Connect = "connect",
Disconnect = "disconnect",
Participants = "participants",
}
export interface IJitsiParticipant {
avatarURL: string;
displayName: string;
formattedDisplayName: string;
participantId: string;
}
const TIMEOUT_MS = 16000;
// Wait until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
let listener;
const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener);
});
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener);
if (timedOut) throw new Error("Timed out");
};
/*
* Holds information about the currently active video channel.
*/
export default class VideoChannelStore extends AsyncStoreWithClient<null> {
private static _instance: VideoChannelStore;
public static get instance(): VideoChannelStore {
if (!VideoChannelStore._instance) {
VideoChannelStore._instance = new VideoChannelStore();
}
return VideoChannelStore._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
private activeChannel: ClientWidgetApi;
private resendDevicesTimer: number;
// This is persisted to settings so we can detect unclean disconnects
public get roomId(): string | null { return SettingsStore.getValue("videoChannelRoomId"); }
private set roomId(value: string | null) {
SettingsStore.setValue("videoChannelRoomId", null, SettingLevel.DEVICE, value);
}
private get room(): Room { return this.matrixClient.getRoom(this.roomId); }
private _connected = false;
public get connected(): boolean { return this._connected; }
private set connected(value: boolean) { this._connected = value; }
private _participants: IJitsiParticipant[] = [];
public get participants(): IJitsiParticipant[] { return this._participants; }
private set participants(value: IJitsiParticipant[]) { this._participants = value; }
public get audioMuted(): boolean { return SettingsStore.getValue("audioInputMuted"); }
public set audioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public get videoMuted(): boolean { return SettingsStore.getValue("videoInputMuted"); }
public set videoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
public connect = async (
roomId: string,
audioDevice: MediaDeviceInfo | null,
videoDevice: MediaDeviceInfo | null,
) => {
if (this.activeChannel) await this.disconnect();
const jitsi = getVideoChannel(roomId);
if (!jitsi) throw new Error(`No video channel in room ${roomId}`);
const jitsiUid = WidgetUtils.getWidgetUid(jitsi);
const messagingStore = WidgetMessagingStore.instance;
let messaging = messagingStore.getMessagingForUid(jitsiUid);
if (!messaging) {
// The widget might still be initializing, so wait for it
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.StoreMessaging,
(uid: string, widgetApi: ClientWidgetApi) => {
if (uid === jitsiUid) {
messaging = widgetApi;
return true;
}
return false;
},
);
} catch (e) {
throw new Error(`Failed to bind video channel in room ${roomId}: ${e}`);
}
}
// Now that we got the messaging, we need a way to ensure that it doesn't get stopped
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const listener = (uid: string) => {
if (uid === jitsiUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = () => {
cleanup();
resolve();
};
const cleanup = () => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(VideoChannelEvent.Connect, done);
this.off(VideoChannelEvent.Disconnect, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(VideoChannelEvent.Connect, done);
this.on(VideoChannelEvent.Disconnect, done);
});
if (!messagingStore.isWidgetReady(jitsiUid)) {
// Wait for the widget to be ready to receive our join event
try {
await Promise.race([
waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.WidgetReady,
(uid: string) => uid === jitsiUid,
),
dontStopMessaging,
]);
} catch (e) {
throw new Error(`Video channel in room ${roomId} never became ready: ${e}`);
}
}
// Participant data and mute state will come down the event pipeline quickly, so prepare in advance
this.activeChannel = messaging;
this.roomId = roomId;
messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
messaging.on(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
messaging.on(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
messaging.on(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
messaging.on(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we also
// need to add the hangup listener now rather than later
messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.emit(VideoChannelEvent.StartConnect, roomId);
// Actually perform the join
const waitForJoin = waitForEvent(
messaging,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.ack(ev);
return true;
},
);
messaging.transport.send(ElementWidgetActions.JoinCall, {
audioDevice: audioDevice?.label ?? null,
videoDevice: videoDevice?.label ?? null,
});
try {
await Promise.race([waitForJoin, dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.activeChannel = null;
this.roomId = null;
messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
messaging.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
messaging.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
messaging.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
messaging.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
messaging.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (messaging.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
messaging.transport.send(ElementWidgetActions.ForceHangupCall, {});
}
this.emit(VideoChannelEvent.Disconnect, roomId);
throw new Error(`Failed to join call in room ${roomId}: ${e}`);
}
this.connected = true;
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.setDisconnected);
this.emit(VideoChannelEvent.Connect, roomId);
// Tell others that we're connected, by adding our device to room state
await addOurDevice(this.room);
// Re-add this device every so often so our video member event doesn't become stale
this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`);
await addOurDevice(this.room);
}, (STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
};
public disconnect = async () => {
if (!this.activeChannel) throw new Error("Not connected to any video channel");
const waitForDisconnect = waitForEvent(this, VideoChannelEvent.Disconnect);
this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {});
try {
await waitForDisconnect; // onHangup cleans up for us
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
};
public setDisconnected = async () => {
const roomId = this.roomId;
const room = this.room;
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.setDisconnected);
clearInterval(this.resendDevicesTimer);
this.activeChannel = null;
this.roomId = null;
this.connected = false;
this.participants = [];
this.emit(VideoChannelEvent.Disconnect, roomId);
// Tell others that we're disconnected, by removing our device from room state
await removeOurDevice(room);
};
private ack = (ev: CustomEvent<IWidgetApiRequest>, messaging = this.activeChannel) => {
// Even if we don't have a reply to a given widget action, we still need
// to give the widget API something to acknowledge receipt
messaging.transport.reply(ev.detail, {});
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
const messaging = this.activeChannel;
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (!this.connected) await waitForEvent(this, VideoChannelEvent.Connect);
await this.setDisconnected();
this.ack(ev, messaging);
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants);
this.ack(ev);
};
private onMuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = true;
this.ack(ev);
};
private onUnmuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = false;
this.ack(ev);
};
private onMuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = true;
this.ack(ev);
};
private onUnmuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = false;
this.ack(ev);
};
private onMyMembership = (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.activeChannel.transport.send(ElementWidgetActions.TileLayout, {});
};
private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.activeChannel.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
}

View file

@ -33,10 +33,11 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): VoiceRecordingStore {
if (!VoiceRecordingStore.internalInstance) {
VoiceRecordingStore.internalInstance = new VoiceRecordingStore();
if (!this.internalInstance) {
this.internalInstance = new VoiceRecordingStore();
this.internalInstance.start();
}
return VoiceRecordingStore.internalInstance;
return this.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {

View file

@ -36,7 +36,7 @@ export interface IApp extends IWidget {
roomId: string;
eventId: string;
// eslint-disable-next-line camelcase
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
}
interface IRoomWidgets {
@ -46,7 +46,11 @@ interface IRoomWidgets {
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new WidgetStore();
private static readonly internalInstance = (() => {
const instance = new WidgetStore();
instance.start();
return instance;
})();
private widgetMap = new Map<string, IApp>(); // Key is widget Unique ID (UID)
private roomMap = new Map<string, IRoomWidgets>(); // Key is room ID

View file

@ -44,10 +44,11 @@ export class EchoStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): EchoStore {
if (!EchoStore._instance) {
EchoStore._instance = new EchoStore();
if (!this._instance) {
this._instance = new EchoStore();
this._instance.start();
}
return EchoStore._instance;
return this._instance;
}
public get contexts(): EchoContext[] {

View file

@ -34,7 +34,11 @@ interface IState {}
export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator");
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private static readonly internalInstance = (() => {
const instance = new RoomNotificationStateStore();
instance.start();
return instance;
})();
private roomMap = new Map<Room, RoomNotificationState>();
private roomThreadsMap = new Map<Room, ThreadsRoomNotificationState>();

View file

@ -394,10 +394,11 @@ export default class RightPanelStore extends ReadyWatchingStore {
}
public static get instance(): RightPanelStore {
if (!RightPanelStore.internalInstance) {
RightPanelStore.internalInstance = new RightPanelStore();
if (!this.internalInstance) {
this.internalInstance = new RightPanelStore();
this.internalInstance.start();
}
return RightPanelStore.internalInstance;
return this.internalInstance;
}
}

View file

@ -25,9 +25,9 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { PollStartEventPreview } from "./previews/PollStartEventPreview";
import { TagID } from "./models";
import { CallInviteEventPreview } from "./previews/CallInviteEventPreview";
import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview";
import { CallHangupEvent } from "./previews/CallHangupEvent";
import { LegacyCallInviteEventPreview } from "./previews/LegacyCallInviteEventPreview";
import { LegacyCallAnswerEventPreview } from "./previews/LegacyCallAnswerEventPreview";
import { LegacyCallHangupEvent } from "./previews/LegacyCallHangupEvent";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
@ -47,15 +47,15 @@ const PREVIEWS: Record<string, {
},
'm.call.invite': {
isState: false,
previewer: new CallInviteEventPreview(),
previewer: new LegacyCallInviteEventPreview(),
},
'm.call.answer': {
isState: false,
previewer: new CallAnswerEventPreview(),
previewer: new LegacyCallAnswerEventPreview(),
},
'm.call.hangup': {
isState: false,
previewer: new CallHangupEvent(),
previewer: new LegacyCallHangupEvent(),
},
'm.sticker': {
isState: false,
@ -87,7 +87,11 @@ interface IState {
}
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new MessagePreviewStore();
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
instance.start();
return instance;
})();
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID|TAG_ANY, string|null>>();

View file

@ -34,8 +34,9 @@ export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new RoomListLayoutStore();
this.internalInstance.start();
}
return RoomListLayoutStore.internalInstance;
}

View file

@ -602,11 +602,13 @@ export default class RoomListStore {
private static internalInstance: Interface;
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new RoomListStoreClass();
if (!this.internalInstance) {
const instance = new RoomListStoreClass();
instance.start();
this.internalInstance = instance;
}
return RoomListStore.internalInstance;
return this.internalInstance;
}
}

View file

@ -34,7 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import { VisibilityProvider } from "../filters/VisibilityProvider";
import VideoChannelStore, { VideoChannelEvent } from "../../VideoChannelStore";
import { CallStore, CallStoreEvent } from "../../CallStore";
/**
* Fired when the Algorithm has determined a list has been updated.
@ -82,13 +82,11 @@ export class Algorithm extends EventEmitter {
public updatesInhibited = false;
public start() {
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public stop() {
VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public get stickyRoom(): Room {
@ -106,7 +104,7 @@ export class Algorithm extends EventEmitter {
protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val;
this.recalculateStickyRoom();
this.recalculateVideoRoom();
this.recalculateActiveCallRooms();
}
protected get cachedRooms(): ITagMap {
@ -143,7 +141,7 @@ export class Algorithm extends EventEmitter {
algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
public getListOrdering(tagId: TagID): ListAlgorithm {
@ -162,7 +160,7 @@ export class Algorithm extends EventEmitter {
algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
private updateStickyRoom(val: Room) {
@ -279,22 +277,20 @@ export class Algorithm extends EventEmitter {
// a room while filtering and it'll disappear. We don't update the filter earlier in
// this function simply because we don't have to.
this.recalculateStickyRoom();
this.recalculateVideoRoom(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateVideoRoom(lastStickyRoom.tag);
this.recalculateActiveCallRooms(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Update the stickiness of video rooms.
*/
public updateVideoRoom = () => {
// In case we're unsticking a video room, sort it back into natural order
private onActiveCalls = () => {
// In case we're unsticking a room, sort it back into natural order
this.recalculateStickyRoom();
this.recalculateVideoRoom();
// Update the stickiness of rooms with calls
this.recalculateActiveCallRooms();
if (this.updatesInhibited) return;
// This isn't in response to any particular RoomListStore update,
@ -358,16 +354,16 @@ export class Algorithm extends EventEmitter {
}
/**
* Recalculate the position of any video rooms. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* Recalculate the position of any rooms with calls. If this is being called in
* relation to a specific tag being updated, it should be given to this function to
* optimize the call.
*
* This expects to be called *after* the sticky rooms are updated, and sticks the
* currently connected video room to the top of its tag.
* room with the currently active call to the top of its tag.
*
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateVideoRoom(updatedTag: TagID = null): void {
protected recalculateActiveCallRooms(updatedTag: TagID = null): void {
if (!updatedTag) {
// Assume all tags need updating
// We're not modifying the map here, so can safely rely on the cached values
@ -376,24 +372,26 @@ export class Algorithm extends EventEmitter {
if (!tagId) {
throw new Error("Unexpected recursion: falsy tag");
}
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
return;
}
const videoRoomId = VideoChannelStore.instance.connected ? VideoChannelStore.instance.roomId : null;
if (videoRoomId) {
// We operate directly on the sticky rooms map
if (CallStore.instance.activeCalls.size) {
// We operate on the sticky rooms map
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
const rooms = this._cachedStickyRooms[updatedTag];
const videoRoomIdxInTag = rooms.findIndex(r => r.roomId === videoRoomId);
if (videoRoomIdxInTag < 0) return; // no-op
const videoRoom = rooms[videoRoomIdxInTag];
rooms.splice(videoRoomIdxInTag, 1);
rooms.unshift(videoRoom);
this._cachedStickyRooms[updatedTag] = rooms; // re-set because references aren't always safe
const activeRoomIds = new Set([...CallStore.instance.activeCalls].map(call => call.roomId));
const activeRooms: Room[] = [];
const inactiveRooms: Room[] = [];
for (const room of rooms) {
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
}
// Stick rooms with active calls to the top
this._cachedStickyRooms[updatedTag] = [...activeRooms, ...inactiveRooms];
}
}
@ -666,7 +664,7 @@ export class Algorithm extends EventEmitter {
algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
this.recalculateVideoRoom(rmTag);
this.recalculateActiveCallRooms(rmTag);
}
for (const addTag of diff.added) {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
@ -742,7 +740,7 @@ export class Algorithm extends EventEmitter {
// Flag that we've done something
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tag);
this.recalculateActiveCallRooms(tag);
changed = true;
}

View file

@ -16,7 +16,7 @@
import { Room } from "matrix-js-sdk/src/models/room";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import VoipUserMapper from "../../../VoipUserMapper";
@ -44,7 +44,7 @@ export class VisibilityProvider {
}
if (
CallHandler.instance.getSupportsVirtualRooms() &&
LegacyCallHandler.instance.getSupportsVirtualRooms() &&
VoipUserMapper.sharedInstance().isVirtualRoom(room)
) {
return false;

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallAnswerEventPreview implements IPreview {
export class LegacyCallAnswerEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallHangupEvent implements IPreview {
export class LegacyCallHangupEvent implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallInviteEventPreview implements IPreview {
export class LegacyCallInviteEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -1284,7 +1284,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
export default class SpaceStore {
private static internalInstance = new SpaceStoreClass();
private static readonly internalInstance = (() => {
const instance = new SpaceStoreClass();
instance.start();
return instance;
})();
public static get instance(): SpaceStoreClass {
return SpaceStore.internalInstance;

View file

@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020-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.
@ -17,13 +17,9 @@
import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
WidgetReady = "io.element.widget_ready",
// All of these actions are currently specific to Jitsi
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
ForceHangupCall = "io.element.force_hangup",
CallParticipants = "io.element.participants",
MuteAudio = "io.element.mute_audio",
UnmuteAudio = "io.element.unmute_audio",

View file

@ -114,10 +114,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
}
public static get instance(): WidgetLayoutStore {
if (!WidgetLayoutStore.internalInstance) {
WidgetLayoutStore.internalInstance = new WidgetLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new WidgetLayoutStore();
this.internalInstance.start();
}
return WidgetLayoutStore.internalInstance;
return this.internalInstance;
}
public static emissionForRoom(room: Room): string {

View file

@ -14,9 +14,8 @@
* limitations under the License.
*/
import { ClientWidgetApi, Widget, IWidgetApiRequest } from "matrix-widget-api";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import { ElementWidgetActions } from "./ElementWidgetActions";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
@ -26,7 +25,6 @@ import WidgetUtils from "../../utils/WidgetUtils";
export enum WidgetMessagingStoreEvent {
StoreMessaging = "store_messaging",
StopMessaging = "stop_messaging",
WidgetReady = "widget_ready",
}
/**
@ -34,11 +32,14 @@ export enum WidgetMessagingStoreEvent {
* going to be merged with a more complete WidgetStore, but for now it's
* easiest to split this into a single place.
*/
export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
private static internalInstance = new WidgetMessagingStore();
export class WidgetMessagingStore extends AsyncStoreWithClient<{}> {
private static readonly internalInstance = (() => {
const instance = new WidgetMessagingStore();
instance.start();
return instance;
})();
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget UID, ClientWidgetAPi>
private readyWidgets = new Set<string>(); // widgets that have sent a WidgetReady event
public constructor() {
super(defaultDispatcher);
@ -62,12 +63,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
const uid = WidgetUtils.calcWidgetUid(widget.id, roomId);
this.widgetMap.set(uid, widgetApi);
widgetApi.once(`action:${ElementWidgetActions.WidgetReady}`, (ev: CustomEvent<IWidgetApiRequest>) => {
this.readyWidgets.add(uid);
this.emit(WidgetMessagingStoreEvent.WidgetReady, uid);
widgetApi.transport.reply(ev.detail, {}); // ack
});
this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi);
}
@ -85,7 +80,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
*/
public stopMessagingByUid(widgetUid: string) {
this.widgetMap.remove(widgetUid)?.stop();
this.readyWidgets.delete(widgetUid);
this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid);
}
@ -97,12 +91,4 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
public getMessagingForUid(widgetUid: string): ClientWidgetApi {
return this.widgetMap.get(widgetUid);
}
/**
* @param {string} widgetUid The widget UID.
* @returns {boolean} Whether the widget has issued an ElementWidgetActions.WidgetReady event.
*/
public isWidgetReady(widgetUid: string): boolean {
return this.readyWidgets.has(widgetUid);
}
}

View file

@ -21,14 +21,14 @@ import React from 'react';
import { CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import CallHandler, { CallHandlerEvent } from '../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../LegacyCallHandler';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { _t } from '../languageHandler';
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import AccessibleTooltipButton from '../components/views/elements/AccessibleTooltipButton';
import AccessibleButton from '../components/views/elements/AccessibleButton';
export const getIncomingCallToastKey = (callId: string) => `call_${callId}`;
export const getIncomingLegacyCallToastKey = (callId: string) => `call_${callId}`;
interface IProps {
call: MatrixCall;
@ -38,83 +38,87 @@ interface IState {
silenced: boolean;
}
export default class IncomingCallToast extends React.Component<IProps, IState> {
export default class IncomingLegacyCallToast extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
silenced: CallHandler.instance.isCallSilenced(this.props.call.callId),
silenced: LegacyCallHandler.instance.isCallSilenced(this.props.call.callId),
};
}
public componentDidMount = (): void => {
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
};
public componentWillUnmount(): void {
CallHandler.instance.removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.removeListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
}
private onSilencedCallsChanged = (): void => {
this.setState({ silenced: CallHandler.instance.isCallSilenced(this.props.call.callId) });
this.setState({ silenced: LegacyCallHandler.instance.isCallSilenced(this.props.call.callId) });
};
private onAnswerClick = (e: React.MouseEvent): void => {
e.stopPropagation();
CallHandler.instance.answerCall(CallHandler.instance.roomIdForCall(this.props.call));
LegacyCallHandler.instance.answerCall(LegacyCallHandler.instance.roomIdForCall(this.props.call));
};
private onRejectClick= (e: React.MouseEvent): void => {
e.stopPropagation();
CallHandler.instance.hangupOrReject(CallHandler.instance.roomIdForCall(this.props.call), true);
LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call), true);
};
private onSilenceClick = (e: React.MouseEvent): void => {
e.stopPropagation();
const callId = this.props.call.callId;
this.state.silenced ?
CallHandler.instance.unSilenceCall(callId) :
CallHandler.instance.silenceCall(callId);
LegacyCallHandler.instance.unSilenceCall(callId) :
LegacyCallHandler.instance.silenceCall(callId);
};
public render() {
const call = this.props.call;
const room = MatrixClientPeg.get().getRoom(CallHandler.instance.roomIdForCall(call));
const room = MatrixClientPeg.get().getRoom(LegacyCallHandler.instance.roomIdForCall(call));
const isVoice = call.type === CallType.Voice;
const contentClass = classNames("mx_IncomingCallToast_content", {
"mx_IncomingCallToast_content_voice": isVoice,
"mx_IncomingCallToast_content_video": !isVoice,
const contentClass = classNames("mx_IncomingLegacyCallToast_content", {
"mx_IncomingLegacyCallToast_content_voice": isVoice,
"mx_IncomingLegacyCallToast_content_video": !isVoice,
});
const silenceClass = classNames("mx_IncomingCallToast_iconButton", {
"mx_IncomingCallToast_unSilence": this.state.silenced,
"mx_IncomingCallToast_silence": !this.state.silenced,
const silenceClass = classNames("mx_IncomingLegacyCallToast_iconButton", {
"mx_IncomingLegacyCallToast_unSilence": this.state.silenced,
"mx_IncomingLegacyCallToast_silence": !this.state.silenced,
});
return <React.Fragment>
<RoomAvatar
room={room}
room={room ?? undefined}
height={32}
width={32}
/>
<div className={contentClass}>
<span className="mx_CallEvent_caller">
<span className="mx_LegacyCallEvent_caller">
{ room ? room.name : _t("Unknown caller") }
</span>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
<div className="mx_LegacyCallEvent_type">
<div className="mx_LegacyCallEvent_type_icon" />
{ isVoice ? _t("Voice call") : _t("Video call") }
</div>
<div className="mx_IncomingCallToast_buttons">
<div className="mx_IncomingLegacyCallToast_buttons">
<AccessibleButton
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_decline"
className="mx_IncomingLegacyCallToast_button mx_IncomingLegacyCallToast_button_decline"
onClick={this.onRejectClick}
kind="danger"
>
<span> { _t("Decline") } </span>
</AccessibleButton>
<AccessibleButton
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_accept"
className="mx_IncomingLegacyCallToast_button mx_IncomingLegacyCallToast_button_accept"
onClick={this.onAnswerClick}
kind="primary"
>

View file

@ -1,204 +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 { useState, useMemo, useEffect } from "react";
import { throttle } from "lodash";
import { Optional } from "matrix-events-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { IMyDevice } from "matrix-js-sdk/src/client";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import WidgetStore, { IApp } from "../stores/WidgetStore";
import { WidgetType } from "../widgets/WidgetType";
import WidgetUtils from "./WidgetUtils";
import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../stores/VideoChannelStore";
interface IVideoChannelMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
export const VIDEO_CHANNEL_MEMBER = "io.element.video.member";
export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
}
export const getVideoChannel = (roomId: string): IApp => {
const apps = WidgetStore.instance.getApps(roomId);
return apps.find(app => WidgetType.JITSI.matches(app.type) && app.data.isVideoChannel);
};
export const addVideoChannel = async (roomId: string, roomName: string) => {
await WidgetUtils.addJitsiWidget(roomId, CallType.Video, "Video channel", true, roomName);
};
// Gets the members connected to a given video room, along with a timestamp
// indicating when this data should be considered stale
const getConnectedMembers = (room: Room, connectedLocalEcho: boolean): [Set<RoomMember>, number] => {
const members = new Set<RoomMember>();
const now = Date.now();
let allExpireAt = Infinity;
for (const e of room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER)) {
const member = room.getMember(e.getStateKey());
const content = e.getContent<IVideoChannelMemberContent>();
let devices = Array.isArray(content.devices) ? content.devices : [];
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
// Ignore events with a timeout that's way off in the future
const inTheFuture = (expiresAt - ((STUCK_DEVICE_TIMEOUT_MS * 5) / 4)) > now;
const expired = expiresAt <= now || inTheFuture;
// Apply local echo for the disconnected case
if (!connectedLocalEcho && member?.userId === room.client.getUserId()) {
devices = devices.filter(d => d !== room.client.getDeviceId());
}
// Must have a device connected, be unexpired, and still be joined to the room
if (devices.length && !expired && member?.membership === "join") {
members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
}
}
// Apply local echo for the connected case
if (connectedLocalEcho) members.add(room.getMember(room.client.getUserId()));
return [members, allExpireAt];
};
export const useConnectedMembers = (
room: Room, connectedLocalEcho: boolean, throttleMs = 100,
): Set<RoomMember> => {
const [[members, expiresAt], setState] = useState(() => getConnectedMembers(room, connectedLocalEcho));
const updateState = useMemo(() => throttle(() => {
setState(getConnectedMembers(room, connectedLocalEcho));
}, throttleMs, { leading: true, trailing: true }), [setState, room, connectedLocalEcho, throttleMs]);
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, updateState);
useEffect(() => {
if (expiresAt < Infinity) {
const timer = setTimeout(() => {
logger.log(`Refreshing video members for ${room.roomId}`);
updateState();
}, expiresAt - Date.now());
return () => clearTimeout(timer);
}
}, [expiresAt, updateState, room.roomId]);
return members;
};
export const useJitsiParticipants = (room: Room): IJitsiParticipant[] => {
const store = VideoChannelStore.instance;
const [participants, setParticipants] = useState(() =>
store.connected && store.roomId === room.roomId ? store.participants : [],
);
useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => {
if (roomId === room.roomId) setParticipants([]);
});
useEventEmitter(store, VideoChannelEvent.Participants, (roomId: string, participants: IJitsiParticipant[]) => {
if (roomId === room.roomId) setParticipants(participants);
});
return participants;
};
const updateDevices = async (room: Optional<Room>, fn: (devices: string[] | null) => string[]) => {
if (room?.getMyMembership() !== "join") return;
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, room.client.getUserId());
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];
const newDevices = fn(devices);
if (newDevices) {
const content: IVideoChannelMemberContent = {
devices: newDevices,
expires_ts: Date.now() + STUCK_DEVICE_TIMEOUT_MS,
};
await room.client.sendStateEvent(room.roomId, VIDEO_CHANNEL_MEMBER, content, room.client.getUserId());
}
};
export const addOurDevice = async (room: Room) => {
await updateDevices(room, devices => Array.from(new Set(devices).add(room.client.getDeviceId())));
};
export const removeOurDevice = async (room: Room) => {
await updateDevices(room, devices => {
const devicesSet = new Set(devices);
devicesSet.delete(room.client.getDeviceId());
return Array.from(devicesSet);
});
};
/**
* Fixes devices that may have gotten stuck in video channel member state after
* an unclean disconnection, by filtering out logged out devices, inactive
* devices, and our own device (if we're disconnected).
* @param {Room} room The room to fix
* @param {boolean} connectedLocalEcho Local echo of whether this device is connected
*/
export const fixStuckDevices = async (room: Room, connectedLocalEcho: boolean) => {
const now = Date.now();
const { devices: myDevices } = await room.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
await updateDevices(room, devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts
&& !(d === room.client.getDeviceId() && !connectedLocalEcho)
&& (now - device.last_seen_ts) < STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
};
export const useConnectionState = (room: Room): ConnectionState => {
const store = VideoChannelStore.instance;
const [state, setState] = useState(() =>
store.roomId === room.roomId
? store.connected
? ConnectionState.Connected
: ConnectionState.Connecting
: ConnectionState.Disconnected,
);
useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Disconnected);
});
useEventEmitter(store, VideoChannelEvent.StartConnect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Connecting);
});
useEventEmitter(store, VideoChannelEvent.Connect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Connected);
});
return state;
};

View file

@ -19,9 +19,9 @@ import { CallEvent, CallState, CallType } from 'matrix-js-sdk/src/webrtc/call';
import EventEmitter from 'events';
import { mocked } from 'jest-mock';
import CallHandler, {
CallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL,
} from '../src/CallHandler';
import LegacyCallHandler, {
LegacyCallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL,
} from '../src/LegacyCallHandler';
import { stubClient, mkStubRoom, untilDispatch } from './test-utils';
import { MatrixClientPeg } from '../src/MatrixClientPeg';
import DMRoomMap from '../src/utils/DMRoomMap';
@ -109,7 +109,7 @@ class FakeCall extends EventEmitter {
}
}
function untilCallHandlerEvent(callHandler: CallHandler, event: CallHandlerEvent): Promise<void> {
function untilCallHandlerEvent(callHandler: LegacyCallHandler, event: LegacyCallHandlerEvent): Promise<void> {
return new Promise<void>((resolve) => {
callHandler.addListener(event, () => {
resolve();
@ -117,7 +117,7 @@ function untilCallHandlerEvent(callHandler: CallHandler, event: CallHandlerEvent
});
}
describe('CallHandler', () => {
describe('LegacyCallHandler', () => {
let dmRoomMap;
let callHandler;
let audioElement: HTMLAudioElement;
@ -145,7 +145,7 @@ describe('CallHandler', () => {
});
};
callHandler = new CallHandler();
callHandler = new LegacyCallHandler();
callHandler.start();
mocked(getFunctionalMembers).mockReturnValue([
@ -251,7 +251,7 @@ describe('CallHandler', () => {
callHandler.stop();
DMRoomMap.setShared(null);
// @ts-ignore
window.mxCallHandler = null;
window.mxLegacyCallHandler = null;
fakeCall = null;
MatrixClientPeg.unset();
@ -295,14 +295,14 @@ describe('CallHandler', () => {
it('should move calls between rooms when remote asserted identity changes', async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
await untilCallHandlerEvent(callHandler, CallHandlerEvent.CallState);
await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState);
// We placed the call in Alice's room so it should start off there
expect(callHandler.getCallForRoom(NATIVE_ROOM_ALICE)).toBe(fakeCall);
let callRoomChangeEventCount = 0;
const roomChangePromise = new Promise<void>(resolve => {
callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => {
callHandler.addListener(LegacyCallHandlerEvent.CallChangeRoom, () => {
++callRoomChangeEventCount;
resolve();
});
@ -343,9 +343,9 @@ describe('CallHandler', () => {
});
});
describe('CallHandler without third party protocols', () => {
describe('LegacyCallHandler without third party protocols', () => {
let dmRoomMap;
let callHandler: CallHandler;
let callHandler: LegacyCallHandler;
let audioElement: HTMLAudioElement;
let fakeCall;
@ -363,7 +363,7 @@ describe('CallHandler without third party protocols', () => {
throw new Error("Endpoint unsupported.");
};
callHandler = new CallHandler();
callHandler = new LegacyCallHandler();
callHandler.start();
const nativeRoomAlice = mkStubDM(NATIVE_ROOM_ALICE, NATIVE_ALICE);
@ -406,7 +406,7 @@ describe('CallHandler without third party protocols', () => {
callHandler.stop();
DMRoomMap.setShared(null);
// @ts-ignore
window.mxCallHandler = null;
window.mxLegacyCallHandler = null;
fakeCall = null;
MatrixClientPeg.unset();
@ -417,7 +417,7 @@ describe('CallHandler without third party protocols', () => {
it('should still start a native call', async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
await untilCallHandlerEvent(callHandler, CallHandlerEvent.CallState);
await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState);
// Check that a call was started: its room on the protocol level
// should be the virtual room

View file

@ -23,7 +23,7 @@ import { MatrixClientPeg } from '../src/MatrixClientPeg';
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom';
import { RoomViewStore } from '../src/stores/RoomViewStore';
import SettingsStore from '../src/settings/SettingsStore';
import CallHandler from '../src/CallHandler';
import LegacyCallHandler from '../src/LegacyCallHandler';
describe('SlashCommands', () => {
let client: MatrixClient;
@ -120,7 +120,7 @@ describe('SlashCommands', () => {
describe("isEnabled", () => {
describe("when virtual rooms are supported", () => {
beforeEach(() => {
jest.spyOn(CallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(true);
jest.spyOn(LegacyCallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(true);
});
it("should return true for Room", () => {
@ -136,7 +136,7 @@ describe('SlashCommands', () => {
describe("when virtual rooms are not supported", () => {
beforeEach(() => {
jest.spyOn(CallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(false);
jest.spyOn(LegacyCallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(false);
});
it("should return false for Room", () => {

View file

@ -20,14 +20,14 @@ import { CallState } from "matrix-js-sdk/src/webrtc/call";
import { stubClient } from '../../test-utils';
import { MatrixClientPeg } from '../../../src/MatrixClientPeg';
import CallEventGrouper, { CustomCallState } from "../../../src/components/structures/CallEventGrouper";
import LegacyCallEventGrouper, { CustomCallState } from "../../../src/components/structures/LegacyCallEventGrouper";
const MY_USER_ID = "@me:here";
const THEIR_USER_ID = "@they:here";
let client: MatrixClient;
describe('CallEventGrouper', () => {
describe('LegacyCallEventGrouper', () => {
beforeEach(() => {
stubClient();
client = MatrixClientPeg.get();
@ -37,7 +37,7 @@ describe('CallEventGrouper', () => {
});
it("detects a missed call", () => {
const grouper = new CallEventGrouper();
const grouper = new LegacyCallEventGrouper();
grouper.add({
getContent: () => {
@ -57,8 +57,8 @@ describe('CallEventGrouper', () => {
});
it("detects an ended call", () => {
const grouperHangup = new CallEventGrouper();
const grouperReject = new CallEventGrouper();
const grouperHangup = new LegacyCallEventGrouper();
const grouperReject = new LegacyCallEventGrouper();
grouperHangup.add({
getContent: () => {
@ -119,7 +119,7 @@ describe('CallEventGrouper', () => {
});
it("detects call type", () => {
const grouper = new CallEventGrouper();
const grouper = new LegacyCallEventGrouper();
grouper.add({
getContent: () => {

View file

@ -15,110 +15,103 @@ limitations under the License.
*/
import React from "react";
// eslint-disable-next-line deprecate/import
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";
import { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
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 { MatrixWidgetType } from "matrix-widget-api";
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,
stubVideoChannelStore,
StubVideoChannelStore,
mkRoom,
mkRoomMember,
wrapInMatrixClientContext,
mockStateEventImplementation,
mkVideoChannelMember,
useMockedCalls,
MockedCall,
setupAsyncStoreWithClient,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { VIDEO_CHANNEL_MEMBER } from "../../../src/utils/VideoChannelUtils";
import WidgetStore from "../../../src/stores/WidgetStore";
import _VideoRoomView from "../../../src/components/structures/VideoRoomView";
import VideoLobby from "../../../src/components/views/voip/VideoLobby";
import AppTile from "../../../src/components/views/elements/AppTile";
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(_VideoRoomView);
const VideoRoomView = wrapInMatrixClientContext(UnwrappedVideoRoomView);
describe("VideoRoomView", () => {
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{
id: "1",
eventId: "$1:example.org",
roomId: "!1:example.org",
type: MatrixWidgetType.JitsiMeet,
url: "https://example.org",
name: "Video channel",
creatorUserId: "@alice:example.org",
avatar_url: null,
data: { isVideoChannel: true },
}]);
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", {
value: { enumerateDevices: () => [] },
value: {
enumerateDevices: async () => [],
getUserMedia: () => null,
},
});
let cli: MatrixClient;
let client: Mocked<MatrixClient>;
let room: Room;
let store: StubVideoChannelStore;
let call: Call;
let widget: Widget;
let alice: RoomMember;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.get();
jest.spyOn(WidgetStore.instance, "matrixClient", "get").mockReturnValue(cli);
store = stubVideoChannelStore();
room = mkRoom(cli, "!1:example.org");
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);
});
it("removes stuck devices on mount", async () => {
// Simulate an unclean disconnect
store.roomId = "!1:example.org";
afterEach(() => {
cleanup();
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
const devices: IMyDevice[] = [
{
device_id: cli.getDeviceId(),
last_seen_ts: new Date().valueOf(),
},
{
device_id: "went offline 2 hours ago",
last_seen_ts: new Date().valueOf() - 1000 * 60 * 60 * 2,
},
];
mocked(cli).getDevices.mockResolvedValue({ devices });
const renderView = async (): Promise<void> => {
render(<VideoRoomView room={room} resizing={false} />);
await act(() => Promise.resolve()); // Let effects settle
};
// Make both devices be stuck
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
mkVideoChannelMember(cli.getUserId(), devices.map(d => d.device_id)),
]));
mount(<VideoRoomView room={room} resizing={false} />);
// Wait for state to settle
await act(() => Promise.resolve());
// All devices should have been removed
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
"!1:example.org",
VIDEO_CHANNEL_MEMBER,
{ devices: [], expires_ts: expect.any(Number) },
cli.getUserId(),
);
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 () => {
const view = mount(<VideoRoomView room={room} resizing={false} />);
// Wait for state to settle
await act(() => Promise.resolve());
expect(view.find(VideoLobby).exists()).toEqual(true);
expect(view.find(AppTile).exists()).toEqual(true);
await renderView();
screen.getByRole("button", { name: "Join" });
screen.getAllByText(/\bwidget\b/i);
});
it("only shows widget when connected", async () => {
store.connect("!1:example.org");
const view = mount(<VideoRoomView room={room} resizing={false} />);
// Wait for state to settle
await act(() => Promise.resolve());
expect(view.find(VideoLobby).exists()).toEqual(false);
expect(view.find(AppTile).exists()).toEqual(true);
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);
});
});

View file

@ -93,7 +93,7 @@ describe("AppTile", () => {
url: "https://example.com",
name: "Example 1",
creatorUserId: cli.getUserId(),
avatar_url: null,
avatar_url: undefined,
};
app2 = {
id: "1",
@ -103,7 +103,7 @@ describe("AppTile", () => {
url: "https://example.com",
name: "Example 2",
creatorUserId: cli.getUserId(),
avatar_url: null,
avatar_url: undefined,
};
jest.spyOn(WidgetStore.instance, "getApps").mockImplementation(roomId => {
if (roomId === "r1") return [app1];

View file

@ -3,6 +3,7 @@
exports[`<TooltipTarget /> displays Bottom aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; top: 6px; left: 0px; transform: translate(-50%);"
>
<div
@ -15,6 +16,7 @@ exports[`<TooltipTarget /> displays Bottom aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays InnerBottom aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; top: -50px; left: 0px; transform: translate(-50%);"
>
<div
@ -27,6 +29,7 @@ exports[`<TooltipTarget /> displays InnerBottom aligned tooltip on mouseover 1`]
exports[`<TooltipTarget /> displays Left aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; right: 1030px; top: 0px; transform: translateY(-50%);"
>
<div
@ -39,6 +42,7 @@ exports[`<TooltipTarget /> displays Left aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Natural aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; left: 6px; top: 0px; transform: translateY(-50%);"
>
<div
@ -51,6 +55,7 @@ exports[`<TooltipTarget /> displays Natural aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Right aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; left: 6px; top: 0px; transform: translateY(-50%);"
>
<div
@ -63,6 +68,7 @@ exports[`<TooltipTarget /> displays Right aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Top aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; top: -6px; left: 0px; transform: translate(-50%, -100%);"
>
<div
@ -75,6 +81,7 @@ exports[`<TooltipTarget /> displays Top aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays TopRight aligned tooltip on mouseover 1`] = `
<div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
role="tooltip"
style="display: block; top: -6px; right: 1024px; transform: translateY(-100%);"
>
<div

View file

@ -15,148 +15,131 @@ limitations under the License.
*/
import React from "react";
// eslint-disable-next-line deprecate/import
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { render, screen, act } 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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import type { ClientWidgetApi } from "matrix-widget-api";
import {
stubClient,
mockStateEventImplementation,
mkRoom,
mkVideoChannelMember,
stubVideoChannelStore,
StubVideoChannelStore,
mkRoomMember,
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
} from "../../../test-utils";
import { STUCK_DEVICE_TIMEOUT_MS } from "../../../../src/utils/VideoChannelUtils";
import { CallStore } from "../../../../src/stores/CallStore";
import RoomTile from "../../../../src/components/views/rooms/RoomTile";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { DefaultTagID } from "../../../../src/stores/room-list/models";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import PlatformPeg from "../../../../src/PlatformPeg";
import BasePlatform from "../../../../src/BasePlatform";
const mockGetMember = (room: Room, getMembership: (userId: string) => string = () => "join") => {
mocked(room).getMember.mockImplementation(userId => ({
userId,
membership: getMembership(userId),
name: userId,
rawDisplayName: userId,
roomId: "!1:example.org",
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
}) as unknown as RoomMember);
};
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, 'get')
jest.spyOn(PlatformPeg, "get")
.mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform);
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", {
value: { enumerateDevices: async () => [] },
});
let client: Mocked<MatrixClient>;
let cli: MatrixClient;
let store: StubVideoChannelStore;
beforeEach(() => {
const realGetValue = SettingsStore.getValue;
SettingsStore.getValue = <T, >(name: string, roomId?: string): T => {
if (name === "feature_video_rooms") {
return true as unknown as T;
}
return realGetValue(name, roomId);
};
stubClient();
cli = MatrixClientPeg.get();
store = stubVideoChannelStore();
client = mocked(MatrixClientPeg.get());
DMRoomMap.makeShared();
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
describe("video rooms", () => {
describe("call subtitle", () => {
let room: Room;
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
room = mkRoom(cli, "!1:example.org");
mocked(room.isElementVideoRoom).mockReturnValue(true);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
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) as MockedCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
render(
<RoomTile
room={room}
showMessagePreview={false}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>,
);
});
const mountTile = () => mount(
<RoomTile
room={room}
showMessagePreview={false}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>,
);
it("tracks connection state", () => {
const tile = mountTile();
expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Video");
act(() => { store.startConnect("!1:example.org"); });
tile.update();
expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Joining…");
act(() => { store.connect("!1:example.org"); });
tile.update();
expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Joined");
act(() => { store.disconnect(); });
tile.update();
expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Video");
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("displays connected members", () => {
mockGetMember(room, userId => userId === "@chris:example.org" ? "leave" : "join");
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
// A user connected from 2 devices
mkVideoChannelMember("@alice:example.org", ["device 1", "device 2"]),
// A disconnected user
mkVideoChannelMember("@bob:example.org", []),
// A user that claims to have a connected device, but has left the room
mkVideoChannelMember("@chris:example.org", ["device 1"]),
]));
it("tracks connection state", async () => {
screen.getByText("Video");
const tile = mountTile();
// Insert an await point in the connection method so we can inspect
// the intermediate connecting state
let completeConnection: () => void;
const connectionCompleted = new Promise<void>(resolve => completeConnection = resolve);
jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted);
// Only Alice should display as connected
expect(tile.find(".mx_VideoRoomSummary_participants").text()).toEqual("1");
await Promise.all([
(async () => {
await screen.findByText("Joining…");
const joinedFound = screen.findByText("Joined");
completeConnection();
await joinedFound;
})(),
call.connect(),
]);
await Promise.all([
screen.findByText("Video"),
call.disconnect(),
]);
});
it("reflects local echo in connected members", () => {
mockGetMember(room);
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
// Make the remote echo claim that we're connected, while leaving the store disconnected
mkVideoChannelMember(cli.getUserId(), [cli.getDeviceId()]),
]));
it("tracks participants", () => {
const alice = mkRoomMember(room.roomId, "@alice:example.org");
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
const tile = mountTile();
expect(screen.queryByLabelText(/participant/)).toBe(null);
// Because of our local echo, we should still appear as disconnected
expect(tile.find(".mx_VideoRoomSummary_participants").exists()).toEqual(false);
});
act(() => { call.participants = new Set([alice]); });
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
it("doesn't count members whose device data has expired", () => {
jest.useFakeTimers();
jest.setSystemTime(0);
act(() => { call.participants = new Set([alice, bob, carol]); });
expect(screen.getByLabelText("3 participants").textContent).toBe("3");
mockGetMember(room);
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
mkVideoChannelMember("@alice:example.org", ["device 1"], STUCK_DEVICE_TIMEOUT_MS),
]));
const tile = mountTile();
expect(tile.find(".mx_VideoRoomSummary_participants").text()).toEqual("1");
// Expire Alice's device data
act(() => { jest.advanceTimersByTime(STUCK_DEVICE_TIMEOUT_MS); });
tile.update();
expect(tile.find(".mx_VideoRoomSummary_participants").exists()).toEqual(false);
act(() => { call.participants = new Set(); });
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
});
});

View file

@ -0,0 +1,181 @@
/*
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 { zip } from "lodash";
import { render, screen, act, fireEvent, waitFor } 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 {
stubClient,
mkRoomMember,
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallLobby } from "../../../../src/components/views/voip/CallLobby";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../src/stores/CallStore";
describe("CallLobby", () => {
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", {
value: {
enumerateDevices: jest.fn(),
getUserMedia: () => null,
},
});
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let client: Mocked<MatrixClient>;
let room: Room;
let call: MockedCall;
let widget: Widget;
let alice: RoomMember;
beforeEach(() => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([]);
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) 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} />);
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");
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 renderLobby();
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([]);
});
describe("device buttons", () => {
it("hide when no devices are available", async () => {
await renderLobby();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
});
it("show without dropdown when only one device is available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{
deviceId: "1",
groupId: "1",
label: "Webcam",
kind: "videoinput",
toJSON: () => {},
}]);
await renderLobby();
screen.getByRole("button", { name: /camera/ });
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
});
it("show with dropdown when multiple devices are available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
{
deviceId: "1",
groupId: "1",
label: "Headphones",
kind: "audioinput",
toJSON: () => {},
},
{
deviceId: "2",
groupId: "1",
label: "", // Should fall back to "Audio input 2"
kind: "audioinput",
toJSON: () => {},
},
]);
await renderLobby();
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 });
});
});
});

View file

@ -1,193 +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";
// eslint-disable-next-line deprecate/import
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
stubClient,
stubVideoChannelStore,
StubVideoChannelStore,
mkRoom,
mkVideoChannelMember,
mockStateEventImplementation,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import FacePile from "../../../../src/components/views/elements/FacePile";
import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar";
import VideoLobby from "../../../../src/components/views/voip/VideoLobby";
describe("VideoLobby", () => {
Object.defineProperty(navigator, "mediaDevices", {
value: {
enumerateDevices: jest.fn(),
getUserMedia: () => null,
},
});
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let cli: MatrixClient;
let store: StubVideoChannelStore;
let room: Room;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.get();
store = stubVideoChannelStore();
room = mkRoom(cli, "!1:example.org");
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([]);
});
describe("connected members", () => {
it("hides when no one is connected", async () => {
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
expect(lobby.find(".mx_VideoLobby_connectedMembers").exists()).toEqual(false);
});
it("is shown when someone is connected", async () => {
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
// A user connected from 2 devices
mkVideoChannelMember("@alice:example.org", ["device 1", "device 2"]),
// A disconnected user
mkVideoChannelMember("@bob:example.org", []),
// A user that claims to have a connected device, but has left the room
mkVideoChannelMember("@chris:example.org", ["device 1"]),
]));
mocked(room).getMember.mockImplementation(userId => ({
userId,
membership: userId === "@chris:example.org" ? "leave" : "join",
name: userId,
rawDisplayName: userId,
roomId: "!1:example.org",
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
}) as unknown as RoomMember);
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
// Only Alice should display as connected
const memberText = lobby.find(".mx_VideoLobby_connectedMembers").children().at(0).text();
expect(memberText).toEqual("1 person joined");
expect(lobby.find(FacePile).find(MemberAvatar).props().member.userId).toEqual("@alice:example.org");
});
it("doesn't include remote echo of this device being connected", async () => {
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
// Make the remote echo claim that we're connected, while leaving the store disconnected
mkVideoChannelMember(cli.getUserId(), [cli.getDeviceId()]),
]));
mocked(room).getMember.mockImplementation(userId => ({
userId,
membership: "join",
name: userId,
rawDisplayName: userId,
roomId: "!1:example.org",
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
}) as unknown as RoomMember);
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
// Because of our local echo, we should still appear as disconnected
expect(lobby.find(".mx_VideoLobby_connectedMembers").exists()).toEqual(false);
});
});
describe("device buttons", () => {
it("hides when no devices are available", async () => {
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
expect(lobby.find("DeviceButton").children().exists()).toEqual(false);
});
it("hides device list when only one device is available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{
deviceId: "1",
groupId: "1",
label: "Webcam",
kind: "videoinput",
toJSON: () => {},
}]);
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
expect(lobby.find(".mx_VideoLobby_deviceListButton").exists()).toEqual(false);
});
it("shows device list when multiple devices are available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
{
deviceId: "1",
groupId: "1",
label: "Front camera",
kind: "videoinput",
toJSON: () => {},
},
{
deviceId: "2",
groupId: "1",
label: "Back camera",
kind: "videoinput",
toJSON: () => {},
},
]);
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
expect(lobby.find(".mx_VideoLobby_deviceListButton").exists()).toEqual(true);
});
});
describe("join button", () => {
it("works", async () => {
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();
act(() => {
lobby.find("AccessibleButton.mx_VideoLobby_joinButton").simulate("click");
});
expect(store.connect).toHaveBeenCalled();
});
});
});

View file

@ -23,7 +23,7 @@ import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-u
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import WidgetStore from "../src/stores/WidgetStore";
import WidgetUtils from "../src/utils/WidgetUtils";
import { VIDEO_CHANNEL_MEMBER } from "../src/utils/VideoChannelUtils";
import { JitsiCall } from "../src/models/Call";
import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
describe("createRoom", () => {
@ -51,7 +51,7 @@ describe("createRoom", () => {
},
events: {
"im.vector.modular.widgets": widgetPower,
[VIDEO_CHANNEL_MEMBER]: videoMemberPower,
[JitsiCall.MEMBER_EVENT_TYPE]: jitsiMemberPower,
},
},
}]] = mocked(client.createRoom).mock.calls as any; // no good type
@ -64,7 +64,7 @@ describe("createRoom", () => {
expect(widgetStateKey).toEqual("im.vector.modular.widgets");
// All members should be able to update their connected devices
expect(videoMemberPower).toEqual(0);
expect(jitsiMemberPower).toEqual(0);
// Jitsi widget should be immutable for admins
expect(widgetPower).toBeGreaterThan(100);
// and we should have been reset back to admin

339
test/models/Call-test.ts Normal file
View file

@ -0,0 +1,339 @@
/*
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 EventEmitter from "events";
import { isEqual } from "lodash";
import { mocked } from "jest-mock";
import { waitFor } from "@testing-library/react";
import { 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 { Mocked } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/client";
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, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { CallEvent, ConnectionState, JitsiCall } from "../../src/models/Call";
import WidgetStore from "../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
describe("JitsiCall", () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [
{ deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} },
],
[MediaDeviceKindEnum.VideoInput]: [
{ deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} },
],
[MediaDeviceKindEnum.AudioOutput]: [],
});
jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let carol: RoomMember;
let call: Call;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
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");
bob = mkRoomMember(room.roomId, "@bob:example.org");
carol = mkRoomMember(room.roomId, "@carol:example.org");
jest.spyOn(room, "getMember").mockImplementation(userId => {
switch (userId) {
case alice.userId: return alice;
case bob.userId: return bob;
case carol.userId: return carol;
default: return null;
}
});
jest.spyOn(room, "getMyMembership").mockReturnValue("join");
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.getUserId.mockReturnValue(alice.userId);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
if (roomId !== room.roomId) throw new Error("Unknown room");
const event = mkEvent({
event: true,
type: eventType,
room: roomId,
user: alice.userId,
skey: stateKey,
content,
});
room.addLiveEvents([event]);
return { event_id: event.getId() };
});
setupAsyncStoreWithClient(WidgetStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
await JitsiCall.create(room);
call = JitsiCall.get(room);
if (call === null) throw new Error("Failed to create call");
widget = new Widget(call.widget);
const eventEmitter = new EventEmitter();
messaging = {
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
stop: jest.fn(),
transport: {
send: jest.fn(async action => {
if (action === ElementWidgetActions.JoinCall) {
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
} else if (action === ElementWidgetActions.HangupCall) {
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
}
return {};
}),
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
});
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
jest.clearAllMocks();
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
});
it("connects muted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true);
videoMutedSpy.mockReturnValue(true);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
videoInput: null,
});
});
it("connects unmuted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(false);
videoMutedSpy.mockReturnValue(false);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "Headphones",
videoInput: "Built-in webcam",
});
});
it("waits for messaging when connecting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.connect();
expect(call.connectionState).toBe(ConnectionState.Connecting);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
it("handles instant remote disconnection when connecting", async () => {
mocked(messaging.transport).send.mockImplementation(async action => {
if (action === ElementWidgetActions.JoinCall) {
// Emit the hangup event *before* the join event to fully
// exercise the race condition
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
}
return {};
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
// Should disconnect on its own almost instantly
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("tracks participants in room state", async () => {
expect([...call.participants]).toEqual([]);
// A participant with multiple devices (should only show up once)
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
{ devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 },
bob.userId,
);
// A participant with an expired device (should not show up)
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
{ devices: ["carolandroid"], expires_ts: -1000 * 60 },
carol.userId,
);
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.connect();
expect([...call.participants]).toEqual([bob, alice]);
await call.disconnect();
expect([...call.participants]).toEqual([bob]);
});
it("updates room state when connecting and disconnecting", async () => {
const now1 = Date.now();
await call.connect();
await waitFor(() => expect(
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
).toEqual({
devices: [client.getDeviceId()],
expires_ts: now1 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
}), { interval: 5 });
const now2 = Date.now();
await call.disconnect();
await waitFor(() => expect(
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
).toEqual({
devices: [],
expires_ts: now2 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
}), { interval: 5 });
});
it("repeatedly updates room state while connected", async () => {
await call.connect();
await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
{ devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
alice.userId,
), { interval: 5 });
client.sendStateEvent.mockClear();
jest.advanceTimersByTime(JitsiCall.STUCK_DEVICE_TIMEOUT_MS);
await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
{ devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
alice.userId,
), { interval: 5 });
});
it("emits events when connection state changes", async () => {
const events: ConnectionState[] = [];
const onConnectionState = (state: ConnectionState) => events.push(state);
call.on(CallEvent.ConnectionState, onConnectionState);
await call.connect();
await call.disconnect();
expect(events).toEqual([
ConnectionState.Connecting,
ConnectionState.Connected,
ConnectionState.Disconnecting,
ConnectionState.Disconnected,
]);
});
it("emits events when participants change", async () => {
const events: Set<RoomMember>[] = [];
const onParticipants = (participants: Set<RoomMember>) => {
if (!isEqual(participants, events[events.length - 1])) events.push(participants);
};
call.on(CallEvent.Participants, onParticipants);
await call.connect();
await call.disconnect();
expect(events).toEqual([new Set([alice]), new Set()]);
});
it("switches to spotlight layout when the widget becomes a PiP", async () => {
await call.connect();
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
});
});

View file

@ -1,225 +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 { mocked, Mocked } from "jest-mock";
import {
Widget,
ClientWidgetApi,
MatrixWidgetType,
WidgetApiAction,
IWidgetApiRequest,
IWidgetApiRequestData,
} from "matrix-widget-api";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils";
import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore";
describe("VideoChannelStore", () => {
const store = VideoChannelStore.instance;
const widget = { id: "1" } as unknown as Widget;
const app = {
id: "1",
eventId: "$1:example.org",
roomId: "!1:example.org",
type: MatrixWidgetType.JitsiMeet,
url: "",
name: "Video channel",
creatorUserId: "@alice:example.org",
avatar_url: null,
data: { isVideoChannel: true },
} as IApp;
// Set up mocks to simulate the remote end of the widget API
let sendMock: (action: WidgetApiAction, data: IWidgetApiRequestData) => void;
let onMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
let messaging: ClientWidgetApi;
let cli: Mocked<MatrixClient>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.get());
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
setupAsyncStoreWithClient(store, cli);
cli.getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
sendMock = jest.fn();
onMock = jest.fn();
onceMock = jest.fn();
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]);
messaging = {
on: onMock,
off: () => {},
stop: () => {},
once: onceMock,
transport: {
send: sendMock,
reply: () => {},
},
} as unknown as ClientWidgetApi;
});
afterEach(() => jest.useRealTimers());
const getRequest = <T extends IWidgetApiRequestData>(): Promise<[WidgetApiAction, T]> =>
new Promise<[WidgetApiAction, T]>(resolve => {
mocked(sendMock).mockImplementationOnce((action, data) => resolve([action, data as T]));
});
const widgetReady = () => {
// Tell the WidgetStore that the widget is ready
const [, ready] = mocked(onceMock).mock.calls.find(([action]) =>
action === `action:${ElementWidgetActions.WidgetReady}`,
);
ready({ detail: {} } as unknown as CustomEvent<IWidgetApiRequest>);
};
const confirmConnect = async () => {
// Wait for the store to contact the widget API
await getRequest();
// Then, locate the callback that will confirm the join
const [, join] = mocked(onMock).mock.calls.find(([action]) =>
action === `action:${ElementWidgetActions.JoinCall}`,
);
// Confirm the join, and wait for the store to update
const waitForConnect = new Promise<void>(resolve =>
store.once(VideoChannelEvent.Connect, resolve),
);
join(new CustomEvent("widgetapirequest", { detail: {} }) as CustomEvent<IWidgetApiRequest>);
await waitForConnect;
};
const confirmDisconnect = async () => {
// Locate the callback that will perform the hangup
const [, hangup] = mocked(onceMock).mock.calls.find(([action]) =>
action === `action:${ElementWidgetActions.HangupCall}`,
);
// Hangup and wait for the store, once again
const waitForHangup = new Promise<void>(resolve =>
store.once(VideoChannelEvent.Disconnect, resolve),
);
hangup(new CustomEvent("widgetapirequest", { detail: {} }) as CustomEvent<IWidgetApiRequest>);
await waitForHangup;
};
it("connects and disconnects", async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
const connectConfirmed = confirmConnect();
const connectPromise = store.connect("!1:example.org", null, null);
await connectConfirmed;
await expect(connectPromise).resolves.toBeUndefined();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);
// Our device should now appear as connected
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
"!1:example.org",
VIDEO_CHANNEL_MEMBER,
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
cli.getUserId(),
);
cli.sendStateEvent.mockClear();
// Our devices should be resent within the timeout period to prevent
// the data from becoming stale
jest.advanceTimersByTime(STUCK_DEVICE_TIMEOUT_MS);
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
"!1:example.org",
VIDEO_CHANNEL_MEMBER,
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
cli.getUserId(),
);
cli.sendStateEvent.mockClear();
const disconnectPromise = store.disconnect();
await confirmDisconnect();
await expect(disconnectPromise).resolves.toBeUndefined();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
// Our device should now be marked as disconnected
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
"!1:example.org",
VIDEO_CHANNEL_MEMBER,
{ devices: [], expires_ts: expect.any(Number) },
cli.getUserId(),
);
});
it("waits for messaging when connecting", async () => {
const connectConfirmed = confirmConnect();
const connectPromise = store.connect("!1:example.org", null, null);
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
await connectConfirmed;
await expect(connectPromise).resolves.toBeUndefined();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);
store.disconnect();
await confirmDisconnect();
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
});
it("rejects if the widget's messaging gets stopped mid-connect", async () => {
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
const requestPromise = getRequest();
const connectPromise = store.connect("!1:example.org", null, null);
// Wait for the store to contact the widget API, then stop the messaging
await requestPromise;
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
await expect(connectPromise).rejects.toBeDefined();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
});
it("switches to spotlight mode when the widget becomes a PiP", async () => {
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
confirmConnect();
await store.connect("!1:example.org", null, null);
const request = getRequest<IWidgetApiRequestData>();
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
const [action, data] = await request;
expect(action).toEqual(ElementWidgetActions.SpotlightLayout);
expect(data).toEqual({});
store.disconnect();
await confirmDisconnect();
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
});
});

Some files were not shown because too many files have changed in this diff Show more