From 0d6a550c33d0b70decd2146a60ec54f8ddf5d715 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 30 Aug 2022 15:13:39 -0400 Subject: [PATCH] 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 --- .eslintrc.js | 4 + res/css/_common.pcss | 2 +- res/css/_components.pcss | 22 +- res/css/structures/_VideoRoomView.pcss | 2 +- ...tMenu.pcss => _LegacyCallContextMenu.pcss} | 2 +- ...{_CallEvent.pcss => _LegacyCallEvent.pcss} | 94 +-- ...Summary.pcss => _RoomTileCallSummary.pcss} | 8 +- ...ast.pcss => _IncomingLegacyCallToast.pcss} | 38 +- .../_LegacyCallViewButtons.pcss} | 44 +- .../{_VideoLobby.pcss => _CallLobby.pcss} | 60 +- ...llPreview.pcss => _LegacyCallPreview.pcss} | 2 +- .../{_CallView.pcss => _LegacyCallView.pcss} | 38 +- ...rRoom.pcss => _LegacyCallViewForRoom.pcss} | 6 +- ...Header.pcss => _LegacyCallViewHeader.pcss} | 26 +- ...debar.pcss => _LegacyCallViewSidebar.pcss} | 4 +- res/themes/dark/css/_dark.pcss | 6 +- res/themes/legacy-dark/css/_legacy-dark.pcss | 6 +- .../legacy-light/css/_legacy-light.pcss | 6 +- res/themes/light/css/_light.pcss | 6 +- src/@types/global.d.ts | 4 +- ...{CallHandler.tsx => LegacyCallHandler.tsx} | 40 +- src/Lifecycle.ts | 13 +- src/MediaDeviceHandler.ts | 14 + src/Notifier.ts | 4 +- src/SlashCommands.tsx | 10 +- src/VoipUserMapper.ts | 8 +- src/components/structures/LeftPanel.tsx | 4 +- ...ntGrouper.ts => LegacyCallEventGrouper.ts} | 44 +- src/components/structures/LoggedInView.tsx | 14 +- src/components/structures/MatrixChat.tsx | 10 +- src/components/structures/MessagePanel.tsx | 4 +- src/components/structures/RoomView.tsx | 14 +- src/components/structures/TimelinePanel.tsx | 22 +- src/components/structures/VideoRoomView.tsx | 75 +-- ...textMenu.tsx => LegacyCallContextMenu.tsx} | 12 +- src/components/views/dialogs/InviteDialog.tsx | 6 +- src/components/views/elements/AppTile.tsx | 13 +- src/components/views/elements/Tooltip.tsx | 2 +- .../{CallEvent.tsx => LegacyCallEvent.tsx} | 79 +-- src/components/views/rooms/AuxPanel.tsx | 4 +- src/components/views/rooms/EventTile.tsx | 6 +- src/components/views/rooms/RoomTile.tsx | 23 +- ...oomSummary.tsx => RoomTileCallSummary.tsx} | 52 +- .../views/rooms/SearchResultTile.tsx | 12 +- ...ll.tsx => AudioFeedArrayForLegacyCall.tsx} | 2 +- .../voip/{VideoLobby.tsx => CallLobby.tsx} | 170 +++--- src/components/views/voip/DialPadModal.tsx | 4 +- .../voip/{CallView.tsx => LegacyCallView.tsx} | 90 +-- .../LegacyCallViewButtons.tsx} | 52 +- .../LegacyCallViewHeader.tsx} | 38 +- ...wForRoom.tsx => LegacyCallViewForRoom.tsx} | 30 +- ...wSidebar.tsx => LegacyCallViewSidebar.tsx} | 6 +- src/components/views/voip/PipView.tsx | 22 +- src/createRoom.ts | 52 +- src/events/EventTileFactory.tsx | 12 +- src/hooks/useCall.ts | 46 ++ src/i18n/strings/en_EN.json | 126 ++-- src/models/Call.ts | 539 ++++++++++++++++++ src/settings/Settings.tsx | 4 +- src/settings/SettingsStore.ts | 7 +- src/stores/AsyncStoreWithClient.ts | 4 + src/stores/AutoRageshakeStore.ts | 6 +- src/stores/BreadcrumbsStore.ts | 6 +- src/stores/CallStore.ts | 185 ++++++ src/stores/ModalWidgetStore.ts | 12 +- src/stores/OwnBeaconStore.ts | 6 +- src/stores/OwnProfileStore.ts | 6 +- src/stores/ReadyWatchingStore.ts | 15 +- src/stores/VideoChannelStore.ts | 355 ------------ src/stores/VoiceRecordingStore.ts | 7 +- src/stores/WidgetStore.ts | 8 +- src/stores/local-echo/EchoStore.ts | 7 +- .../RoomNotificationStateStore.ts | 6 +- src/stores/right-panel/RightPanelStore.ts | 7 +- src/stores/room-list/MessagePreviewStore.ts | 18 +- src/stores/room-list/RoomListLayoutStore.ts | 5 +- src/stores/room-list/RoomListStore.ts | 8 +- src/stores/room-list/algorithms/Algorithm.ts | 66 ++- .../room-list/filters/VisibilityProvider.ts | 4 +- ...iew.ts => LegacyCallAnswerEventPreview.ts} | 2 +- ...angupEvent.ts => LegacyCallHangupEvent.ts} | 2 +- ...iew.ts => LegacyCallInviteEventPreview.ts} | 2 +- src/stores/spaces/SpaceStore.ts | 6 +- src/stores/widgets/ElementWidgetActions.ts | 6 +- src/stores/widgets/WidgetLayoutStore.ts | 7 +- src/stores/widgets/WidgetMessagingStore.ts | 28 +- ...lToast.tsx => IncomingLegacyCallToast.tsx} | 54 +- src/utils/VideoChannelUtils.ts | 204 ------- ...dler-test.ts => LegacyCallHandler-test.ts} | 28 +- test/SlashCommands-test.tsx | 6 +- ...test.ts => LegacyCallEventGrouper-test.ts} | 12 +- .../structures/VideoRoomView-test.tsx | 149 +++-- .../views/elements/AppTile-test.tsx | 4 +- .../__snapshots__/TooltipTarget-test.tsx.snap | 7 + test/components/views/rooms/RoomTile-test.tsx | 191 +++---- test/components/views/voip/CallLobby-test.tsx | 181 ++++++ .../components/views/voip/VideoLobby-test.tsx | 193 ------- test/createRoom-test.ts | 6 +- test/models/Call-test.ts | 339 +++++++++++ test/stores/VideoChannelStore-test.ts | 225 -------- test/stores/VoiceRecordingStore-test.ts | 18 +- .../room-list/algorithms/Algorithm-test.ts | 84 ++- .../filters/VisibilityProvider-test.ts | 6 +- test/test-utils/call.ts | 92 +++ test/test-utils/index.ts | 2 +- test/test-utils/test-utils.ts | 15 + test/test-utils/video.ts | 65 --- 107 files changed, 2573 insertions(+), 2157 deletions(-) rename res/css/views/context_menus/{_CallContextMenu.pcss => _LegacyCallContextMenu.pcss} (95%) rename res/css/views/messages/{_CallEvent.pcss => _LegacyCallEvent.pcss} (69%) rename res/css/views/rooms/{_VideoRoomSummary.pcss => _RoomTileCallSummary.pcss} (89%) rename res/css/views/toasts/{_IncomingCallToast.pcss => _IncomingLegacyCallToast.pcss} (74%) rename res/css/views/voip/{CallView/_CallViewButtons.pcss => LegacyCallView/_LegacyCallViewButtons.pcss} (77%) rename res/css/views/voip/{_VideoLobby.pcss => _CallLobby.pcss} (72%) rename res/css/views/voip/{_CallPreview.pcss => _LegacyCallPreview.pcss} (97%) rename res/css/views/voip/{_CallView.pcss => _LegacyCallView.pcss} (83%) rename res/css/views/voip/{_CallViewForRoom.pcss => _LegacyCallViewForRoom.pcss} (89%) rename res/css/views/voip/{_CallViewHeader.pcss => _LegacyCallViewHeader.pcss} (83%) rename res/css/views/voip/{_CallViewSidebar.pcss => _LegacyCallViewSidebar.pcss} (94%) rename src/{CallHandler.tsx => LegacyCallHandler.tsx} (96%) rename src/components/structures/{CallEventGrouper.ts => LegacyCallEventGrouper.ts} (77%) rename src/components/views/context_menus/{CallContextMenu.tsx => LegacyCallContextMenu.tsx} (79%) rename src/components/views/messages/{CallEvent.tsx => LegacyCallEvent.tsx} (76%) rename src/components/views/rooms/{VideoRoomSummary.tsx => RoomTileCallSummary.tsx} (51%) rename src/components/views/voip/{AudioFeedArrayForCall.tsx => AudioFeedArrayForLegacyCall.tsx} (94%) rename src/components/views/voip/{VideoLobby.tsx => CallLobby.tsx} (50%) rename src/components/views/voip/{CallView.tsx => LegacyCallView.tsx} (85%) rename src/components/views/voip/{CallView/CallViewButtons.tsx => LegacyCallView/LegacyCallViewButtons.tsx} (83%) rename src/components/views/voip/{CallView/CallViewHeader.tsx => LegacyCallView/LegacyCallViewHeader.tsx} (62%) rename src/components/views/voip/{CallViewForRoom.tsx => LegacyCallViewForRoom.tsx} (71%) rename src/components/views/voip/{CallViewSidebar.tsx => LegacyCallViewSidebar.tsx} (87%) create mode 100644 src/hooks/useCall.ts create mode 100644 src/models/Call.ts create mode 100644 src/stores/CallStore.ts delete mode 100644 src/stores/VideoChannelStore.ts rename src/stores/room-list/previews/{CallAnswerEventPreview.ts => LegacyCallAnswerEventPreview.ts} (95%) rename src/stores/room-list/previews/{CallHangupEvent.ts => LegacyCallHangupEvent.ts} (95%) rename src/stores/room-list/previews/{CallInviteEventPreview.ts => LegacyCallInviteEventPreview.ts} (95%) rename src/toasts/{IncomingCallToast.tsx => IncomingLegacyCallToast.tsx} (59%) delete mode 100644 src/utils/VideoChannelUtils.ts rename test/{CallHandler-test.ts => LegacyCallHandler-test.ts} (94%) rename test/components/structures/{CallEventGrouper-test.ts => LegacyCallEventGrouper-test.ts} (90%) create mode 100644 test/components/views/voip/CallLobby-test.tsx delete mode 100644 test/components/views/voip/VideoLobby-test.tsx create mode 100644 test/models/Call-test.ts delete mode 100644 test/stores/VideoChannelStore-test.ts create mode 100644 test/test-utils/call.ts delete mode 100644 test/test-utils/video.ts diff --git a/.eslintrc.js b/.eslintrc.js index bcf20ab5a6..7885cfd88d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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 diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 862a6ad494..db663a8e25 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -752,7 +752,7 @@ legend { cursor: pointer; } -@define-mixin CallButton { +@define-mixin LegacyCallButton { box-sizing: border-box; font-weight: 600; height: $font-24px; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b12ada477f..640184c8ca 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/structures/_VideoRoomView.pcss b/res/css/structures/_VideoRoomView.pcss index aee8cc2816..6d758820bf 100644 --- a/res/css/structures/_VideoRoomView.pcss +++ b/res/css/structures/_VideoRoomView.pcss @@ -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; } } diff --git a/res/css/views/context_menus/_CallContextMenu.pcss b/res/css/views/context_menus/_LegacyCallContextMenu.pcss similarity index 95% rename from res/css/views/context_menus/_CallContextMenu.pcss rename to res/css/views/context_menus/_LegacyCallContextMenu.pcss index 55b73b0344..d9adc07e62 100644 --- a/res/css/views/context_menus/_CallContextMenu.pcss +++ b/res/css/views/context_menus/_LegacyCallContextMenu.pcss @@ -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; diff --git a/res/css/views/messages/_CallEvent.pcss b/res/css/views/messages/_LegacyCallEvent.pcss similarity index 69% rename from res/css/views/messages/_CallEvent.pcss rename to res/css/views/messages/_LegacyCallEvent.pcss index 9ce45621ba..2b8126910a 100644 --- a/res/css/views/messages/_CallEvent.pcss +++ b/res/css/views/messages/_LegacyCallEvent.pcss @@ -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 */ } } diff --git a/res/css/views/rooms/_VideoRoomSummary.pcss b/res/css/views/rooms/_RoomTileCallSummary.pcss similarity index 89% rename from res/css/views/rooms/_VideoRoomSummary.pcss rename to res/css/views/rooms/_RoomTileCallSummary.pcss index b3e9af3f65..9c5e99c5ec 100644 --- a/res/css/views/rooms/_VideoRoomSummary.pcss +++ b/res/css/views/rooms/_RoomTileCallSummary.pcss @@ -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: ''; diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingLegacyCallToast.pcss similarity index 74% rename from res/css/views/toasts/_IncomingCallToast.pcss rename to res/css/views/toasts/_IncomingLegacyCallToast.pcss index 3fa5e5e00a..2fdaabf243 100644 --- a/res/css/views/toasts/_IncomingCallToast.pcss +++ b/res/css/views/toasts/_IncomingLegacyCallToast.pcss @@ -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'); } } diff --git a/res/css/views/voip/CallView/_CallViewButtons.pcss b/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss similarity index 77% rename from res/css/views/voip/CallView/_CallViewButtons.pcss rename to res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss index 8f95eef7ca..412bc112f8 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.pcss +++ b/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss @@ -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; diff --git a/res/css/views/voip/_VideoLobby.pcss b/res/css/views/voip/_CallLobby.pcss similarity index 72% rename from res/css/views/voip/_VideoLobby.pcss rename to res/css/views/voip/_CallLobby.pcss index 3f4f1af4ec..306ed8962b 100644 --- a/res/css/views/voip/_VideoLobby.pcss +++ b/res/css/views/voip/_CallLobby.pcss @@ -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; } diff --git a/res/css/views/voip/_CallPreview.pcss b/res/css/views/voip/_LegacyCallPreview.pcss similarity index 97% rename from res/css/views/voip/_CallPreview.pcss rename to res/css/views/voip/_LegacyCallPreview.pcss index 950466bdb5..86d6fae0d4 100644 --- a/res/css/views/voip/_CallPreview.pcss +++ b/res/css/views/voip/_LegacyCallPreview.pcss @@ -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; diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_LegacyCallView.pcss similarity index 83% rename from res/css/views/voip/_CallView.pcss rename to res/css/views/voip/_LegacyCallView.pcss index 765657e10a..ec221c4c6f 100644 --- a/res/css/views/voip/_CallView.pcss +++ b/res/css/views/voip/_LegacyCallView.pcss @@ -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; } } diff --git a/res/css/views/voip/_CallViewForRoom.pcss b/res/css/views/voip/_LegacyCallViewForRoom.pcss similarity index 89% rename from res/css/views/voip/_CallViewForRoom.pcss rename to res/css/views/voip/_LegacyCallViewForRoom.pcss index 6822f7196f..e88116017b 100644 --- a/res/css/views/voip/_CallViewForRoom.pcss +++ b/res/css/views/voip/_LegacyCallViewForRoom.pcss @@ -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; diff --git a/res/css/views/voip/_CallViewHeader.pcss b/res/css/views/voip/_LegacyCallViewHeader.pcss similarity index 83% rename from res/css/views/voip/_CallViewHeader.pcss rename to res/css/views/voip/_LegacyCallViewHeader.pcss index 6280da8cbb..3d8d4d2fd9 100644 --- a/res/css/views/voip/_CallViewHeader.pcss +++ b/res/css/views/voip/_LegacyCallViewHeader.pcss @@ -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; diff --git a/res/css/views/voip/_CallViewSidebar.pcss b/res/css/views/voip/_LegacyCallViewSidebar.pcss similarity index 94% rename from res/css/views/voip/_CallViewSidebar.pcss rename to res/css/views/voip/_LegacyCallViewSidebar.pcss index 351f4061f4..32a5c47b1b 100644 --- a/res/css/views/voip/_CallViewSidebar.pcss +++ b/res/css/views/voip/_LegacyCallViewSidebar.pcss @@ -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; diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index d5567c0b08..b17010f275 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -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 */ diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 349453622f..49043b648d 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -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; diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 20ca67a3cf..4380bef408 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -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; diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index fc9168ccf8..630ca8c70d 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -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 */ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 0075837111..c4971d24f1 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -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; diff --git a/src/CallHandler.tsx b/src/LegacyCallHandler.tsx similarity index 96% rename from src/CallHandler.tsx rename to src/LegacyCallHandler.tsx index 804b448549..624dc86a33 100644 --- a/src/CallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -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(); // 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(); // 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); } } } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 94b2e25706..1e7fae8136 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -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 { 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 { // 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 { 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")); } diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 3734f8c395..29df6fb37b 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -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 { - 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 { - 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; } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index bd120529a9..09136257f3 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -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 { // 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 = , +export function buildLegacyCallEventGroupers( + callEventGroupers: Map, events?: MatrixEvent[], -): Map { +): Map { 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 = new Set(); 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(); }; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index d4737c2fca..6c8aad5f71 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -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 { // 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 { 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 { 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 { } 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 { const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { return ( - + ); }); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8c173a3630..cd1b3f599d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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 { } 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); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 71f698c9f8..8e41a599f1 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -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; + callEventGroupers: Map; } interface IState { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 83eb8ead8b..f692676858 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { 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 { // (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 { ); } - 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 { } 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 { 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, diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 27367caf1c..d90c671c71 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -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 { private readReceiptActivityTimer: Timer; private readMarkerActivityTimer: Timer; - // A map of - private callEventGroupers = new Map(); + // A map of + private callEventGroupers = new Map(); constructor(props, context) { super(props, context); @@ -493,7 +493,7 @@ class TimelinePanel extends React.Component { this.timelineWindow.unpaginate(count, backwards); const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); - this.buildCallEventGroupers(events); + this.buildLegacyCallEventGroupers(events); const newState: Partial = { events, liveEvents, @@ -555,7 +555,7 @@ class TimelinePanel extends React.Component { debuglog("paginate complete backwards:"+backwards+"; success:"+r); const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); - this.buildCallEventGroupers(events); + this.buildLegacyCallEventGroupers(events); const newState: Partial = { [paginatingKey]: false, [canPaginateKey]: r, @@ -686,7 +686,7 @@ class TimelinePanel extends React.Component { 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 = { @@ -855,7 +855,7 @@ class TimelinePanel extends React.Component { // 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 { 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 { 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 { 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() { diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx index 5535c5c14f..d08ee53a46 100644 --- a/src/components/structures/VideoRoomView.tsx +++ b/src/components/structures/VideoRoomView.tsx @@ -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 = ({ room, resizing }) => { +const LoadedVideoRoomView: FC = ({ 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
- { connected ? null : } + { connected ? null : } { /* We render the widget even if we're disconnected, so it stays loaded */ }
; }; -export default VideoRoomView; +export const VideoRoomView: FC = ({ room, resizing }) => { + const call = useCall(room.roomId); + return call ? : null; +}; diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/LegacyCallContextMenu.tsx similarity index 79% rename from src/components/views/context_menus/CallContextMenu.tsx rename to src/components/views/context_menus/LegacyCallContextMenu.tsx index 8c9e07dfcc..1f52fa2637 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/LegacyCallContextMenu.tsx @@ -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 { +export default class LegacyCallContextMenu extends React.Component { 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 { }; 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 { let transferItem; if (this.props.call.opponentCanBeTransferred()) { - transferItem = + transferItem = { _t("Transfer") } ; } return - + { holdUnholdCaption } { transferItem } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 386301f0ea..d23c7f6a18 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -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 { 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 { 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 { } 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 { 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), diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index ae1aff26de..a2298486e8 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -181,7 +181,7 @@ export default class Tooltip extends React.PureComponent { style.display = this.props.visible ? "block" : "none"; const tooltip = ( -
+
{ this.props.label }
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/LegacyCallEvent.tsx similarity index 76% rename from src/components/views/messages/CallEvent.tsx rename to src/components/views/messages/LegacyCallEvent.tsx index a46f010617..4ab3ba00c3 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/LegacyCallEvent.tsx @@ -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 { +export default class LegacyCallEvent extends React.PureComponent { private wrapperElement = createRef(); private resizeObserver: ResizeObserver; @@ -59,18 +62,18 @@ export default class CallEvent extends React.PureComponent { } 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 { private renderCallBackButton(text: string): JSX.Element { return ( @@ -108,9 +111,9 @@ export default class CallEvent extends React.PureComponent { 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 { } return ( -
+
{ silenceIcon } { _t("Decline") } @@ -156,7 +159,7 @@ export default class CallEvent extends React.PureComponent { if (gotRejected) { return ( -
+
{ _t("Call declined") } { this.renderCallBackButton(_t("Call back")) } { this.props.timestamp } @@ -175,14 +178,14 @@ export default class CallEvent extends React.PureComponent { text += " • " + formatCallTime(duration); } return ( -
+
{ text } { this.props.timestamp }
); } else if (hangupReason === CallErrorCode.InviteTimeout) { return ( -
+
{ _t("No answer") } { this.renderCallBackButton(_t("Call back")) } { this.props.timestamp } @@ -212,10 +215,10 @@ export default class CallEvent extends React.PureComponent { } return ( -
+
{ _t("Connection failed") } @@ -226,7 +229,7 @@ export default class CallEvent extends React.PureComponent { } if (state === CallState.Connected) { return ( -
+
{ this.props.timestamp }
@@ -234,7 +237,7 @@ export default class CallEvent extends React.PureComponent { } if (state === CallState.Connecting) { return ( -
+
{ _t("Connecting") } { this.props.timestamp }
@@ -242,7 +245,7 @@ export default class CallEvent extends React.PureComponent { } if (state === CustomCallState.Missed) { return ( -
+
{ _t("Missed call") } { this.renderCallBackButton(_t("Call back")) } { this.props.timestamp } @@ -251,7 +254,7 @@ export default class CallEvent extends React.PureComponent { } return ( -
+
{ _t("The call is in an unknown state!") } { this.props.timestamp }
@@ -266,13 +269,13 @@ export default class CallEvent extends React.PureComponent { 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 { } return ( -
+
{ silenceIcon } -
+
-
-
+
+
{ sender }
-
-
+
+
{ callType }
diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index c1325658f9..a50596196c 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -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 { render() { const callView = ( - { private roomTileRef = createRef(); 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 { 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 { 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 { 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 { 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 { } }; + 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 { } let subtitle; - if (this.isVideoRoom) { + if (this.state.call) { subtitle = (
- +
); } else if (this.showMessagePreview && this.state.messagePreview) { diff --git a/src/components/views/rooms/VideoRoomSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx similarity index 51% rename from src/components/views/rooms/VideoRoomSummary.tsx rename to src/components/views/rooms/RoomTileCallSummary.tsx index e83203af14..9af01f20d4 100644 --- a/src/components/views/rooms/VideoRoomSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -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 = ({ room }) => { - const connectionState = useConnectionState(room); - const videoMembers = useConnectedMembers(room, connectionState === ConnectionState.Connected); - const jitsiParticipants = useJitsiParticipants(room); +export const RoomTileCallSummary: FC = ({ 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 + return - { indicator } + { text } - { participantCount ? <> + { participants.size ? <> { " · " } - { participantCount } + { participants.size } : null } ; }; - -export default VideoRoomSummary; diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index d4bb791aa8..5984412b91 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -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 { static contextType = RoomContext; public context!: React.ContextType; - // A map of - private callEventGroupers = new Map(); + // A map of + private callEventGroupers = new Map(); 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() { diff --git a/src/components/views/voip/AudioFeedArrayForCall.tsx b/src/components/views/voip/AudioFeedArrayForLegacyCall.tsx similarity index 94% rename from src/components/views/voip/AudioFeedArrayForCall.tsx rename to src/components/views/voip/AudioFeedArrayForLegacyCall.tsx index baf68cd01a..09c8baff3d 100644 --- a/src/components/views/voip/AudioFeedArrayForCall.tsx +++ b/src/components/views/voip/AudioFeedArrayForLegacyCall.tsx @@ -28,7 +28,7 @@ interface IState { feeds: Array; } -export default class AudioFeedArrayForCall extends React.Component { +export default class AudioFeedArrayForLegacyCall extends React.Component { constructor(props: IProps) { super(props); diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/CallLobby.tsx similarity index 50% rename from src/components/views/voip/VideoLobby.tsx rename to src/components/views/voip/CallLobby.tsx index 08de1e1f49..39cef9e406 100644 --- a/src/components/views/voip/VideoLobby.tsx +++ b/src/components/views/voip/CallLobby.tsx @@ -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 = ({ - kind, devices, setDevice, deviceListLabel, active, disabled, toggle, activeTitle, inactiveTitle, +const DeviceButton: FC = ({ + 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 = ({ closeMenu(); }; - const buttonRect = buttonRef.current.getBoundingClientRect(); + const buttonRect = buttonRef.current!.getBoundingClientRect(); contextMenu = - { labelledDevices.map(d => + { devices.map((d, index) => selectDevice(d)} />, ) } @@ -78,21 +77,20 @@ const DeviceButton: FC = ({ if (!devices.length) return null; return
- { labelledDevices.length > 1 ? ( + { devices.length > 1 ? ( = ({ const MAX_FACES = 8; -const VideoLobby: FC<{ room: Room }> = ({ room }) => { - const store = VideoChannelStore.instance; +interface Props { + room: Room; + call: Call; +} + +export const CallLobby: FC = ({ room, call }) => { const [connecting, setConnecting] = useState(false); - const me = useMemo(() => room.getMember(room.myUserId), [room]); - const connectedMembers = useConnectedMembers(room, false); - const videoRef = useRef(); + const me = useMemo(() => room.getMember(room.myUserId)!, [room]); + const participants = useParticipants(call); + const videoRef = useRef(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(null); - const [selectedVideoDevice, selectVideoDevice] = useState(null); + const [videoInputId, setVideoInputId] = useState(() => 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 =
- { _t("%(count)s people joined", { count: connectedMembers.size }) } + facePile =
+ { _t("%(count)s people joined", { count: participants.size }) }
; } - return
+ return
{ facePile } -
+
; }; - -export default VideoLobby; diff --git a/src/components/views/voip/DialPadModal.tsx b/src/components/views/voip/DialPadModal.tsx index 321cd4b070..d7c1ddfa6a 100644 --- a/src/components/views/voip/DialPadModal.tsx +++ b/src/components/views/voip/DialPadModal.tsx @@ -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 { }; onDialPress = async () => { - CallHandler.instance.dialNumber(this.state.value); + LegacyCallHandler.instance.dialNumber(this.state.value); this.props.onFinished(true); }; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/LegacyCallView.tsx similarity index 85% rename from src/components/views/voip/CallView.tsx rename to src/components/views/voip/LegacyCallView.tsx index 53c3ec59e6..6d37d0339c 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -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) => void; showApps?: boolean; @@ -104,15 +104,15 @@ function exitFullscreen() { if (exitMethod) exitMethod.call(document); } -export default class CallView extends React.Component { +export default class LegacyCallView extends React.Component { private dispatcherRef: string; private contentWrapperRef = createRef(); - private buttonsRef = createRef(); + private buttonsRef = createRef(); 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 { } static getDerivedStateFromProps(props: IProps): Partial { - 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 { }; private onFeedsChanged = (newFeeds: Array): 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 { }; // 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 { }; 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 { ); return ( - { } return ( -
+
{ text }
); @@ -443,7 +443,7 @@ export default class CallView extends React.Component { 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 { } 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 =
+ holdTransferContent =
{ _t( "Consulting with %(transferTarget)s. Transfer to %(transferee)s", { @@ -494,7 +494,7 @@ export default class CallView extends React.Component { let onHoldText: React.ReactNode; if (isRemoteOnHold) { onHoldText = _t( - CallHandler.instance.hasAnyUnheldCall() + LegacyCallHandler.instance.hasAnyUnheldCall() ? _td("You held the call Switch") : _td("You held the call Resume"), {}, @@ -511,7 +511,7 @@ export default class CallView extends React.Component { } holdTransferContent = ( -
+
{ onHoldText }
); @@ -519,16 +519,16 @@ export default class CallView extends React.Component { return (
-
+
{ holdTransferContent }
); } else if (call.noIncomingFeeds()) { return ( -
-
+
+
{ />
-
{ _t("Connecting") }
+
{ _t("Connecting") }
{ secondaryFeedElement }
); } else if (pipMode) { return (
{ ); } else if (secondaryFeed) { return ( -
+
{ ); } else { return ( -
+
{ onResize={onResize} primary={true} /> - { sidebarShown && { } = 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
- -
+
{ this.renderToast() } { this.renderContent() } { this.renderCallControls() } diff --git a/src/components/views/voip/CallView/CallViewButtons.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx similarity index 83% rename from src/components/views/voip/CallView/CallViewButtons.tsx rename to src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx index d8f07c826b..b6f06986f0 100644 --- a/src/components/views/voip/CallView/CallViewButtons.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx @@ -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 void; } -const CallViewToggleButton: React.FC = ({ +const LegacyCallViewToggleButton: React.FC = ({ children, state: isOn, className, @@ -57,9 +57,9 @@ const CallViewToggleButton: React.FC = ({ 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 = ({ state, deviceKinds, ...props }) => { +const LegacyCallViewDropdownButton: React.FC = ({ 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 = ({ state, deviceK }; return ( - - + setHoveringDropdown(hovering)} @@ -105,7 +105,7 @@ const CallViewDropdownButton: React.FC = ({ state, deviceK onFinished={closeMenu} deviceKinds={deviceKinds} /> } - + ); }; @@ -141,7 +141,7 @@ interface IState { showMoreMenu: boolean; } -export default class CallViewButtons extends React.Component { +export default class LegacyCallViewButtons extends React.Component { private dialpadButton = createRef(); private contextMenuButton = createRef(); private controlsHideTimer: number = null; @@ -212,8 +212,8 @@ export default class CallViewButtons extends React.Component { }; 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 { let contextMenu; if (this.state.showMoreMenu) { - contextMenu = { { contextMenu } { this.props.buttonsVisibility.dialpad && } - - { this.props.buttonsVisibility.vidMute && } - { this.props.buttonsVisibility.screensharing && } - { this.props.buttonsVisibility.sidebar && } { this.props.buttonsVisibility.contextMenu && { alignment={Alignment.Top} /> } void; onPin?: () => void; onMaximize?: () => void; } -const CallViewHeaderControls: React.FC = ({ onExpand, onPin, onMaximize }) => { - return
+const LegacyCallViewHeaderControls: React.FC = ({ onExpand, onPin, onMaximize }) => { + return
{ onMaximize && } { onPin && } { onExpand && } @@ -52,15 +52,15 @@ interface ISecondaryCallInfoProps { } const SecondaryCallInfo: React.FC = ({ callRoom }) => { - return + return - + { _t("%(name)s on hold", { name: callRoom.name }) } ; }; -interface CallViewHeaderProps { +interface LegacyCallViewHeaderProps { pipMode: boolean; callRooms?: Room[]; onPipMouseDown: (event: React.MouseEvent) => void; @@ -69,7 +69,7 @@ interface CallViewHeaderProps { onMaximize?: () => void; } -const CallViewHeader: React.FC = ({ +const LegacyCallViewHeader: React.FC = ({ pipMode = false, callRooms = [], onPipMouseDown, @@ -81,25 +81,25 @@ const CallViewHeader: React.FC = ({ const callRoomName = callRoom.name; if (!pipMode) { - return
-
- { _t("Call") } - + return
+
+ { _t("Call") } +
; } return (
-
-
{ callRoomName }
+
+
{ callRoomName }
{ onHoldCallRoom && }
- +
); }; -export default CallViewHeader; +export default LegacyCallViewHeader; diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/LegacyCallViewForRoom.tsx similarity index 71% rename from src/components/views/voip/CallViewForRoom.tsx rename to src/components/views/voip/LegacyCallViewForRoom.tsx index 1a44387654..a7f2f97278 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/LegacyCallViewForRoom.tsx @@ -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 { +export default class LegacyCallViewForRoom extends React.Component { constructor(props: IProps) { super(props); this.state = { @@ -48,13 +48,13 @@ export default class CallViewForRoom extends React.Component { } 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 { } }; - 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 { if (!this.state.call) return null; return ( -
+
{ 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" }} > - { +export default class LegacyCallViewSidebar extends React.Component { render() { const feeds = this.props.feeds.map((feed) => { return ( @@ -41,8 +41,8 @@ export default class CallViewSidebar extends React.Component { ); }); - const className = classNames("mx_CallViewSidebar", { - mx_CallViewSidebar_pipMode: this.props.pipMode, + const className = classNames("mx_LegacyCallViewSidebar", { + mx_LegacyCallViewSidebar_pipMode: this.props.pipMode, }); return ( diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 42462de7dd..58ecd1e4ff 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -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 { } 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 { } 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 { if (this.state.primaryCall) { pipContent = ({ onStartMoving, onResize }) => - { pipContent = ({ onStartMoving, _onResize }) =>
- { onStartMoving(event); this.onStartMoving.bind(this)(); }} pipMode={pipMode} callRooms={[roomForWidget]} diff --git a/src/createRoom.ts b/src/createRoom.ts index 61397a8312..df7361c8e5 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -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 { 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 { let modal; if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); - let roomId; + let roomId: string; + let room: Promise; 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 { } }).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. diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 88982b373f..8c2ce8838c 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -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; const MessageEventFactory: Factory = (ref, props) => ; const KeyVerificationConclFactory: Factory = (ref, props) => ; -const CallEventFactory: Factory = (ref, props) => ( - +const LegacyCallEventFactory: Factory = (ref, props) => ( + ); const TextualEventFactory: Factory = (ref, props) => ; const VerificationReqFactory: Factory = (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 = { diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts new file mode 100644 index 0000000000..6a32ee1894 --- /dev/null +++ b/src/hooks/useCall.ts @@ -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 => + useTypedEventEmitterState( + call, + CallEvent.Participants, + useCallback(state => state ?? call.participants, [call]), + ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1e0450ae60..070fb79b4d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.", - "Alternatively, you can try to use the public server at turn.matrix.org, 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 turn.matrix.org, 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 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 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 (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.", + "Alternatively, you can try to use the public server at turn.matrix.org, 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 turn.matrix.org, 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 /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", "Send as message": "Send as message", + "%(count)s people joined|other": "%(count)s people joined", + "%(count)s people joined|one": "%(count)s person joined", + "Audio devices": "Audio devices", + "Audio input %(n)s": "Audio input %(n)s", + "Mute microphone": "Mute microphone", + "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 Resume": "You held the call Resume", "%(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 , which this homeserver has marked as unstable.": "This room is running room version , which this homeserver has marked as unstable.", @@ -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", diff --git a/src/models/Call.ts b/src/models/Call.ts new file mode 100644 index 0000000000..13451ab782 --- /dev/null +++ b/src/models/Call.ts @@ -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(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) => 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 { + 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(); + public get participants(): Set { + return this._participants; + } + protected set participants(value: Set) { + 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; + + /** + * 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; + + /** + * Contacts the widget to disconnect from the call. + */ + protected abstract performDisconnection(): Promise; + + /** + * 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 { + 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 { + 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 { + 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(); + 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(); + 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 { + if (this.room.getMyMembership() !== "join") return; + + const devicesState = this.room.currentState.getStateEvents( + JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!, + ); + const devices = devicesState?.getContent().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 { + await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); + } + + private async removeOurDevice(): Promise { + await this.updateDevices(devices => { + const devicesSet = new Set(devices); + devicesSet.delete(this.client.getDeviceId()); + return Array.from(devicesSet); + }); + } + + public async clean(): Promise { + const now = Date.now(); + const { devices: myDevices } = await this.client.getDevices(); + const deviceMap = new Map(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 { + // Ensure that the messaging doesn't get stopped while we're waiting for responses + const dontStopMessaging = new Promise((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) => { + 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 { + const response = waitForEvent( + this.messaging!, + `action:${ElementWidgetActions.HangupCall}`, + (ev: CustomEvent) => { + 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) => { + // 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(); + }; +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index c5f1511420..e374de12d1 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 76b12b62bb..139bfa4812 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -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 { + public static async setValue( + settingName: string, + roomId: string | null, + level: SettingLevel, + value: any, + ): Promise { // Verify that the setting is actually a setting const setting = SETTINGS[settingName]; if (!setting) { diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index 6a2bc46b86..19718cf0a7 100644 --- a/src/stores/AsyncStoreWithClient.ts +++ b/src/stores/AsyncStoreWithClient.ts @@ -44,6 +44,10 @@ export abstract class AsyncStoreWithClient extends AsyncStore< })(dispatcher); } + public async start(): Promise { + await this.readyStore.start(); + } + get matrixClient(): MatrixClient { return this.readyStore.mxClient; } diff --git a/src/stores/AutoRageshakeStore.ts b/src/stores/AutoRageshakeStore.ts index 26c66549f0..c9540458cb 100644 --- a/src/stores/AutoRageshakeStore.ts +++ b/src/stores/AutoRageshakeStore.ts @@ -45,7 +45,11 @@ interface IState { * reported. */ export default class AutoRageshakeStore extends AsyncStoreWithClient { - private static internalInstance = new AutoRageshakeStore(); + private static readonly internalInstance = (() => { + const instance = new AutoRageshakeStore(); + instance.start(); + return instance; + })(); private constructor() { super(defaultDispatcher, { diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index bc5c342c73..10ea0849c4 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -37,7 +37,11 @@ interface IState { } export class BreadcrumbsStore extends AsyncStoreWithClient { - private static internalInstance = new BreadcrumbsStore(); + private static readonly internalInstance = (() => { + const instance = new BreadcrumbsStore(); + instance.start(); + return instance; + })(); private waitingRooms: { roomId: string, addedTs: number }[] = []; diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts new file mode 100644 index 0000000000..7337ffe896 --- /dev/null +++ b/src/stores/CallStore.ts @@ -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 { + // nothing to do + } + + protected async onReady(): Promise { + // 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("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 { + 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 = new Set(); + /** + * The calls to which the user is currently connected. + */ + public get activeCalls(): Set { + return this._activeCalls; + } + private set activeCalls(value: Set) { + 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(); // Key is room ID + private callListeners = new Map 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 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); + } + }; +} diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts index d04b28a420..43f873b802 100644 --- a/src/stores/ModalWidgetStore.ts +++ b/src/stores/ModalWidgetStore.ts @@ -30,10 +30,14 @@ interface IState { } export class ModalWidgetStore extends AsyncStoreWithClient { - private static internalInstance = new ModalWidgetStore(); - private modalInstance: IHandle = null; - private openSourceWidgetId: string = null; - private openSourceWidgetRoomId: string = null; + private static readonly internalInstance = (() => { + const instance = new ModalWidgetStore(); + instance.start(); + return instance; + })(); + private modalInstance: IHandle | null = null; + private openSourceWidgetId: string | null = null; + private openSourceWidgetRoomId: string | null = null; private constructor() { super(defaultDispatcher, {}); diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 18d25ffad3..846b7cac68 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -92,7 +92,11 @@ const getLocallyCreatedBeaconEventIds = (): string[] => { return ids; }; export class OwnBeaconStore extends AsyncStoreWithClient { - 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(); public readonly beaconsByRoomId = new Map>(); diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index cd5ee2999c..7a8c94b9a4 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -37,7 +37,11 @@ const KEY_DISPLAY_NAME = "mx_profile_displayname"; const KEY_AVATAR_URL = "mx_profile_avatar_url"; export class OwnProfileStore extends AsyncStoreWithClient { - private static internalInstance = new OwnProfileStore(); + private static readonly internalInstance = (() => { + const instance = new OwnProfileStore(); + instance.start(); + return instance; + })(); private monitoredUser: User; diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts index 4060abfe5d..9bc8e21ecf 100644 --- a/src/stores/ReadyWatchingStore.ts +++ b/src/stores/ReadyWatchingStore.ts @@ -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) { super(); + } + public async start(): Promise { 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() { diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts deleted file mode 100644 index 05b2b4dfb7..0000000000 --- a/src/stores/VideoChannelStore.ts +++ /dev/null @@ -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(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 { - 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 { - // 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((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) => { - 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, 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) => { - 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) => { - 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) => { - ev.preventDefault(); - this.audioMuted = true; - this.ack(ev); - }; - - private onUnmuteAudio = (ev: CustomEvent) => { - ev.preventDefault(); - this.audioMuted = false; - this.ack(ev); - }; - - private onMuteVideo = (ev: CustomEvent) => { - ev.preventDefault(); - this.videoMuted = true; - this.ack(ev); - }; - - private onUnmuteVideo = (ev: CustomEvent) => { - 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, {}); - }; -} diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index ec3ec0c2d6..c4377fd57d 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -33,10 +33,11 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { } 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 { diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 0d6ecb5252..bdb95f1895 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -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 { - private static internalInstance = new WidgetStore(); + private static readonly internalInstance = (() => { + const instance = new WidgetStore(); + instance.start(); + return instance; + })(); private widgetMap = new Map(); // Key is widget Unique ID (UID) private roomMap = new Map(); // Key is room ID diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index 8b6ae48c68..1b1532c0af 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -44,10 +44,11 @@ export class EchoStore extends AsyncStoreWithClient { } 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[] { diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 3409f657a8..48aa7e7c20 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -34,7 +34,11 @@ interface IState {} export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator"); export class RoomNotificationStateStore extends AsyncStoreWithClient { - private static internalInstance = new RoomNotificationStateStore(); + private static readonly internalInstance = (() => { + const instance = new RoomNotificationStateStore(); + instance.start(); + return instance; + })(); private roomMap = new Map(); private roomThreadsMap = new Map(); diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index b37691a5a9..327f82153f 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -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; } } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 2fa0abc70d..457295f639 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -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 { - 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>(); diff --git a/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts index 79ebe3d280..cefed0eb2e 100644 --- a/src/stores/room-list/RoomListLayoutStore.ts +++ b/src/stores/room-list/RoomListLayoutStore.ts @@ -34,8 +34,9 @@ export default class RoomListLayoutStore extends AsyncStoreWithClient { } 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; } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 9083943ed9..f80839f66f 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -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; } } diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 9810c386f7..4a94d36b83 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -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; } diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index ca37733106..6359d24702 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -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; diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts similarity index 95% rename from src/stores/room-list/previews/CallAnswerEventPreview.ts rename to src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts index 638b0eb2a6..68826b2611 100644 --- a/src/stores/room-list/previews/CallAnswerEventPreview.ts +++ b/src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts @@ -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)) { diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/LegacyCallHangupEvent.ts similarity index 95% rename from src/stores/room-list/previews/CallHangupEvent.ts rename to src/stores/room-list/previews/LegacyCallHangupEvent.ts index 6cbd0ae1a2..0896e43bf0 100644 --- a/src/stores/room-list/previews/CallHangupEvent.ts +++ b/src/stores/room-list/previews/LegacyCallHangupEvent.ts @@ -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)) { diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/LegacyCallInviteEventPreview.ts similarity index 95% rename from src/stores/room-list/previews/CallInviteEventPreview.ts rename to src/stores/room-list/previews/LegacyCallInviteEventPreview.ts index 4ad109b14c..1aff65decc 100644 --- a/src/stores/room-list/previews/CallInviteEventPreview.ts +++ b/src/stores/room-list/previews/LegacyCallInviteEventPreview.ts @@ -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)) { diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 4fd785602b..ce86b6ec0f 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -1284,7 +1284,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } 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; diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index d9b9806944..8dfced1b70 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -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", diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 7c48d5d96b..4029c33dbf 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -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 { diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 686f222a1f..fb749170de 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -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 { - 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(); // - private readyWidgets = new Set(); // widgets that have sent a WidgetReady event public constructor() { super(defaultDispatcher); @@ -62,12 +63,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); this.widgetMap.set(uid, widgetApi); - widgetApi.once(`action:${ElementWidgetActions.WidgetReady}`, (ev: CustomEvent) => { - 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 { */ 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 { 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); - } } diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingLegacyCallToast.tsx similarity index 59% rename from src/toasts/IncomingCallToast.tsx rename to src/toasts/IncomingLegacyCallToast.tsx index 7b27744c95..ee640411ed 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingLegacyCallToast.tsx @@ -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 { +export default class IncomingLegacyCallToast extends React.Component { 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
- + { room ? room.name : _t("Unknown caller") } -
-
+
+
{ isVoice ? _t("Voice call") : _t("Video call") }
-
+
{ _t("Decline") } diff --git a/src/utils/VideoChannelUtils.ts b/src/utils/VideoChannelUtils.ts deleted file mode 100644 index d7461c9e90..0000000000 --- a/src/utils/VideoChannelUtils.ts +++ /dev/null @@ -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, number] => { - const members = new Set(); - 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(); - 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 => { - 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, 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()?.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(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; -}; diff --git a/test/CallHandler-test.ts b/test/LegacyCallHandler-test.ts similarity index 94% rename from test/CallHandler-test.ts rename to test/LegacyCallHandler-test.ts index 72b105a160..8743c4cdf6 100644 --- a/test/CallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -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 { +function untilCallHandlerEvent(callHandler: LegacyCallHandler, event: LegacyCallHandlerEvent): Promise { return new Promise((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(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 diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 09d8e3c587..39d3986270 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -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", () => { diff --git a/test/components/structures/CallEventGrouper-test.ts b/test/components/structures/LegacyCallEventGrouper-test.ts similarity index 90% rename from test/components/structures/CallEventGrouper-test.ts rename to test/components/structures/LegacyCallEventGrouper-test.ts index 5cd3a273c6..73b9238733 100644 --- a/test/components/structures/CallEventGrouper-test.ts +++ b/test/components/structures/LegacyCallEventGrouper-test.ts @@ -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: () => { diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx index ff5368a84f..f3839a4d2f 100644 --- a/test/components/structures/VideoRoomView-test.tsx +++ b/test/components/structures/VideoRoomView-test.tsx @@ -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; 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 => { + render(); + 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(); - // 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(); - // 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(); - // 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); }); }); diff --git a/test/components/views/elements/AppTile-test.tsx b/test/components/views/elements/AppTile-test.tsx index f88e52f772..3018e98d61 100644 --- a/test/components/views/elements/AppTile-test.tsx +++ b/test/components/views/elements/AppTile-test.tsx @@ -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]; diff --git a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap index d8d2b69e59..d77fb7ff49 100644 --- a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -3,6 +3,7 @@ exports[` displays Bottom aligned tooltip on mouseover 1`] = `