From 5e215b5e9daeb473988b0bffb50cd50aca2cf303 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 11 Apr 2022 10:44:44 -0400 Subject: [PATCH 01/27] Show a lobby screen in video rooms --- res/css/_components.scss | 2 + res/css/structures/_RoomView.scss | 18 +- res/css/structures/_VideoRoomView.scss | 37 ++++ res/css/views/voip/_VideoLobby.scss | 161 +++++++++++++++ src/Lifecycle.ts | 3 - src/components/structures/RoomView.tsx | 18 +- src/components/structures/VideoRoomView.tsx | 67 +++++++ src/components/views/voip/VideoLobby.tsx | 208 ++++++++++++++++++++ src/i18n/strings/en_EN.json | 7 + src/stores/VideoChannelStore.ts | 125 +++++++----- test/stores/VideoChannelStore-test.ts | 100 +++++----- 11 files changed, 618 insertions(+), 128 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/voip/VideoLobby.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 79efb3e89bc..5a9b58a1a0a 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -45,6 +45,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"; @@ -316,3 +317,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 84e6041ecd5..c73068896db 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 00000000000..9b17475bf66 --- /dev/null +++ b/res/css/structures/_VideoRoomView.scss @@ -0,0 +1,37 @@ +/* +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; + padding: $container-gap-width; + padding-right: calc($container-gap-width / 2); + + .mx_AppTile { + width: auto; + height: 100%; + padding-top: 33px; // to match the right panel chat heading + border-radius: 8px; + } + + // 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/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss new file mode 100644 index 00000000000..460e43bfe1c --- /dev/null +++ b/res/css/views/voip/_VideoLobby.scss @@ -0,0 +1,161 @@ +/* +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 { + height: 100%; + padding: 12px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 44px; + + .mx_VideoLobby_preview { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 1.5; + background-color: $system; + + border-radius: 20px; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .mx_BaseAvatar { + margin: 20px; + + // Responsive sizing + 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; + height: 66px; + + background-color: rgba($background, 0.9); + + display: flex; + justify-content: center; + gap: 24px; + + .mx_VideoLobby_deviceButtonWrapper { + position: relative; + margin: 6px 0 10px; + + .mx_VideoLobby_deviceButton { + $size: 50px; + + width: $size; + height: $size; + + background-color: $primary-content; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-repeat: no-repeat; + mask-size: 20px; + mask-position: center; + background-color: $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: $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: $system; + height: 100%; + width: 100%; + } + } + + &.mx_VideoLobby_deviceButtonWrapper_active { + .mx_VideoLobby_deviceButton, .mx_VideoLobby_deviceListButton { + background-color: $system; + + &::before { + background-color: $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/src/Lifecycle.ts b/src/Lifecycle.ts index 90ead50fa6a..645963a8bd2 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 @@ -921,7 +919,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 46183cdadb2..58c021d2bba 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -74,8 +74,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"; @@ -2154,18 +2153,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); diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx new file mode 100644 index 00000000000..5ef988beb36 --- /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.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/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx new file mode 100644 index 00000000000..ac9821f616d --- /dev/null +++ b/src/components/views/voip/VideoLobby.tsx @@ -0,0 +1,208 @@ +/* +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 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 MemberAvatar from "../avatars/MemberAvatar"; + +interface IDeviceButtonProps { + kind: string; + devices: MediaDeviceInfo[]; + setDevice: (device: MediaDeviceInfo) => void; + deviceListLabel: string; + active: boolean; + toggle: () => void; + activeTitle: string; + inactiveTitle: string; +} + +const DeviceButton: FC = ({ + kind, devices, setDevice, deviceListLabel, active, 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 VideoLobby: FC<{ room: Room }> = ({ room }) => { + const [connecting, setConnecting] = useState(false); + const me = useMemo(() => room.getMember(room.myUserId), [room]); + 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); + + useEffect(() => { + if (videoDevice && videoActive) { + const videoElement = videoRef.current; + let stream: MediaStream; + + (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: videoDevice.deviceId }, + }); + + videoElement.srcObject = stream; + videoElement.play(); + } catch (e) { + logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`); + } + })(); + + return () => { + stream?.getTracks().forEach(track => track.stop()); + videoElement.srcObject = null; + }; + } + }, [videoDevice, videoActive]); + + 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); + } + }; + + return
+
+ +
+ + { _t("Join video room now") } + +
; +}; + +export default VideoLobby; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 86c35a5cdd1..7a070b17df3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1009,6 +1009,13 @@ "Your camera is turned off": "Your camera is turned off", "Your camera is still enabled": "Your camera is still enabled", "Dial": "Dial", + "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 video room now": "Join video room now", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 6bd1b621e4d..552724c6f89 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -15,19 +15,17 @@ limitations under the License. */ import { EventEmitter } from "events"; -import { logger } from "matrix-js-sdk/src/logger"; import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; -import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore"; import { - VIDEO_CHANNEL, VIDEO_CHANNEL_MEMBER, IVideoChannelMemberContent, getVideoChannel, } from "../utils/VideoChannelUtils"; +import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; export enum VideoChannelEvent { @@ -48,6 +46,7 @@ export interface IJitsiParticipant { */ export default class VideoChannelStore extends EventEmitter { private static _instance: VideoChannelStore; + private static readonly TIMEOUT = 8000; public static get instance(): VideoChannelStore { if (!VideoChannelStore._instance) { @@ -65,56 +64,77 @@ export default class VideoChannelStore extends EventEmitter { return this._roomId; } + private set roomId(value: string) { + this._roomId = value; + } + public get participants(): IJitsiParticipant[] { return this._participants; } - public start = () => { - ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); - }; + private set participants(value: IJitsiParticipant[]) { + this._participants = value; + } - public stop = () => { - ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); - }; + public connect = async (roomId: string, audioDevice: MediaDeviceInfo, videoDevice: MediaDeviceInfo) => { + if (this.activeChannel) await this.disconnect(); - private setConnected = async (roomId: string) => { const jitsi = getVideoChannel(roomId); if (!jitsi) throw new Error(`No video channel in room ${roomId}`); + // TODO: Wait for messaging const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi)); if (!messaging) throw new Error(`Failed to bind video channel in room ${roomId}`); 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); + + // Actually perform the join + const waitForJoin = this.waitForAction(ElementWidgetActions.JoinCall); + 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); + + throw e; + } - this.activeChannel.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.activeChannel.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.emit(VideoChannelEvent.Connect); // 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.cli.getDeviceId()))); }; - private setDisconnected = async () => { - this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + public disconnect = async () => { + if (!this.activeChannel) throw new Error("Not connected to any video channel"); - this.activeChannel = null; - this._participants = null; + const waitForHangup = this.waitForAction(ElementWidgetActions.HangupCall); + this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {}); + await waitForHangup; - 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); + // onHangup cleans up for us + }; + + private waitForAction = async (action: ElementWidgetActions) => { + const wait = new Promise(resolve => + this.activeChannel.once(`action:${action}`, (ev: CustomEvent) => { + this.ack(ev); + resolve(); + }), + ); + if (await timeout(wait, false, VideoChannelStore.TIMEOUT) === false) { + throw new Error("Communication with video channel timed out"); } }; @@ -124,41 +144,40 @@ 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); + private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => { + const room = this.cli.getRoom(roomId); const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.cli.getUserId()); const devices = devicesState?.getContent()?.devices ?? []; await this.cli.sendStateEvent( - this.roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(), + roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.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.participants = null; + + this.emit(VideoChannelEvent.Disconnect); + + // 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.cli.getDeviceId()); + return Array.from(devicesSet); + }); }; private onParticipants = (ev: CustomEvent) => { - this._participants = ev.detail.data.participants as IJitsiParticipant[]; + this.participants = ev.detail.data.participants as IJitsiParticipant[]; this.emit(VideoChannelEvent.Participants, 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/test/stores/VideoChannelStore-test.ts b/test/stores/VideoChannelStore-test.ts index 7b23ce0f4bc..e5c77af01cd 100644 --- a/test/stores/VideoChannelStore-test.ts +++ b/test/stores/VideoChannelStore-test.ts @@ -16,67 +16,79 @@ limitations under the License. import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; -import { stubClient, mkRoom } from "../test-utils"; -import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import { stubClient } from "../test-utils"; import WidgetStore from "../../src/stores/WidgetStore"; -import ActiveWidgetStore from "../../src/stores/ActiveWidgetStore"; 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 videoStore = VideoChannelStore.instance; - const widgetStore = ActiveWidgetStore.instance; - - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ - id: VIDEO_CHANNEL, - eventId: "$1:example.org", - roomId: "!1:example.org", - type: MatrixWidgetType.JitsiMeet, - url: "", - 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); - + // Set up mocks to simulate the remote end of the widget API + let messageSent; + let messageSendMock; + let onceMock; beforeEach(() => { - videoStore.start(); - }); + stubClient(); + let resolveMessageSent; + messageSent = new Promise(resolve => resolveMessageSent = resolve); + messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); + onceMock = jest.fn(); - afterEach(() => { - videoStore.stop(); - jest.clearAllMocks(); + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + id: VIDEO_CHANNEL, + eventId: "$1:example.org", + roomId: "!1:example.org", + type: MatrixWidgetType.JitsiMeet, + url: "", + name: "Video channel", + creatorUserId: "@alice:example.org", + avatar_url: null, + }]); + jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ + on: () => {}, + off: () => {}, + once: onceMock, + transport: { + send: messageSendMock, + reply: () => {}, + }, + } as unknown as ClientWidgetApi); }); - it("tracks connection state", async () => { - expect(videoStore.roomId).toBeFalsy(); + it("connects and disconnects", async () => { + const store = VideoChannelStore.instance; + expect(store.roomId).toBeFalsy(); + + store.connect("!1:example.org", null, null); + // Wait for the store to contact the widget API + await messageSent; + // Then, locate the callback that will confirm the join + const [, join] = onceMock.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: {} }); await waitForConnect; - expect(videoStore.roomId).toEqual("!1:example.org"); + expect(store.roomId).toEqual("!1:example.org"); - const waitForDisconnect = new Promise(resolve => - videoStore.once(VideoChannelEvent.Disconnect, resolve), + store.disconnect(); + // Locate the callback that will perform the hangup + const [, hangup] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.HangupCall}`, + ); + // Hangup and wait for the store, once again + const waitForHangup = new Promise(resolve => + store.once(VideoChannelEvent.Disconnect, resolve), ); - widgetStore.setWidgetPersistence(VIDEO_CHANNEL, "!1:example.org", false); - await waitForDisconnect; + hangup({ detail: {} }); + await waitForHangup; - expect(videoStore.roomId).toBeFalsy(); + expect(store.roomId).toBeFalsy(); }); }); From 5c431c7823b5d941c1beab544bd3af18b3cb6fd3 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 11 Apr 2022 14:52:17 -0400 Subject: [PATCH 02/27] Add connecting state --- src/components/structures/VideoRoomView.tsx | 2 +- src/components/views/rooms/RoomTile.tsx | 57 ++++++++++++++++--- src/i18n/strings/en_EN.json | 1 + src/stores/VideoChannelStore.ts | 39 ++++++------- test/components/views/rooms/RoomTile-test.tsx | 4 ++ test/test-utils/video.ts | 16 +++++- 6 files changed, 87 insertions(+), 32 deletions(-) diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx index 5ef988beb36..2695dafa798 100644 --- a/src/components/structures/VideoRoomView.tsx +++ b/src/components/structures/VideoRoomView.tsx @@ -43,7 +43,7 @@ const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing } return app; }, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps - const [connected, setConnected] = useState(store.roomId === room.roomId); + 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)); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 29476a55cf9..530b22571aa 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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7a070b17df3..ecb4cf6da0d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1862,6 +1862,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", diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 552724c6f89..cdf36d820b4 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -29,6 +29,7 @@ import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; export enum VideoChannelEvent { + StartConnect = "start_connect", Connect = "connect", Disconnect = "disconnect", Participants = "participants", @@ -57,24 +58,18 @@ export default class VideoChannelStore extends EventEmitter { private readonly cli = MatrixClientPeg.get(); 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; } - private set roomId(value: string) { - this._roomId = value; - } - - public get participants(): IJitsiParticipant[] { - return this._participants; - } - - private set participants(value: IJitsiParticipant[]) { - this._participants = value; - } + private _participants: IJitsiParticipant[] = []; + public get participants(): IJitsiParticipant[] { return this._participants; } + private set participants(value: IJitsiParticipant[]) { this._participants = value; } public connect = async (roomId: string, audioDevice: MediaDeviceInfo, videoDevice: MediaDeviceInfo) => { if (this.activeChannel) await this.disconnect(); @@ -91,6 +86,8 @@ export default class VideoChannelStore extends EventEmitter { // Participant data will come down the event pipeline quickly, so prepare in advance messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + this.emit(VideoChannelEvent.StartConnect, roomId); + // Actually perform the join const waitForJoin = this.waitForAction(ElementWidgetActions.JoinCall); messaging.transport.send(ElementWidgetActions.JoinCall, { @@ -105,12 +102,15 @@ export default class VideoChannelStore extends EventEmitter { this.roomId = null; messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + this.emit(VideoChannelEvent.Disconnect, roomId); + throw e; } + this.connected = true; messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.emit(VideoChannelEvent.Connect); + this.emit(VideoChannelEvent.Connect, roomId); // Tell others that we're connected, by adding our device to room state this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.cli.getDeviceId()))); @@ -163,9 +163,10 @@ export default class VideoChannelStore extends EventEmitter { const roomId = this.roomId; this.activeChannel = null; this.roomId = null; - this.participants = null; + this.connected = false; + this.participants = []; - this.emit(VideoChannelEvent.Disconnect); + this.emit(VideoChannelEvent.Disconnect, roomId); // Tell others that we're disconnected, by removing our device from room state await this.updateDevices(roomId, devices => { @@ -177,7 +178,7 @@ export default class VideoChannelStore extends EventEmitter { private onParticipants = (ev: CustomEvent) => { this.participants = ev.detail.data.participants as IJitsiParticipant[]; - this.emit(VideoChannelEvent.Participants, ev.detail.data.participants); + this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants); this.ack(ev); }; } diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 4ac7a369b6d..669d7d85cd8 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -85,6 +85,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/test-utils/video.ts b/test/test-utils/video.ts index 91309452158..1d8777b3d04 100644 --- a/test/test-utils/video.ts +++ b/test/test-utils/video.ts @@ -16,19 +16,29 @@ limitations under the License. import { EventEmitter } from "events"; -import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore"; +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 startConnect = (roomId: string) => { + this._roomId = roomId; + this.emit(VideoChannelEvent.StartConnect, roomId); + }; public connect = (roomId: string) => { this._roomId = roomId; - this.emit(VideoChannelEvent.Connect); + this._connected = true; + this.emit(VideoChannelEvent.Connect, roomId); }; public disconnect = () => { + const roomId = this._roomId; this._roomId = null; - this.emit(VideoChannelEvent.Disconnect); + this._connected = false; + this.emit(VideoChannelEvent.Disconnect, roomId); }; } From fb36fe09c55d20ae89770fc3fccfa2a6c7a0c005 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 11 Apr 2022 18:28:06 -0400 Subject: [PATCH 03/27] Test VideoRoomView --- .../structures/VideoRoomView-test.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/components/structures/VideoRoomView-test.tsx diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx new file mode 100644 index 00000000000..11d747103d9 --- /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); + }); +}); From 9a386cf3483f0c62ffd0fcae0955b5292820e9c4 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 11 Apr 2022 18:28:16 -0400 Subject: [PATCH 04/27] Test VideoLobby --- .../components/views/voip/VideoLobby-test.tsx | 116 ++++++++++++++++++ test/test-utils/video.ts | 8 +- 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 test/components/views/voip/VideoLobby-test.tsx diff --git a/test/components/views/voip/VideoLobby-test.tsx b/test/components/views/voip/VideoLobby-test.tsx new file mode 100644 index 00000000000..273a72ef241 --- /dev/null +++ b/test/components/views/voip/VideoLobby-test.tsx @@ -0,0 +1,116 @@ +/* +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 { stubClient, stubVideoChannelStore, mkRoom } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +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("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/test-utils/video.ts b/test/test-utils/video.ts index 1d8777b3d04..6e0a97825c1 100644 --- a/test/test-utils/video.ts +++ b/test/test-utils/video.ts @@ -29,17 +29,17 @@ class StubVideoChannelStore extends EventEmitter { this._roomId = roomId; this.emit(VideoChannelEvent.StartConnect, roomId); }; - public connect = (roomId: string) => { + public connect = jest.fn((roomId: string) => { this._roomId = roomId; this._connected = true; this.emit(VideoChannelEvent.Connect, roomId); - }; - public disconnect = () => { + }); + public disconnect = jest.fn(() => { const roomId = this._roomId; this._roomId = null; this._connected = false; this.emit(VideoChannelEvent.Disconnect, roomId); - }; + }); } export const stubVideoChannelStore = (): StubVideoChannelStore => { From c0ae630670dfaeddcd72aaea3015440cd38ec4eb Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 12:36:59 -0400 Subject: [PATCH 05/27] Get the local video stream with useAsyncMemo --- src/components/views/voip/VideoLobby.tsx | 35 ++++++++++++------------ 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx index ac9821f616d..2dd9610f0cd 100644 --- a/src/components/views/voip/VideoLobby.tsx +++ b/src/components/views/voip/VideoLobby.tsx @@ -124,30 +124,31 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => { const [audioActive, toggleAudio] = useStateToggle(true); const [videoActive, toggleVideo] = useStateToggle(true); - useEffect(() => { + const videoStream = useAsyncMemo(async () => { if (videoDevice && videoActive) { - const videoElement = videoRef.current; - let stream: MediaStream; - - (async () => { - try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: videoDevice.deviceId }, - }); + 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]); - videoElement.srcObject = stream; - videoElement.play(); - } catch (e) { - logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`); - } - })(); + useEffect(() => { + if (videoStream) { + const videoElement = videoRef.current; + videoElement.srcObject = videoStream; + videoElement.play(); return () => { - stream?.getTracks().forEach(track => track.stop()); + videoStream?.getTracks().forEach(track => track.stop()); videoElement.srcObject = null; }; } - }, [videoDevice, videoActive]); + }, [videoStream]); const connect = async () => { setConnecting(true); From 861cce5328567a6bdfc4a66b037a206a24abc560 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 12:37:57 -0400 Subject: [PATCH 06/27] Clean up code review nits --- src/components/views/voip/VideoLobby.tsx | 3 ++- src/stores/VideoChannelStore.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx index 2dd9610f0cd..77caf4032c5 100644 --- a/src/components/views/voip/VideoLobby.tsx +++ b/src/components/views/voip/VideoLobby.tsx @@ -24,7 +24,8 @@ import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import { useStateToggle } from "../../../hooks/useStateToggle"; import VideoChannelStore from "../../../stores/VideoChannelStore"; import IconizedContextMenu, { - IconizedContextMenuOption, IconizedContextMenuOptionList, + IconizedContextMenuOption, + IconizedContextMenuOptionList, } from "../context_menus/IconizedContextMenu"; import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { Alignment } from "../elements/Tooltip"; diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index cdf36d820b4..27304c64021 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -47,7 +47,7 @@ export interface IJitsiParticipant { */ export default class VideoChannelStore extends EventEmitter { private static _instance: VideoChannelStore; - private static readonly TIMEOUT = 8000; + private static readonly TIMEOUT_MS = 8000; public static get instance(): VideoChannelStore { if (!VideoChannelStore._instance) { @@ -133,7 +133,7 @@ export default class VideoChannelStore extends EventEmitter { resolve(); }), ); - if (await timeout(wait, false, VideoChannelStore.TIMEOUT) === false) { + if (await timeout(wait, false, VideoChannelStore.TIMEOUT_MS) === false) { throw new Error("Communication with video channel timed out"); } }; From 256ca1d506df7d3b5eea45b54477f9fc828d0cdf Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 12:40:43 -0400 Subject: [PATCH 07/27] Explicitly state what !important is overriding --- res/css/views/voip/_VideoLobby.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index 460e43bfe1c..08f96ab5fae 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -42,7 +42,7 @@ limitations under the License. .mx_BaseAvatar { margin: 20px; - // Responsive sizing + // Override the explicit dimensions on the element so that this gets sized responsively width: unset !important; height: unset !important; min-width: 0; From b3522b56f71aeb02ca6d628f9964340572834863 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 12:46:03 -0400 Subject: [PATCH 08/27] Use spacing variables --- res/css/views/voip/_VideoLobby.scss | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index 08f96ab5fae..c38bce63dc4 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -16,13 +16,13 @@ limitations under the License. .mx_VideoLobby { height: 100%; - padding: 12px; + padding: $spacing-12; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 44px; + gap: $spacing-40; .mx_VideoLobby_preview { position: relative; @@ -40,7 +40,7 @@ limitations under the License. align-items: center; .mx_BaseAvatar { - margin: 20px; + margin: $spacing-20; // Override the explicit dimensions on the element so that this gets sized responsively width: unset !important; @@ -66,13 +66,12 @@ limitations under the License. bottom: 0; left: 0; right: 0; - height: 66px; background-color: rgba($background, 0.9); display: flex; justify-content: center; - gap: 24px; + gap: $spacing-24; .mx_VideoLobby_deviceButtonWrapper { position: relative; From 76822b82a958e861b06d389682e706ccc8027dda Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 13:14:42 -0400 Subject: [PATCH 09/27] Wait for video channel messaging --- src/stores/VideoChannelStore.ts | 53 ++++++++---- src/stores/widgets/WidgetMessagingStore.ts | 5 +- test/stores/VideoChannelStore-test.ts | 98 +++++++++++++++------- 3 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 27304c64021..100fad13bfb 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -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 { @@ -27,6 +27,8 @@ import { } from "../utils/VideoChannelUtils"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; +import { UPDATE_EVENT } from "./AsyncStore"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; export enum VideoChannelEvent { StartConnect = "start_connect", @@ -45,7 +47,7 @@ export interface IJitsiParticipant { /* * Holds information about the currently active video channel. */ -export default class VideoChannelStore extends EventEmitter { +export default class VideoChannelStore extends AsyncStoreWithClient { private static _instance: VideoChannelStore; private static readonly TIMEOUT_MS = 8000; @@ -56,7 +58,14 @@ export default class VideoChannelStore extends EventEmitter { return VideoChannelStore._instance; } - private readonly cli = MatrixClientPeg.get(); + constructor() { + super(defaultDispatcher); + } + + protected async onAction(payload: ActionPayload): Promise { + // nothing to do + } + private activeChannel: ClientWidgetApi; private _roomId: string; @@ -76,10 +85,26 @@ export default class VideoChannelStore extends EventEmitter { const jitsi = getVideoChannel(roomId); if (!jitsi) throw new Error(`No video channel in room ${roomId}`); - - // TODO: Wait for messaging - 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); + + let messaging = WidgetMessagingStore.instance.getMessagingForUid(jitsiUid); + if (!messaging) { + // The widget might still be initializing, so wait for it + let messagingListener; + const getMessaging = new Promise(resolve => { + messagingListener = (uid: string, widgetApi: ClientWidgetApi) => { + if (uid === jitsiUid) { + messaging = widgetApi; + resolve(); + } + }; + WidgetMessagingStore.instance.on(UPDATE_EVENT, messagingListener); + }); + + const timedOut = await timeout(getMessaging, false, VideoChannelStore.TIMEOUT_MS) === false; + WidgetMessagingStore.instance.off(UPDATE_EVENT, messagingListener); + if (timedOut) throw new Error(`Failed to bind video channel in room ${roomId}`); + } this.activeChannel = messaging; this.roomId = roomId; @@ -113,7 +138,7 @@ export default class VideoChannelStore extends EventEmitter { this.emit(VideoChannelEvent.Connect, roomId); // Tell others that we're connected, by adding our device to room state - this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.cli.getDeviceId()))); + this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.matrixClient.getDeviceId()))); }; public disconnect = async () => { @@ -145,12 +170,12 @@ export default class VideoChannelStore extends EventEmitter { }; private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => { - const room = this.cli.getRoom(roomId); - const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.cli.getUserId()); + 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( - roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(), + await this.matrixClient.sendStateEvent( + roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.matrixClient.getUserId(), ); }; @@ -171,7 +196,7 @@ export default class VideoChannelStore extends EventEmitter { // 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.cli.getDeviceId()); + devicesSet.delete(this.matrixClient.getDeviceId()); return Array.from(devicesSet); }); }; diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 1766db27594..54aa0800dcf 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -21,6 +21,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { EnhancedMap } from "../../utils/maps"; import WidgetUtils from "../../utils/WidgetUtils"; +import { UPDATE_EVENT } from "../AsyncStore"; /** * Temporary holding store for widget messaging instances. This is eventually @@ -51,7 +52,9 @@ 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); + this.emit(UPDATE_EVENT, uid, widgetApi); } public stopMessaging(widget: Widget, roomId: string) { diff --git a/test/stores/VideoChannelStore-test.ts b/test/stores/VideoChannelStore-test.ts index e5c77af01cd..fb9beb644d5 100644 --- a/test/stores/VideoChannelStore-test.ts +++ b/test/stores/VideoChannelStore-test.ts @@ -14,81 +14,115 @@ 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 } from "../test-utils"; -import WidgetStore from "../../src/stores/WidgetStore"; +import { stubClient, setupAsyncStoreWithClient } from "../test-utils"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +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", () => { + const store = VideoChannelStore.instance; + + const widget = { id: VIDEO_CHANNEL } as unknown as Widget; + const app = { + id: VIDEO_CHANNEL, + eventId: "$1:example.org", + roomId: "!1:example.org", + type: MatrixWidgetType.JitsiMeet, + url: "", + name: "Video channel", + creatorUserId: "@alice:example.org", + avatar_url: null, + } as IApp; + // Set up mocks to simulate the remote end of the widget API - let messageSent; - let messageSendMock; - let onceMock; + let messageSent: Promise; + let messageSendMock: () => void; + let onceMock: (action: string, listener: (ev: CustomEvent) => void) => void; + let messaging: ClientWidgetApi; beforeEach(() => { stubClient(); - let resolveMessageSent; + 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()); onceMock = jest.fn(); - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ - id: VIDEO_CHANNEL, - eventId: "$1:example.org", - roomId: "!1:example.org", - type: MatrixWidgetType.JitsiMeet, - url: "", - name: "Video channel", - creatorUserId: "@alice:example.org", - avatar_url: null, - }]); - jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]); + messaging = { on: () => {}, off: () => {}, + stop: () => {}, once: onceMock, transport: { send: messageSendMock, reply: () => {}, }, - } as unknown as ClientWidgetApi); + } as unknown as ClientWidgetApi; }); - it("connects and disconnects", async () => { - const store = VideoChannelStore.instance; - - expect(store.roomId).toBeFalsy(); - - store.connect("!1:example.org", null, null); + 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] = onceMock.mock.calls.find(([action]) => + const [, join] = mocked(onceMock).mock.calls.find(([action]) => action === `action:${ElementWidgetActions.JoinCall}`, ); // Confirm the join, and wait for the store to update const waitForConnect = new Promise(resolve => store.once(VideoChannelEvent.Connect, resolve), ); - join({ detail: {} }); + join({ detail: {} } as unknown as CustomEvent); await waitForConnect; + }; - expect(store.roomId).toEqual("!1:example.org"); - - store.disconnect(); + const confirmDisconnect = async () => { // Locate the callback that will perform the hangup - const [, hangup] = onceMock.mock.calls.find(([action]) => + const [, hangup] = mocked(onceMock).mock.calls.find(([action]) => action === `action:${ElementWidgetActions.HangupCall}`, ); // Hangup and wait for the store, once again const waitForHangup = new Promise(resolve => store.once(VideoChannelEvent.Disconnect, resolve), ); - hangup({ detail: {} }); + hangup({ detail: {} } as unknown as CustomEvent); await waitForHangup; + }; + + it("connects and disconnects", async () => { + WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging); + 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); + 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"); }); }); From 5a9cbec7f14eedc1d6c5486dfc68f1bc1857be7c Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 14:38:41 -0400 Subject: [PATCH 10/27] Update join button copy --- src/components/views/voip/VideoLobby.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx index 77caf4032c5..245b28b060a 100644 --- a/src/components/views/voip/VideoLobby.tsx +++ b/src/components/views/voip/VideoLobby.tsx @@ -202,7 +202,7 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => { disabled={connecting} onClick={connect} > - { _t("Join video room now") } + { _t("Connect now") } ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6d94c174e8b..db41aca2866 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1015,7 +1015,7 @@ "Video devices": "Video devices", "Turn off camera": "Turn off camera", "Turn on camera": "Turn on camera", - "Join video room now": "Join video room now", + "Connect now": "Connect now", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", From c1269d9056dbdd84128b2570f73c76a057864565 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 14:44:43 -0400 Subject: [PATCH 11/27] Show frame on both the lobby and widget --- res/css/structures/_VideoRoomView.scss | 12 ++++++++---- res/css/views/voip/_VideoLobby.scss | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss index 9b17475bf66..d99b3f5894b 100644 --- a/res/css/structures/_VideoRoomView.scss +++ b/res/css/structures/_VideoRoomView.scss @@ -20,14 +20,18 @@ limitations under the License. display: flex; flex-direction: column; - padding: $container-gap-width; - padding-right: calc($container-gap-width / 2); + 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%; - padding-top: 33px; // to match the right panel chat heading - border-radius: 8px; + border: none; } // While the lobby is shown, the widget needs to stay loaded but hidden in the background diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index c38bce63dc4..a868fa9ebd8 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -17,6 +17,8 @@ limitations under the License. .mx_VideoLobby { height: 100%; padding: $spacing-12; + background-color: $background; + border-radius: 8px; display: flex; flex-direction: column; From cf4f3ef3be3868127d27660c68359c32453df3c4 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 14:59:17 -0400 Subject: [PATCH 12/27] Force dark theme for video lobby --- res/css/views/voip/_VideoLobby.scss | 18 +++++++++--------- res/themes/dark/css/_dark.scss | 4 ++++ res/themes/legacy-dark/css/_legacy-dark.scss | 4 ++++ res/themes/legacy-light/css/_legacy-light.scss | 5 +++++ res/themes/light/css/_light.scss | 5 +++++ 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index a868fa9ebd8..5d9903d8241 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_VideoLobby { height: 100%; padding: $spacing-12; - background-color: $background; + background-color: $video-lobby-background; border-radius: 8px; display: flex; @@ -31,7 +31,7 @@ limitations under the License. width: 100%; max-width: 800px; aspect-ratio: 1.5; - background-color: $system; + background-color: $video-lobby-system; border-radius: 20px; overflow: hidden; @@ -69,7 +69,7 @@ limitations under the License. left: 0; right: 0; - background-color: rgba($background, 0.9); + background-color: rgba($video-lobby-background, 0.9); display: flex; justify-content: center; @@ -85,7 +85,7 @@ limitations under the License. width: $size; height: $size; - background-color: $primary-content; + background-color: $video-lobby-primary-content; border-radius: calc($size / 2); &::before { @@ -94,7 +94,7 @@ limitations under the License. mask-repeat: no-repeat; mask-size: 20px; mask-position: center; - background-color: $system; + background-color: $video-lobby-system; height: 100%; width: 100%; } @@ -117,7 +117,7 @@ limitations under the License. width: $size; height: $size; - background-color: $primary-content; + background-color: $video-lobby-primary-content; border-radius: calc($size / 2); &::before { @@ -126,7 +126,7 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); mask-size: $size; mask-position: center; - background-color: $system; + background-color: $video-lobby-system; height: 100%; width: 100%; } @@ -134,10 +134,10 @@ limitations under the License. &.mx_VideoLobby_deviceButtonWrapper_active { .mx_VideoLobby_deviceButton, .mx_VideoLobby_deviceListButton { - background-color: $system; + background-color: $video-lobby-system; &::before { - background-color: $primary-content; + background-color: $video-lobby-primary-content; } } diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 7921a4e1a80..41c85b6a1e5 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -185,6 +185,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 03ba8c70ea6..02e08d4ef64 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -116,6 +116,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 b039cfa70f6..47d018d46b4 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -175,6 +175,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 73e2ee71c04..8c756a40328 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -278,6 +278,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 From 4f714259d939e09f65623b3ececb61bded5e634b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 20:01:55 -0400 Subject: [PATCH 13/27] Wait for the widget to be ready --- src/stores/VideoChannelStore.ts | 26 +++++++++++++--- src/stores/widgets/ElementWidgetActions.ts | 1 + src/stores/widgets/WidgetMessagingStore.ts | 36 +++++++++++++++++----- test/stores/VideoChannelStore-test.ts | 10 ++++++ 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 100fad13bfb..e56c30214bc 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -19,7 +19,7 @@ import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; -import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; +import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore"; import { VIDEO_CHANNEL_MEMBER, IVideoChannelMemberContent, @@ -27,7 +27,6 @@ import { } from "../utils/VideoChannelUtils"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; -import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; export enum VideoChannelEvent { @@ -85,9 +84,11 @@ export default class VideoChannelStore extends AsyncStoreWithClient { 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 = WidgetMessagingStore.instance.getMessagingForUid(jitsiUid); + let messaging = messagingStore.getMessagingForUid(jitsiUid); if (!messaging) { // The widget might still be initializing, so wait for it let messagingListener; @@ -98,14 +99,29 @@ export default class VideoChannelStore extends AsyncStoreWithClient { resolve(); } }; - WidgetMessagingStore.instance.on(UPDATE_EVENT, messagingListener); + messagingStore.on(WidgetMessagingStoreEvent.StoreMessaging, messagingListener); }); const timedOut = await timeout(getMessaging, false, VideoChannelStore.TIMEOUT_MS) === false; - WidgetMessagingStore.instance.off(UPDATE_EVENT, messagingListener); + messagingStore.off(WidgetMessagingStoreEvent.StoreMessaging, messagingListener); if (timedOut) throw new Error(`Failed to bind video channel in room ${roomId}`); } + if (!messagingStore.isWidgetReady(jitsiUid)) { + // Wait for the widget to be ready to receive our join event + let readyListener; + const ready = new Promise(resolve => { + readyListener = (uid: string) => { + if (uid === jitsiUid) resolve(); + }; + messagingStore.on(WidgetMessagingStoreEvent.WidgetReady, readyListener); + }); + + const timedOut = await timeout(ready, false, VideoChannelStore.TIMEOUT_MS) === false; + messagingStore.off(WidgetMessagingStoreEvent.WidgetReady, readyListener); + if (timedOut) throw new Error(`Video channel in room ${roomId} never became ready`); + } + this.activeChannel = messaging; this.roomId = roomId; // Participant data will come down the event pipeline quickly, so prepare in advance diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index e58581ce92a..117c4b47f3a 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 54aa0800dcf..d954af6d609 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -14,14 +14,19 @@ * 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"; -import { UPDATE_EVENT } from "../AsyncStore"; + +export enum WidgetMessagingStoreEvent { + StoreMessaging = "store_messaging", + WidgetReady = "widget_ready", +} /** * Temporary holding store for widget messaging instances. This is eventually @@ -32,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); @@ -54,11 +60,20 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { this.stopMessaging(widget, roomId); const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); this.widgetMap.set(uid, widgetApi); - this.emit(UPDATE_EVENT, 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 { @@ -67,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(); @@ -75,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/test/stores/VideoChannelStore-test.ts b/test/stores/VideoChannelStore-test.ts index fb9beb644d5..7409bbd904c 100644 --- a/test/stores/VideoChannelStore-test.ts +++ b/test/stores/VideoChannelStore-test.ts @@ -69,6 +69,14 @@ describe("VideoChannelStore", () => { } as unknown as ClientWidgetApi; }); + 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; @@ -99,6 +107,7 @@ describe("VideoChannelStore", () => { it("connects and disconnects", async () => { WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging); + widgetReady(); expect(store.roomId).toBeFalsy(); expect(store.connected).toEqual(false); @@ -117,6 +126,7 @@ describe("VideoChannelStore", () => { 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); From 56ea403ecad0483e50d4634261c840e2a452ce8e Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 12 Apr 2022 20:07:23 -0400 Subject: [PATCH 14/27] Make VideoChannelStore constructor private --- src/stores/VideoChannelStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index e56c30214bc..6c7a7a9d884 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -57,7 +57,7 @@ export default class VideoChannelStore extends AsyncStoreWithClient { return VideoChannelStore._instance; } - constructor() { + private constructor() { super(defaultDispatcher); } From 8b3c114c191c3218ab6ef4611eb11c0914122ae2 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 13 Apr 2022 09:35:38 -0400 Subject: [PATCH 15/27] Allow video lobby to shrink --- res/css/views/voip/_VideoLobby.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index 5d9903d8241..ce4ec31ea4b 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -15,7 +15,8 @@ limitations under the License. */ .mx_VideoLobby { - height: 100%; + min-height: 0; + flex-grow: 1; padding: $spacing-12; background-color: $video-lobby-background; border-radius: 8px; From e0d0ac99e16612551edf9c7892a955674b54838b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 13 Apr 2022 09:49:01 -0400 Subject: [PATCH 16/27] Add invite button to video room header --- res/css/views/rooms/_RoomHeader.scss | 4 ++++ src/components/structures/RoomView.tsx | 9 +++++++-- src/components/views/rooms/RoomHeader.tsx | 11 +++++++++++ test/components/views/rooms/RoomHeader-test.tsx | 1 + 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 3eabf5e57a3..170fbb81e3e 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -210,6 +210,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/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 828090dbc8a..85835004ede 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1248,7 +1248,7 @@ export class RoomView extends React.Component { } }; - private onInviteButtonClick = () => { + private onInviteClick = () => { // open the room inviter dis.dispatch({ action: 'view_invite', @@ -1901,7 +1901,7 @@ export class RoomView extends React.Component { statusBar = ; @@ -2180,6 +2180,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) { @@ -2202,6 +2203,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 ( @@ -2217,6 +2221,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/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 1cdd2e770e8..9983b6f39c3 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/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index c8030ad7c9f..1037b0377ce 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={() => {}} From 24d612ffb7e2ebae819ed668ccbd5fb19dae9eeb Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 13 Apr 2022 11:24:21 -0400 Subject: [PATCH 17/27] Show connected members on lobby screen --- res/css/views/elements/_FacePile.scss | 3 +- res/css/views/voip/_VideoLobby.scss | 12 +++- src/components/structures/SpaceRoomView.tsx | 6 +- src/components/views/elements/FacePile.tsx | 68 +++++++++++++++---- src/components/views/voip/VideoLobby.tsx | 17 +++++ src/i18n/strings/en_EN.json | 4 +- src/utils/VideoChannelUtils.ts | 13 +++- test/components/views/rooms/RoomTile-test.tsx | 13 +--- .../components/views/voip/VideoLobby-test.tsx | 55 ++++++++++++++- test/test-utils/video.ts | 12 ++++ 10 files changed, 168 insertions(+), 35 deletions(-) diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 3e83446b0ee..90f1c590a14 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/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss index ce4ec31ea4b..f9921c489a7 100644 --- a/res/css/views/voip/_VideoLobby.scss +++ b/res/css/views/voip/_VideoLobby.scss @@ -18,6 +18,7 @@ limitations under the License. min-height: 0; flex-grow: 1; padding: $spacing-12; + color: $video-lobby-primary-content; background-color: $video-lobby-background; border-radius: 8px; @@ -25,7 +26,16 @@ limitations under the License. flex-direction: column; align-items: center; justify-content: center; - gap: $spacing-40; + 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; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index aaf3e4e1358..1c0eb2ac56e 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/FacePile"; 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/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index bb39cc79575..3c2591ae622 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -22,21 +22,62 @@ import { sortBy } from "lodash"; import MemberAvatar from "../avatars/MemberAvatar"; import { _t } from "../../../languageHandler"; import DMRoomMap from "../../../utils/DMRoomMap"; +import TooltipTarget from "../elements/TooltipTarget"; import TextWithTooltip from "../elements/TextWithTooltip"; import { useRoomMembers } from "../../../hooks/useRoomMembers"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +interface IProps extends HTMLAttributes { + members: RoomMember[]; + faceSize: number; + overflow: boolean; + tooltip?: ReactNode; + children?: ReactNode; +} + +const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { + const faces = members.map( + tooltip ? + m => : + m => + + , + ); + + const pileContents = <> + { overflow ? : null } + { faces } + ; + + return
+ { tooltip ? ( + + { pileContents } + + ) : ( +
+ { pileContents } +
+ ) } + { children } +
; +}; + +export default FacePile; + const DEFAULT_NUM_FACES = 5; -interface IProps extends HTMLAttributes { +const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; + +interface IRoomProps extends HTMLAttributes { room: Room; onlyKnownUsers?: boolean; numShown?: number; } -const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; - -const FacePile: FC = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }) => { +export const RoomFacePile: FC = ( + { room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }, +) => { const cli = useContext(MatrixClientContext); const isJoined = room.getMyMembership() === "join"; let members = useRoomMembers(room); @@ -57,7 +98,7 @@ const FacePile: FC = ({ room, onlyKnownUsers = true, numShown = DEFAULT_ // 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(", "); + const commaSeparatedMembers = shownMembers.map(m => m.name).reverse().join(", "); let tooltip: ReactNode; if (props.onClick) { @@ -90,16 +131,15 @@ const FacePile: FC = ({ room, onlyKnownUsers = true, numShown = DEFAULT_ } } - return
- - { members.length > numShown ? : null } - { shownMembers.map(m => - ) } - + return numShown} + tooltip={tooltip} + {...props} + > { onlyKnownUsers && { _t("%(count)s people you know have already joined", { count: members.length }) } } -
; + ; }; - -export default FacePile; diff --git a/src/components/views/voip/VideoLobby.tsx b/src/components/views/voip/VideoLobby.tsx index 245b28b060a..c2b7d5cb2dd 100644 --- a/src/components/views/voip/VideoLobby.tsx +++ b/src/components/views/voip/VideoLobby.tsx @@ -22,6 +22,7 @@ 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, @@ -31,6 +32,7 @@ import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures 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 { @@ -100,9 +102,12 @@ const DeviceButton: FC = ({
; }; +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 () => { @@ -163,7 +168,19 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => { } }; + 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 }