From 6e86a14cc95d7afb2dd9b4763f6ba1431d60cbb5 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 20 Apr 2022 11:03:33 -0400 Subject: [PATCH] Show a lobby screen in video rooms (#8287) * Show a lobby screen in video rooms * Add connecting state * Test VideoRoomView * Test VideoLobby * Get the local video stream with useAsyncMemo * Clean up code review nits * Explicitly state what !important is overriding * Use spacing variables * Wait for video channel messaging * Update join button copy * Show frame on both the lobby and widget * Force dark theme for video lobby * Wait for the widget to be ready * Make VideoChannelStore constructor private * Allow video lobby to shrink * Add invite button to video room header * Show connected members on lobby screen * Make avatars in video lobby clickable * Increase video channel store timeout * Fix Jitsi Meet getting wedged on startup in Chrome and Safari * Revert "Fix Jitsi Meet getting wedged on startup in Chrome and Safari" This reverts commit 9f77b8c227c1a5bffa5d91b0c48bf3bbc44d4cec. * Disable device buttons while connecting * Factor RoomFacePile into a separate file * Fix i18n lint * Fix switching video channels while connected * Properly limit number of connected members in face pile * Fix CSS lint --- res/css/_components.scss | 2 + res/css/structures/_RoomView.scss | 18 +- res/css/structures/_VideoRoomView.scss | 41 ++++ res/css/views/elements/_FacePile.scss | 3 +- res/css/views/rooms/_RoomHeader.scss | 4 + res/css/views/voip/_VideoLobby.scss | 174 +++++++++++++ res/themes/dark/css/_dark.scss | 4 + res/themes/legacy-dark/css/_legacy-dark.scss | 4 + .../legacy-light/css/_legacy-light.scss | 5 + res/themes/light/css/_light.scss | 5 + src/Lifecycle.ts | 3 - src/components/structures/RoomView.tsx | 27 +- src/components/structures/SpaceRoomView.tsx | 6 +- src/components/structures/VideoRoomView.tsx | 67 +++++ src/components/views/elements/FacePile.tsx | 105 +++----- .../views/elements/RoomFacePile.tsx | 107 ++++++++ src/components/views/rooms/RoomHeader.tsx | 11 + src/components/views/rooms/RoomTile.tsx | 57 ++++- src/components/views/voip/VideoLobby.tsx | 232 ++++++++++++++++++ src/i18n/strings/en_EN.json | 34 ++- src/stores/VideoChannelStore.ts | 216 ++++++++++------ src/stores/widgets/ElementWidgetActions.ts | 1 + src/stores/widgets/WidgetMessagingStore.ts | 37 ++- src/utils/VideoChannelUtils.ts | 13 +- .../structures/VideoRoomView-test.tsx | 78 ++++++ .../views/rooms/RoomHeader-test.tsx | 1 + test/components/views/rooms/RoomTile-test.tsx | 17 +- .../components/views/voip/VideoLobby-test.tsx | 167 +++++++++++++ test/stores/VideoChannelStore-test.ts | 132 +++++++--- test/test-utils/video.ts | 34 ++- 30 files changed, 1338 insertions(+), 267 deletions(-) create mode 100644 res/css/structures/_VideoRoomView.scss create mode 100644 res/css/views/voip/_VideoLobby.scss create mode 100644 src/components/structures/VideoRoomView.tsx create mode 100644 src/components/views/elements/RoomFacePile.tsx create mode 100644 src/components/views/voip/VideoLobby.tsx create mode 100644 test/components/structures/VideoRoomView-test.tsx create mode 100644 test/components/views/voip/VideoLobby-test.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 7032c35f39..60cacf0383 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -52,6 +52,7 @@ @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; +@import "./structures/_VideoRoomView.scss"; @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; @@ -323,3 +324,4 @@ @import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_PiPContainer.scss"; @import "./views/voip/_VideoFeed.scss"; +@import "./views/voip/_VideoLobby.scss"; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 84e6041ecd..c73068896d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -211,21 +211,9 @@ hr.mx_RoomView_myReadMarker { opacity: 1; } -// Immersive widgets -.mx_RoomView_immersive { - .mx_RoomHeader_wrapper { - border: unset; - } - - .mx_AppTile { - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); - width: auto; - height: 100%; - padding-top: 33px; // to match the right panel chat heading - - border-radius: 8px; - } +// Rooms with immersive content +.mx_RoomView_immersive .mx_RoomHeader_wrapper { + border: unset; } .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss new file mode 100644 index 0000000000..d99b3f5894 --- /dev/null +++ b/res/css/structures/_VideoRoomView.scss @@ -0,0 +1,41 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VideoRoomView { + flex-grow: 1; + min-height: 0; + + display: flex; + flex-direction: column; + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); + + background-color: $header-panel-bg-color; + padding-top: 33px; // to match the right panel chat heading + border: 8px solid $header-panel-bg-color; + border-radius: 8px; + + .mx_AppTile { + width: auto; + height: 100%; + border: none; + } + + // While the lobby is shown, the widget needs to stay loaded but hidden in the background + .mx_VideoLobby ~ .mx_AppTile { + display: none; + } +} diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 3e83446b0e..90f1c590a1 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -20,7 +20,8 @@ limitations under the License. flex-direction: row-reverse; vertical-align: middle; - > .mx_FacePile_face + .mx_FacePile_face { + // Overlap the children + > * + * { margin-right: -8px; } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 7d25ade6ba..85c139402b 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -217,6 +217,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } +.mx_RoomHeader_inviteButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); +} + .mx_RoomHeader_voiceCallButton::before { mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss new file mode 100644 index 0000000000..a708e79c90 --- /dev/null +++ b/res/css/views/voip/_VideoLobby.scss @@ -0,0 +1,174 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VideoLobby { + min-height: 0; + flex-grow: 1; + padding: $spacing-12; + color: $video-lobby-primary-content; + background-color: $video-lobby-background; + border-radius: 8px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-32; + + .mx_FacePile { + width: fit-content; + margin: $spacing-8 auto 0; + + .mx_FacePile_faces .mx_BaseAvatar_image { + border-color: $video-lobby-background; + } + } + + .mx_VideoLobby_preview { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 1.5; + background-color: $video-lobby-system; + + border-radius: 20px; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .mx_BaseAvatar { + margin: $spacing-20; + + // Override the explicit dimensions on the element so that this gets sized responsively + width: unset !important; + height: unset !important; + min-width: 0; + min-height: 0; + flex: 0 1 200px; + } + + video { + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scaleX(-1); // flip the image + background-color: black; + } + + .mx_VideoLobby_controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + + background-color: rgba($video-lobby-background, 0.9); + + display: flex; + justify-content: center; + gap: $spacing-24; + + .mx_VideoLobby_deviceButtonWrapper { + position: relative; + margin: 6px 0 10px; + + .mx_VideoLobby_deviceButton { + $size: 50px; + + width: $size; + height: $size; + + background-color: $video-lobby-primary-content; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-repeat: no-repeat; + mask-size: 20px; + mask-position: center; + background-color: $video-lobby-system; + height: 100%; + width: 100%; + } + + &.mx_VideoLobby_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-off.svg'); + } + } + + .mx_VideoLobby_deviceListButton { + $size: 15px; + + position: absolute; + bottom: 0; + right: -2.5px; + width: $size; + height: $size; + + background-color: $video-lobby-primary-content; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-size: $size; + mask-position: center; + background-color: $video-lobby-system; + height: 100%; + width: 100%; + } + } + + &.mx_VideoLobby_deviceButtonWrapper_active { + .mx_VideoLobby_deviceButton, + .mx_VideoLobby_deviceListButton { + background-color: $video-lobby-system; + + &::before { + background-color: $video-lobby-primary-content; + } + } + + .mx_VideoLobby_deviceButton { + &.mx_VideoLobby_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-on.svg'); + } + } + } + } + } + } + + .mx_VideoLobby_joinButton { + padding-left: 50px; + padding-right: 50px; + } +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index aa95979a7d..38fd3a58da 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -187,6 +187,10 @@ $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; $video-feed-secondary-background: $system; + +$video-lobby-system: $system; +$video-lobby-background: $background; +$video-lobby-primary-content: $primary-content; // ******************** // Location sharing diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 981495bd13..6f958c08fd 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -120,6 +120,10 @@ $call-view-button-off-background: $primary-content; $video-feed-secondary-background: $system; +$video-lobby-system: $system; +$video-lobby-background: $background; +$video-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.scss b/res/themes/legacy-light/css/_legacy-light.scss index d67b6243c7..e1da4d277d 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -178,6 +178,11 @@ $call-view-button-off-background: $secondary-content; $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; + $username-variant1-color: #368bd6; $username-variant2-color: #ac3ba8; $username-variant3-color: #03b381; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 1c933a6146..14ed62f726 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -280,6 +280,11 @@ $call-view-button-off-background: $secondary-content; $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; // ******************** // One-off colors diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index da4010c57e..f91158c38a 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -36,7 +36,6 @@ import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import VideoChannelStore from "./stores/VideoChannelStore"; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; @@ -807,7 +806,6 @@ async function startMatrixClient(startSyncing = true): Promise { IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.instance.start(); CallHandler.instance.start(); - if (SettingsStore.getValue("feature_video_rooms")) VideoChannelStore.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 @@ -926,7 +924,6 @@ export function stopMatrixClient(unsetClient = true): void { UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); Presence.stop(); - if (SettingsStore.getValue("feature_video_rooms")) VideoChannelStore.instance.stop(); ActiveWidgetStore.instance.stop(); IntegrationManagers.sharedInstance().stopWatching(); Mjolnir.sharedInstance().stop(); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index f53e75db0b..761dd9b496 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -75,8 +75,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; import WidgetStore from "../../stores/WidgetStore"; -import { getVideoChannel } from "../../utils/VideoChannelUtils"; -import AppTile from "../views/elements/AppTile"; +import VideoRoomView from "./VideoRoomView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -1249,7 +1248,7 @@ export class RoomView extends React.Component { } }; - private onInviteButtonClick = () => { + private onInviteClick = () => { // open the room inviter dis.dispatch({ action: 'view_invite', @@ -1904,7 +1903,7 @@ export class RoomView extends React.Component { statusBar = ; @@ -2169,18 +2168,11 @@ export class RoomView extends React.Component { ; break; case MainSplitContentType.Video: { - const app = getVideoChannel(this.state.room.roomId); - if (!app) break; mainSplitContentClassName = "mx_MainSplit_video"; - mainSplitBody = ; + mainSplitBody = <> + + { previewBar } + ; } } const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); @@ -2190,6 +2182,7 @@ export class RoomView extends React.Component { let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; + let onInviteClick = null; // Simplify the header for other main split types switch (this.state.mainSplitContentType) { @@ -2212,6 +2205,9 @@ export class RoomView extends React.Component { onAppsClick = null; onForgetClick = null; onSearchClick = null; + if (this.state.room.canInvite(this.context.credentials.userId)) { + onInviteClick = this.onInviteClick; + } } return ( @@ -2227,6 +2223,7 @@ export class RoomView extends React.Component { oobData={this.props.oobData} inRoom={myMembership === 'join'} onSearchClick={onSearchClick} + onInviteClick={onInviteClick} onForgetClick={(myMembership === "leave") ? onForgetClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index aaf3e4e135..1e9d5caa0c 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -58,7 +58,7 @@ import { } from "../../utils/space"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; -import FacePile from "../views/elements/FacePile"; +import RoomFacePile from "../views/elements/RoomFacePile"; import { AddExistingToSpace, defaultDmsRenderer, @@ -298,7 +298,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp } - { space.getJoinRule() === "public" && } + { space.getJoinRule() === "public" && }
{ joinButtons }
@@ -454,7 +454,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- + { inviteButton } { settingsButton }
diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx new file mode 100644 index 0000000000..2695dafa79 --- /dev/null +++ b/src/components/structures/VideoRoomView.tsx @@ -0,0 +1,67 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useContext, useState, useMemo } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; +import { getVideoChannel } from "../../utils/VideoChannelUtils"; +import WidgetStore 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"; + +const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing }) => { + const cli = useContext(MatrixClientContext); + const store = VideoChannelStore.instance; + + // In case we mount before the WidgetStore knows about our Jitsi widget + const [widgetLoaded, setWidgetLoaded] = useState(false); + useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => { + if (roomId === null || roomId === room.roomId) setWidgetLoaded(true); + }); + + const app = useMemo(() => { + const app = getVideoChannel(room.roomId); + if (!app) logger.warn(`No video channel for room ${room.roomId}`); + return app; + }, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + + 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; + + return
+ { connected ? null : } + { /* We render the widget even if we're disconnected, so it stays loaded */ } + +
; +}; + +export default VideoRoomView; diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index bb39cc7957..566eddbe07 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -14,91 +14,46 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, HTMLAttributes, ReactNode, useContext } from "react"; -import { Room } from "matrix-js-sdk/src/models/room"; +import React, { FC, HTMLAttributes, ReactNode } from "react"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { sortBy } from "lodash"; import MemberAvatar from "../avatars/MemberAvatar"; -import { _t } from "../../../languageHandler"; -import DMRoomMap from "../../../utils/DMRoomMap"; -import TextWithTooltip from "../elements/TextWithTooltip"; -import { useRoomMembers } from "../../../hooks/useRoomMembers"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; - -const DEFAULT_NUM_FACES = 5; +import TooltipTarget from "./TooltipTarget"; +import TextWithTooltip from "./TextWithTooltip"; interface IProps extends HTMLAttributes { - room: Room; - onlyKnownUsers?: boolean; - numShown?: number; + members: RoomMember[]; + faceSize: number; + overflow: boolean; + tooltip?: ReactNode; + children?: ReactNode; } -const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; +const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { + const faces = members.map( + tooltip ? + m => : + m => + + , + ); -const FacePile: FC = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }) => { - const cli = useContext(MatrixClientContext); - const isJoined = room.getMyMembership() === "join"; - let members = useRoomMembers(room); - const count = members.length; - - // sort users with an explicit avatar first - const iteratees = [member => member.getMxcAvatarUrl() ? 0 : 1]; - if (onlyKnownUsers) { - members = members.filter(isKnownMember); - } else { - // sort known users first - iteratees.unshift(member => isKnownMember(member) ? 0 : 1); - } - - // exclude ourselves from the shown members list - const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); - if (shownMembers.length < 1) return null; - - // We reverse the order of the shown faces in CSS to simplify their visual overlap, - // reverse members in tooltip order to make the order between the two match up. - const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); - - let tooltip: ReactNode; - if (props.onClick) { - let subText: string; - if (isJoined) { - subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers }); - } else { - subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }); - } - - tooltip =
-
- { _t("View all %(count)s members", { count }) } -
-
- { subText } -
-
; - } else { - if (isJoined) { - tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", { - count: count - 1, - commaSeparatedMembers, - }); - } else { - tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { - count, - commaSeparatedMembers, - }); - } - } + const pileContents = <> + { overflow ? : null } + { faces } + ; return
- - { members.length > numShown ? : null } - { shownMembers.map(m => - ) } - - { onlyKnownUsers && - { _t("%(count)s people you know have already joined", { count: members.length }) } - } + { tooltip ? ( + + { pileContents } + + ) : ( +
+ { pileContents } +
+ ) } + { children }
; }; diff --git a/src/components/views/elements/RoomFacePile.tsx b/src/components/views/elements/RoomFacePile.tsx new file mode 100644 index 0000000000..0b88403fff --- /dev/null +++ b/src/components/views/elements/RoomFacePile.tsx @@ -0,0 +1,107 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, HTMLAttributes, ReactNode, useContext } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { sortBy } from "lodash"; + +import { _t } from "../../../languageHandler"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import FacePile from "./FacePile"; +import { useRoomMembers } from "../../../hooks/useRoomMembers"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +const DEFAULT_NUM_FACES = 5; + +const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; + +interface IProps extends HTMLAttributes { + room: Room; + onlyKnownUsers?: boolean; + numShown?: number; +} + +const RoomFacePile: FC = ( + { room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }, +) => { + const cli = useContext(MatrixClientContext); + const isJoined = room.getMyMembership() === "join"; + let members = useRoomMembers(room); + const count = members.length; + + // sort users with an explicit avatar first + const iteratees = [member => member.getMxcAvatarUrl() ? 0 : 1]; + if (onlyKnownUsers) { + members = members.filter(isKnownMember); + } else { + // sort known users first + iteratees.unshift(member => isKnownMember(member) ? 0 : 1); + } + + // exclude ourselves from the shown members list + const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); + if (shownMembers.length < 1) return null; + + // We reverse the order of the shown faces in CSS to simplify their visual overlap, + // reverse members in tooltip order to make the order between the two match up. + const commaSeparatedMembers = shownMembers.map(m => m.name).reverse().join(", "); + + let tooltip: ReactNode; + if (props.onClick) { + let subText: string; + if (isJoined) { + subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers }); + } else { + subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }); + } + + tooltip =
+
+ { _t("View all %(count)s members", { count }) } +
+
+ { subText } +
+
; + } else { + if (isJoined) { + tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", { + count: count - 1, + commaSeparatedMembers, + }); + } else { + tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { + count, + commaSeparatedMembers, + }); + } + } + + return numShown} + tooltip={tooltip} + {...props} + > + { onlyKnownUsers && + { _t("%(count)s people you know have already joined", { count: members.length }) } + } + ; +}; + +export default RoomFacePile; diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 1cdd2e770e..9983b6f39c 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -53,6 +53,7 @@ interface IProps { oobData?: IOOBData; inRoom: boolean; onSearchClick: () => void; + onInviteClick: () => void; onForgetClick: () => void; onCallPlaced: (type: CallType) => void; onAppsClick: () => void; @@ -255,6 +256,16 @@ export default class RoomHeader extends React.Component { buttons.push(searchButton); } + if (this.props.onInviteClick && this.props.inRoom) { + const inviteButton = ; + buttons.push(inviteButton); + } + const rightRow =
{ buttons } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 29476a55cf..530b22571a 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -61,6 +61,7 @@ import { RoomViewStore } from "../../../stores/RoomViewStore"; enum VideoStatus { Disconnected, + Connecting, Connected, } @@ -105,7 +106,16 @@ export default class RoomTile extends React.PureComponent { constructor(props: IProps) { super(props); - const videoConnected = VideoChannelStore.instance.roomId === this.props.room.roomId; + let videoStatus; + if (VideoChannelStore.instance.roomId === this.props.room.roomId) { + if (VideoChannelStore.instance.connected) { + videoStatus = VideoStatus.Connected; + } else { + videoStatus = VideoStatus.Connecting; + } + } else { + videoStatus = VideoStatus.Disconnected; + } this.state = { selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, @@ -113,9 +123,9 @@ export default class RoomTile extends React.PureComponent { generalMenuPosition: null, // generatePreview() will return nothing if the user has previews disabled messagePreview: "", - videoStatus: videoConnected ? VideoStatus.Connected : VideoStatus.Disconnected, + videoStatus, videoMembers: getConnectedMembers(this.props.room.currentState), - jitsiParticipants: videoConnected ? VideoChannelStore.instance.participants : [], + jitsiParticipants: VideoChannelStore.instance.participants, }; this.generatePreview(); @@ -185,8 +195,9 @@ export default class RoomTile extends React.PureComponent { this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate); this.props.room.currentState.on(RoomStateEvent.Events, this.updateVideoMembers); - VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoStatus); - VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoStatus); + VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.onConnectVideo); + VideoChannelStore.instance.on(VideoChannelEvent.StartConnect, this.onStartConnectVideo); + VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.onDisconnectVideo); if (VideoChannelStore.instance.roomId === this.props.room.roomId) { VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants); } @@ -204,8 +215,9 @@ export default class RoomTile extends React.PureComponent { this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); - VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoStatus); - VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoStatus); + VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.onConnectVideo); + VideoChannelStore.instance.off(VideoChannelEvent.StartConnect, this.onStartConnectVideo); + VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.onDisconnectVideo); } private onAction = (payload: ActionPayload) => { @@ -586,15 +598,37 @@ export default class RoomTile extends React.PureComponent { private updateVideoStatus = () => { if (VideoChannelStore.instance.roomId === this.props.room?.roomId) { + if (VideoChannelStore.instance.connected) { + this.onConnectVideo(this.props.room?.roomId); + } else { + this.onStartConnectVideo(this.props.room?.roomId); + } + } else { + this.onDisconnectVideo(this.props.room?.roomId); + } + }; + + private onConnectVideo = (roomId: string) => { + if (roomId === this.props.room?.roomId) { this.setState({ videoStatus: VideoStatus.Connected }); VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants); - } else { + } + }; + + private onStartConnectVideo = (roomId: string) => { + if (roomId === this.props.room?.roomId) { + this.setState({ videoStatus: VideoStatus.Connecting }); + } + }; + + private onDisconnectVideo = (roomId: string) => { + if (roomId === this.props.room?.roomId) { this.setState({ videoStatus: VideoStatus.Disconnected }); VideoChannelStore.instance.off(VideoChannelEvent.Participants, this.updateJitsiParticipants); } }; - private updateJitsiParticipants = (participants: IJitsiParticipant[]) => { + private updateJitsiParticipants = (roomId: string, participants: IJitsiParticipant[]) => { this.setState({ jitsiParticipants: participants }); }; @@ -636,6 +670,11 @@ export default class RoomTile extends React.PureComponent { videoActive = false; participantCount = this.state.videoMembers.length; break; + case VideoStatus.Connecting: + videoText = _t("Connecting..."); + videoActive = true; + participantCount = this.state.videoMembers.length; + break; case VideoStatus.Connected: videoText = _t("Connected"); videoActive = true; diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx new file mode 100644 index 0000000000..84bc470273 --- /dev/null +++ b/src/components/views/voip/VideoLobby.tsx @@ -0,0 +1,232 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useState, useMemo, useRef, useEffect } 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 { useStateToggle } from "../../../hooks/useStateToggle"; +import { useConnectedMembers } from "../../../utils/VideoChannelUtils"; +import VideoChannelStore from "../../../stores/VideoChannelStore"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; +import { Alignment } from "../elements/Tooltip"; +import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import FacePile from "../elements/FacePile"; +import MemberAvatar from "../avatars/MemberAvatar"; + +interface IDeviceButtonProps { + kind: string; + devices: MediaDeviceInfo[]; + setDevice: (device: MediaDeviceInfo) => void; + deviceListLabel: string; + active: boolean; + disabled: boolean; + toggle: () => void; + activeTitle: string; + inactiveTitle: string; +} + +const DeviceButton: FC = ({ + kind, devices, setDevice, deviceListLabel, active, disabled, toggle, activeTitle, inactiveTitle, +}) => { + // 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) { + const selectDevice = (device: MediaDeviceInfo) => { + setDevice(device); + closeMenu(); + }; + + const buttonRect = buttonRef.current.getBoundingClientRect(); + contextMenu = + + { labelledDevices.map(d => + selectDevice(d)} + />, + ) } + + ; + } + + if (!devices.length) return null; + + return
+ + { labelledDevices.length > 1 ? ( + + ) : null } + { contextMenu } +
; +}; + +const MAX_FACES = 8; + +const VideoLobby: FC<{ room: Room }> = ({ room }) => { + const [connecting, setConnecting] = useState(false); + const me = useMemo(() => room.getMember(room.myUserId), [room]); + const connectedMembers = useConnectedMembers(room.currentState); + const videoRef = useRef(); + + const devices = useAsyncMemo(async () => { + try { + return await navigator.mediaDevices.enumerateDevices(); + } catch (e) { + 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 audioDevice = selectedAudioDevice ?? audioDevices[0]; + const videoDevice = selectedVideoDevice ?? videoDevices[0]; + + const [audioActive, toggleAudio] = useStateToggle(true); + const [videoActive, toggleVideo] = useStateToggle(true); + + const videoStream = useAsyncMemo(async () => { + if (videoDevice && videoActive) { + try { + return await navigator.mediaDevices.getUserMedia({ + video: { deviceId: videoDevice.deviceId }, + }); + } catch (e) { + logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`); + } + } + return null; + }, [videoDevice, videoActive]); + + useEffect(() => { + if (videoStream) { + const videoElement = videoRef.current; + videoElement.srcObject = videoStream; + videoElement.play(); + + return () => { + videoStream?.getTracks().forEach(track => track.stop()); + videoElement.srcObject = null; + }; + } + }, [videoStream]); + + const connect = async () => { + setConnecting(true); + try { + await VideoChannelStore.instance.connect( + room.roomId, audioActive ? audioDevice : null, videoActive ? videoDevice : null, + ); + } catch (e) { + logger.error(e); + setConnecting(false); + } + }; + + let facePile; + if (connectedMembers.length) { + const shownMembers = connectedMembers.slice(0, MAX_FACES); + const overflow = connectedMembers.length > shownMembers.length; + + facePile =
+ { _t("%(count)s people connected", { count: connectedMembers.length }) } + +
; + } + + return
+ { facePile } +
+ +
+ + { _t("Connect now") } + +
; +}; + +export default VideoLobby; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c6a66c7967..6c8a1a0a4f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1018,6 +1018,15 @@ "Your camera is turned off": "Your camera is turned off", "Your camera is still enabled": "Your camera is still enabled", "Dial": "Dial", + "%(count)s people connected|other": "%(count)s people connected", + "%(count)s people connected|one": "%(count)s person connected", + "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", + "Connect now": "Connect now", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", @@ -1763,6 +1772,7 @@ "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", "Search": "Search", + "Invite": "Invite", "Start new chat": "Start new chat", "Invite to space": "Invite to space", "You do not have permissions to invite people to this space": "You do not have permissions to invite people to this space", @@ -1787,7 +1797,6 @@ "Explore all public rooms": "Explore all public rooms", "%(count)s results|other": "%(count)s results", "%(count)s results|one": "%(count)s result", - "Invite": "Invite", "Add space": "Add space", "You do not have permissions to add spaces to this space": "You do not have permissions to add spaces to this space", "Join public room": "Join public room", @@ -1865,6 +1874,7 @@ "Copy room link": "Copy room link", "Leave": "Leave", "Video": "Video", + "Connecting...": "Connecting...", "Connected": "Connected", "%(count)s participants|other": "%(count)s participants", "%(count)s participants|one": "1 participant", @@ -2298,17 +2308,6 @@ "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssent a hidden message", "%(oneUser)ssent %(count)s hidden messages|other": "%(oneUser)ssent %(count)s hidden messages", "%(oneUser)ssent %(count)s hidden messages|one": "%(oneUser)ssent a hidden message", - "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", - "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", - "View all %(count)s members|other": "View all %(count)s members", - "View all %(count)s members|one": "View 1 member", - "%(count)s members including you, %(commaSeparatedMembers)s|other": "%(count)s members including you, %(commaSeparatedMembers)s", - "%(count)s members including you, %(commaSeparatedMembers)s|zero": "You", - "%(count)s members including you, %(commaSeparatedMembers)s|one": "%(count)s members including you and %(commaSeparatedMembers)s", - "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", - "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", - "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", - "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "collapse": "collapse", "expand": "expand", "Rotate Left": "Rotate Left", @@ -2349,6 +2348,17 @@ "This address is available to use": "This address is available to use", "This address is already in use": "This address is already in use", "This address had invalid server or is already in use": "This address had invalid server or is already in use", + "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "View all %(count)s members|other": "View all %(count)s members", + "View all %(count)s members|one": "View 1 member", + "%(count)s members including you, %(commaSeparatedMembers)s|other": "%(count)s members including you, %(commaSeparatedMembers)s", + "%(count)s members including you, %(commaSeparatedMembers)s|zero": "You", + "%(count)s members including you, %(commaSeparatedMembers)s|one": "%(count)s members including you and %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", + "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "Server Options": "Server Options", "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.", "Join millions for free on the largest public server": "Join millions for free on the largest public server", diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 6bd1b621e4..58e18cab98 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -14,23 +14,24 @@ 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 EventEmitter from "events"; import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; -import { MatrixClientPeg } from "../MatrixClientPeg"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; -import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; -import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore"; +import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore"; import { - VIDEO_CHANNEL, VIDEO_CHANNEL_MEMBER, IVideoChannelMemberContent, getVideoChannel, } 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", @@ -43,10 +44,25 @@ export interface IJitsiParticipant { 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 EventEmitter { +export default class VideoChannelStore extends AsyncStoreWithClient { private static _instance: VideoChannelStore; public static get instance(): VideoChannelStore { @@ -56,65 +72,121 @@ export default class VideoChannelStore extends EventEmitter { return VideoChannelStore._instance; } - private readonly cli = MatrixClientPeg.get(); + private constructor() { + super(defaultDispatcher); + } + + protected async onAction(payload: ActionPayload): Promise { + // nothing to do + } + private activeChannel: ClientWidgetApi; + private _roomId: string; - private _participants: IJitsiParticipant[]; + public get roomId(): string { return this._roomId; } + private set roomId(value: string) { this._roomId = value; } - public get roomId(): string { - return this._roomId; - } + private _connected = false; + public get connected(): boolean { return this._connected; } + private set connected(value: boolean) { this._connected = value; } - public get participants(): IJitsiParticipant[] { - return this._participants; - } + private _participants: IJitsiParticipant[] = []; + public get participants(): IJitsiParticipant[] { return this._participants; } + private set participants(value: IJitsiParticipant[]) { this._participants = value; } - public start = () => { - ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); - }; + public connect = async (roomId: string, audioDevice: MediaDeviceInfo, videoDevice: MediaDeviceInfo) => { + if (this.activeChannel) await this.disconnect(); - public stop = () => { - ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); - }; - - private setConnected = async (roomId: string) => { const jitsi = getVideoChannel(roomId); if (!jitsi) throw new Error(`No video channel in room ${roomId}`); - const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi)); - if (!messaging) throw new Error(`Failed to bind 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}`); + } + } + + if (!messagingStore.isWidgetReady(jitsiUid)) { + // Wait for the widget to be ready to receive our join event + try { + await waitForEvent( + messagingStore, + WidgetMessagingStoreEvent.WidgetReady, + (uid: string) => uid === jitsiUid, + ); + } catch (e) { + throw new Error(`Video channel in room ${roomId} never became ready: ${e}`); + } + } this.activeChannel = messaging; - this._roomId = roomId; - this._participants = []; + this.roomId = roomId; + // Participant data will come down the event pipeline quickly, so prepare in advance + messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); - this.activeChannel.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.activeChannel.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + this.emit(VideoChannelEvent.StartConnect, roomId); - this.emit(VideoChannelEvent.Connect); + // Actually perform the join + const waitForJoin = waitForEvent( + messaging, + `action:${ElementWidgetActions.JoinCall}`, + (ev: CustomEvent) => { + this.ack(ev); + return true; + }, + ); + messaging.transport.send(ElementWidgetActions.JoinCall, { + audioDevice: audioDevice?.label, + videoDevice: videoDevice?.label, + }); + try { + await waitForJoin; + } catch (e) { + // If it timed out, clean up our advance preparations + this.activeChannel = null; + this.roomId = null; + messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + + this.emit(VideoChannelEvent.Disconnect, roomId); + + throw new Error(`Failed to join call in room ${roomId}: ${e}`); + } + + this.connected = true; + messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + + this.emit(VideoChannelEvent.Connect, roomId); // Tell others that we're connected, by adding our device to room state - await this.updateDevices(devices => Array.from(new Set(devices).add(this.cli.getDeviceId()))); + this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.matrixClient.getDeviceId()))); }; - private setDisconnected = async () => { - this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); - - this.activeChannel = null; - this._participants = null; + 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 { - // Tell others that we're disconnected, by removing our device from room state - await this.updateDevices(devices => { - const devicesSet = new Set(devices); - devicesSet.delete(this.cli.getDeviceId()); - return Array.from(devicesSet); - }); - } finally { - // Save this for last, since updateDevices needs the room ID - this._roomId = null; - this.emit(VideoChannelEvent.Disconnect); + await waitForDisconnect; // onHangup cleans up for us + } catch (e) { + throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } }; @@ -124,41 +196,41 @@ export default class VideoChannelStore extends EventEmitter { this.activeChannel.transport.reply(ev.detail, {}); }; - private updateDevices = async (fn: (devices: string[]) => string[]) => { - if (!this.roomId) { - logger.error("Tried to update devices while disconnected"); - return; - } - - const room = this.cli.getRoom(this.roomId); - const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.cli.getUserId()); + private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => { + const room = this.matrixClient.getRoom(roomId); + const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.matrixClient.getUserId()); const devices = devicesState?.getContent()?.devices ?? []; - await this.cli.sendStateEvent( - this.roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(), + await this.matrixClient.sendStateEvent( + roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.matrixClient.getUserId(), ); }; private onHangup = async (ev: CustomEvent) => { this.ack(ev); - await this.setDisconnected(); + + this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + + const roomId = this.roomId; + 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 this.updateDevices(roomId, devices => { + const devicesSet = new Set(devices); + devicesSet.delete(this.matrixClient.getDeviceId()); + return Array.from(devicesSet); + }); }; private onParticipants = (ev: CustomEvent) => { - this._participants = ev.detail.data.participants as IJitsiParticipant[]; - this.emit(VideoChannelEvent.Participants, ev.detail.data.participants); + this.participants = ev.detail.data.participants as IJitsiParticipant[]; + this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants); this.ack(ev); }; - - private onActiveWidgetUpdate = async () => { - if (this.activeChannel) { - // We got disconnected from the previous video channel, so clean up - await this.setDisconnected(); - } - - // If the new active widget is a video channel, that means we joined - if (ActiveWidgetStore.instance.getPersistentWidgetId() === VIDEO_CHANNEL) { - await this.setConnected(ActiveWidgetStore.instance.getPersistentRoomId()); - } - }; } diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index e58581ce92..117c4b47f3 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -18,6 +18,7 @@ import { IWidgetApiRequest } from "matrix-widget-api"; export enum ElementWidgetActions { ClientReady = "im.vector.ready", + WidgetReady = "io.element.widget_ready", JoinCall = "io.element.join", HangupCall = "im.vector.hangup", CallParticipants = "io.element.participants", diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 1766db2759..d954af6d60 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -14,14 +14,20 @@ * limitations under the License. */ -import { ClientWidgetApi, Widget } from "matrix-widget-api"; +import { ClientWidgetApi, Widget, IWidgetApiRequest } from "matrix-widget-api"; +import { ElementWidgetActions } from "./ElementWidgetActions"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { EnhancedMap } from "../../utils/maps"; import WidgetUtils from "../../utils/WidgetUtils"; +export enum WidgetMessagingStoreEvent { + StoreMessaging = "store_messaging", + WidgetReady = "widget_ready", +} + /** * Temporary holding store for widget messaging instances. This is eventually * going to be merged with a more complete WidgetStore, but for now it's @@ -31,6 +37,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { private static internalInstance = new WidgetMessagingStore(); private widgetMap = new EnhancedMap(); // + private readyWidgets = new Set(); // widgets that have sent a WidgetReady event public constructor() { super(defaultDispatcher); @@ -51,11 +58,22 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { public storeMessaging(widget: Widget, roomId: string, widgetApi: ClientWidgetApi) { this.stopMessaging(widget, roomId); - this.widgetMap.set(WidgetUtils.calcWidgetUid(widget.id, roomId), widgetApi); + 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); } public stopMessaging(widget: Widget, roomId: string) { - this.widgetMap.remove(WidgetUtils.calcWidgetUid(widget.id, roomId))?.stop(); + const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); + this.widgetMap.remove(uid)?.stop(); + this.readyWidgets.delete(uid); } public getMessaging(widget: Widget, roomId: string): ClientWidgetApi { @@ -64,7 +82,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { /** * Stops the widget messaging instance for a given widget UID. - * @param {string} widgetId The widget UID. + * @param {string} widgetUid The widget UID. */ public stopMessagingByUid(widgetUid: string) { this.widgetMap.remove(widgetUid)?.stop(); @@ -72,11 +90,18 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { /** * Gets the widget messaging class for a given widget UID. - * @param {string} widgetId The widget UID. + * @param {string} widgetUid The widget UID. * @returns {ClientWidgetApi} The widget API, or a falsey value if not found. - * @deprecated Widget IDs are not globally unique. */ 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/utils/VideoChannelUtils.ts b/src/utils/VideoChannelUtils.ts index 0387f81b8e..11a1a9a35f 100644 --- a/src/utils/VideoChannelUtils.ts +++ b/src/utils/VideoChannelUtils.ts @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { useState } from "react"; +import { throttle } from "lodash"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { useTypedEventEmitter } from "../hooks/useEventEmitter"; import WidgetStore, { IApp } from "../stores/WidgetStore"; import { WidgetType } from "../widgets/WidgetType"; import WidgetUtils from "./WidgetUtils"; @@ -45,3 +48,11 @@ export const getConnectedMembers = (state: RoomState): RoomMember[] => .filter(e => e.getContent()?.devices?.length) .map(e => state.getMember(e.getStateKey())) .filter(member => member?.membership === "join"); + +export const useConnectedMembers = (state: RoomState, throttleMs = 100) => { + const [members, setMembers] = useState(getConnectedMembers(state)); + useTypedEventEmitter(state, RoomStateEvent.Update, throttle(() => { + setMembers(getConnectedMembers(state)); + }, throttleMs, { leading: true, trailing: true })); + return members; +}; diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx new file mode 100644 index 0000000000..11d747103d --- /dev/null +++ b/test/components/structures/VideoRoomView-test.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import { MatrixWidgetType } from "matrix-widget-api"; + +import { stubClient, stubVideoChannelStore, mkRoom, wrapInMatrixClientContext } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { VIDEO_CHANNEL } 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"; + +const VideoRoomView = wrapInMatrixClientContext(_VideoRoomView); + +describe("VideoRoomView", () => { + stubClient(); + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + id: VIDEO_CHANNEL, + 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, + }]); + Object.defineProperty(navigator, "mediaDevices", { + value: { enumerateDevices: () => [] }, + }); + + const cli = MatrixClientPeg.get(); + const room = mkRoom(cli, "!1:example.org"); + + let store; + beforeEach(() => { + store = stubVideoChannelStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("shows lobby and keeps widget loaded when disconnected", async () => { + const view = mount(); + // Wait for state to settle + await act(async () => Promise.resolve()); + + expect(view.find(VideoLobby).exists()).toEqual(true); + expect(view.find(AppTile).exists()).toEqual(true); + }); + + it("only shows widget when connected", async () => { + store.connect("!1:example.org"); + const view = mount(); + // Wait for state to settle + await act(async () => Promise.resolve()); + + expect(view.find(VideoLobby).exists()).toEqual(false); + expect(view.find(AppTile).exists()).toEqual(true); + }); +}); diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index c8030ad7c9..1037b0377c 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -176,6 +176,7 @@ function render(room: Room, roomContext?: Partial): ReactWrapper { room={room} inRoom={true} onSearchClick={() => {}} + onInviteClick={null} onForgetClick={() => {}} onCallPlaced={(_type) => { }} onAppsClick={() => {}} diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 4ac7a369b6..d209c32f0f 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -18,34 +18,23 @@ import React from "react"; import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; import { mocked } from "jest-mock"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { stubClient, mockStateEventImplementation, mkRoom, - mkEvent, + mkVideoChannelMember, stubVideoChannelStore, } from "../../../test-utils"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { DefaultTagID } from "../../../../src/stores/room-list/models"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { VIDEO_CHANNEL_MEMBER } from "../../../../src/utils/VideoChannelUtils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; -const mkVideoChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({ - event: true, - type: VIDEO_CHANNEL_MEMBER, - room: "!1:example.org", - user: userId, - skey: userId, - content: { devices }, -}); - describe("RoomTile", () => { jest.spyOn(PlatformPeg, 'get') .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); @@ -85,6 +74,10 @@ describe("RoomTile", () => { ); expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Video"); + act(() => { store.startConnect("!1:example.org"); }); + tile.update(); + expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Connecting..."); + act(() => { store.connect("!1:example.org"); }); tile.update(); expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Connected"); diff --git a/test/components/views/voip/VideoLobby-test.tsx b/test/components/views/voip/VideoLobby-test.tsx new file mode 100644 index 0000000000..4e7afb12c4 --- /dev/null +++ b/test/components/views/voip/VideoLobby-test.tsx @@ -0,0 +1,167 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import { mocked } from "jest-mock"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + +import { + stubClient, + stubVideoChannelStore, + mkRoom, + mkVideoChannelMember, + mockStateEventImplementation, +} from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import FacePile from "../../../../src/components/views/elements/FacePile"; +import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar"; +import VideoLobby from "../../../../src/components/views/voip/VideoLobby"; + +describe("VideoLobby", () => { + stubClient(); + Object.defineProperty(navigator, "mediaDevices", { + value: { + enumerateDevices: jest.fn(), + getUserMedia: () => null, + }, + }); + jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); + + const cli = MatrixClientPeg.get(); + const room = mkRoom(cli, "!1:example.org"); + + let store; + beforeEach(() => { + store = stubVideoChannelStore(); + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("connected members", () => { + it("hides when no one is connected", async () => { + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + expect(lobby.find(".mx_VideoLobby_connectedMembers").exists()).toEqual(false); + }); + + it("is shown when someone is connected", async () => { + mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ + // A user connected from 2 devices + mkVideoChannelMember("@alice:example.org", ["device 1", "device 2"]), + // A disconnected user + mkVideoChannelMember("@bob:example.org", []), + // A user that claims to have a connected device, but has left the room + mkVideoChannelMember("@chris:example.org", ["device 1"]), + ])); + + mocked(room.currentState).getMember.mockImplementation(userId => ({ + userId, + membership: userId === "@chris:example.org" ? "leave" : "join", + name: userId, + rawDisplayName: userId, + roomId: "!1:example.org", + getAvatarUrl: () => {}, + getMxcAvatarUrl: () => {}, + }) as unknown as RoomMember); + + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + // Only Alice should display as connected + const memberText = lobby.find(".mx_VideoLobby_connectedMembers").children().at(0).text(); + expect(memberText).toEqual("1 person connected"); + expect(lobby.find(FacePile).find(MemberAvatar).props().member.userId).toEqual("@alice:example.org"); + }); + }); + + describe("device buttons", () => { + it("hides when no devices are available", async () => { + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + expect(lobby.find("DeviceButton").children().exists()).toEqual(false); + }); + + it("hides device list when only one device is available", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{ + deviceId: "1", + groupId: "1", + label: "Webcam", + kind: "videoinput", + toJSON: () => {}, + }]); + + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + expect(lobby.find(".mx_VideoLobby_deviceListButton").exists()).toEqual(false); + }); + + it("shows device list when multiple devices are available", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([ + { + deviceId: "1", + groupId: "1", + label: "Front camera", + kind: "videoinput", + toJSON: () => {}, + }, + { + deviceId: "2", + groupId: "1", + label: "Back camera", + kind: "videoinput", + toJSON: () => {}, + }, + ]); + + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + expect(lobby.find(".mx_VideoLobby_deviceListButton").exists()).toEqual(true); + }); + }); + + describe("join button", () => { + it("works", async () => { + const lobby = mount(); + // Wait for state to settle + await act(() => Promise.resolve()); + lobby.update(); + + act(() => { + lobby.find("AccessibleButton.mx_VideoLobby_joinButton").simulate("click"); + }); + expect(store.connect).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/stores/VideoChannelStore-test.ts b/test/stores/VideoChannelStore-test.ts index 7b23ce0f4b..fc8752ec76 100644 --- a/test/stores/VideoChannelStore-test.ts +++ b/test/stores/VideoChannelStore-test.ts @@ -14,24 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; +import { mocked } from "jest-mock"; +import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api"; -import { stubClient, mkRoom } from "../test-utils"; +import { stubClient, setupAsyncStoreWithClient } from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; -import WidgetStore from "../../src/stores/WidgetStore"; -import ActiveWidgetStore from "../../src/stores/ActiveWidgetStore"; +import WidgetStore, { IApp } from "../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore"; import { VIDEO_CHANNEL } from "../../src/utils/VideoChannelUtils"; describe("VideoChannelStore", () => { - stubClient(); - mkRoom(MatrixClientPeg.get(), "!1:example.org"); + const store = VideoChannelStore.instance; - const videoStore = VideoChannelStore.instance; - const widgetStore = ActiveWidgetStore.instance; - - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + const widget = { id: VIDEO_CHANNEL } as unknown as Widget; + const app = { id: VIDEO_CHANNEL, eventId: "$1:example.org", roomId: "!1:example.org", @@ -40,43 +38,103 @@ describe("VideoChannelStore", () => { name: "Video channel", creatorUserId: "@alice:example.org", avatar_url: null, - }]); - jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ - on: () => {}, - off: () => {}, - once: () => {}, - transport: { - send: () => {}, - reply: () => {}, - }, - } as unknown as ClientWidgetApi); + } as IApp; + // Set up mocks to simulate the remote end of the widget API + let messageSent: Promise; + let messageSendMock: () => void; + let onMock: (action: string, listener: (ev: CustomEvent) => void) => void; + let onceMock: (action: string, listener: (ev: CustomEvent) => void) => void; + let messaging: ClientWidgetApi; beforeEach(() => { - videoStore.start(); + stubClient(); + const cli = MatrixClientPeg.get(); + setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli); + setupAsyncStoreWithClient(store, cli); + + let resolveMessageSent: () => void; + messageSent = new Promise(resolve => resolveMessageSent = resolve); + messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); + onMock = jest.fn(); + onceMock = jest.fn(); + + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]); + messaging = { + on: onMock, + off: () => {}, + stop: () => {}, + once: onceMock, + transport: { + send: messageSendMock, + reply: () => {}, + }, + } as unknown as ClientWidgetApi; }); - afterEach(() => { - videoStore.stop(); - jest.clearAllMocks(); - }); - - it("tracks connection state", async () => { - expect(videoStore.roomId).toBeFalsy(); + const widgetReady = () => { + // Tell the WidgetStore that the widget is ready + const [, ready] = mocked(onceMock).mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.WidgetReady}`, + ); + ready({ detail: {} } as unknown as CustomEvent); + }; + const confirmConnect = async () => { + // Wait for the store to contact the widget API + await messageSent; + // Then, locate the callback that will confirm the join + const [, join] = mocked(onMock).mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.JoinCall}`, + ); + // Confirm the join, and wait for the store to update const waitForConnect = new Promise(resolve => - videoStore.once(VideoChannelEvent.Connect, resolve), + store.once(VideoChannelEvent.Connect, resolve), ); - widgetStore.setWidgetPersistence(VIDEO_CHANNEL, "!1:example.org", true); + join({ detail: {} } as unknown as CustomEvent); await waitForConnect; + }; - expect(videoStore.roomId).toEqual("!1:example.org"); - - const waitForDisconnect = new Promise(resolve => - videoStore.once(VideoChannelEvent.Disconnect, resolve), + const confirmDisconnect = async () => { + // Locate the callback that will perform the hangup + const [, hangup] = mocked(onceMock).mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.HangupCall}`, ); - widgetStore.setWidgetPersistence(VIDEO_CHANNEL, "!1:example.org", false); - await waitForDisconnect; + // Hangup and wait for the store, once again + const waitForHangup = new Promise(resolve => + store.once(VideoChannelEvent.Disconnect, resolve), + ); + hangup({ detail: {} } as unknown as CustomEvent); + await waitForHangup; + }; - expect(videoStore.roomId).toBeFalsy(); + it("connects and disconnects", async () => { + WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging); + widgetReady(); + expect(store.roomId).toBeFalsy(); + expect(store.connected).toEqual(false); + + store.connect("!1:example.org", null, null); + await confirmConnect(); + expect(store.roomId).toEqual("!1:example.org"); + expect(store.connected).toEqual(true); + + store.disconnect(); + await confirmDisconnect(); + expect(store.roomId).toBeFalsy(); + expect(store.connected).toEqual(false); + WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org"); + }); + + it("waits for messaging when connecting", async () => { + store.connect("!1:example.org", null, null); + WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging); + widgetReady(); + await confirmConnect(); + expect(store.roomId).toEqual("!1:example.org"); + expect(store.connected).toEqual(true); + + store.disconnect(); + await confirmDisconnect(); + WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org"); }); }); diff --git a/test/test-utils/video.ts b/test/test-utils/video.ts index 9130945215..79c657a0c6 100644 --- a/test/test-utils/video.ts +++ b/test/test-utils/video.ts @@ -15,21 +15,34 @@ limitations under the License. */ import { EventEmitter } from "events"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore"; +import { mkEvent } from "./test-utils"; +import { VIDEO_CHANNEL_MEMBER } from "../../src/utils/VideoChannelUtils"; +import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../src/stores/VideoChannelStore"; class StubVideoChannelStore extends EventEmitter { private _roomId: string; public get roomId(): string { return this._roomId; } + private _connected: boolean; + public get connected(): boolean { return this._connected; } + public get participants(): IJitsiParticipant[] { return []; } - public connect = (roomId: string) => { + public startConnect = (roomId: string) => { this._roomId = roomId; - this.emit(VideoChannelEvent.Connect); + this.emit(VideoChannelEvent.StartConnect, roomId); }; - public disconnect = () => { + public connect = jest.fn((roomId: string) => { + this._roomId = roomId; + this._connected = true; + this.emit(VideoChannelEvent.Connect, roomId); + }); + public disconnect = jest.fn(() => { + const roomId = this._roomId; this._roomId = null; - this.emit(VideoChannelEvent.Disconnect); - }; + this._connected = false; + this.emit(VideoChannelEvent.Disconnect, roomId); + }); } export const stubVideoChannelStore = (): StubVideoChannelStore => { @@ -37,3 +50,12 @@ export const stubVideoChannelStore = (): StubVideoChannelStore => { jest.spyOn(VideoChannelStore, "instance", "get").mockReturnValue(store as unknown as VideoChannelStore); return store; }; + +export const mkVideoChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({ + event: true, + type: VIDEO_CHANNEL_MEMBER, + room: "!1:example.org", + user: userId, + skey: userId, + content: { devices }, +});