From da99f3b939828c24fcd675f596aa6420f71811e2 Mon Sep 17 00:00:00 2001 From: Leonard Kim Date: Wed, 31 May 2017 08:42:50 -0700 Subject: [PATCH] feat(remote-video): convert remote video menu to react - Create new React Components for RemoteVideoMenu and its buttons - Remove existing menu creation from RemoteVideo - Refactor RemoteVideo so all function binding happens once in the constructor, removing the need to rebind when updating the RemoteVideoMenu - Allow popover to append and remove React Components from itself - Refactor popover so post-popover creation calls are broken out and popover removal behavior is all done in one function. --- modules/UI/util/JitsiPopover.js | 96 +++++-- modules/UI/videolayout/RemoteVideo.js | 237 ++++++------------ .../components/KickButton.js | 55 ++++ .../components/MuteButton.js | 68 +++++ .../components/RemoteControlButton.js | 99 ++++++++ .../components/RemoteVideoMenu.js | 43 ++++ .../components/RemoteVideoMenuButton.js | 76 ++++++ .../components/VolumeSlider.js | 102 ++++++++ .../remote-video-menu/components/index.js | 8 + react/features/remote-video-menu/index.js | 1 + 10 files changed, 605 insertions(+), 180 deletions(-) create mode 100644 react/features/remote-video-menu/components/KickButton.js create mode 100644 react/features/remote-video-menu/components/MuteButton.js create mode 100644 react/features/remote-video-menu/components/RemoteControlButton.js create mode 100644 react/features/remote-video-menu/components/RemoteVideoMenu.js create mode 100644 react/features/remote-video-menu/components/RemoteVideoMenuButton.js create mode 100644 react/features/remote-video-menu/components/VolumeSlider.js create mode 100644 react/features/remote-video-menu/components/index.js create mode 100644 react/features/remote-video-menu/index.js diff --git a/modules/UI/util/JitsiPopover.js b/modules/UI/util/JitsiPopover.js index ce6686b39..3bb226aa3 100644 --- a/modules/UI/util/JitsiPopover.js +++ b/modules/UI/util/JitsiPopover.js @@ -1,5 +1,13 @@ /* global $ */ +/* eslint-disable no-unused-vars */ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { I18nextProvider } from 'react-i18next'; + +import { i18next } from '../../../react/features/base/i18n'; +/* eslint-enable no-unused-vars */ + const positionConfigurations = { left: { @@ -155,7 +163,7 @@ var JitsiPopover = (function () { * Hides the popover and clears the document elements added by popover. */ JitsiPopover.prototype.forceHide = function () { - $(".jitsipopover").remove(); + this.remove(); this.popoverShown = false; if(this.popoverIsHovered) { //the browser is not firing hover events //when the element was on hover if got removed. @@ -170,21 +178,59 @@ var JitsiPopover = (function () { JitsiPopover.prototype.createPopover = function () { $("body").append(this.template); let popoverElem = $(".jitsipopover > .jitsipopover__content"); - popoverElem.html(this.options.content); - if(typeof this.options.onBeforePosition === "function") { - this.options.onBeforePosition($(".jitsipopover")); + + const { content } = this.options; + + if (React.isValidElement(content)) { + /* jshint ignore:start */ + ReactDOM.render( + + { content } + , + popoverElem.get(0), + () => { + // FIXME There seems to be odd timing interaction when a + // React Component is manually removed from the DOM and then + // created again, as the ReactDOM callback will fire before + // render is called on the React Component. Using a timeout + // looks to bypass this behavior, maybe by creating + // different execution context. JitsiPopover should be + // rewritten into react soon anyway or at least rewritten + // so the html isn't completely torn down with each update. + setTimeout(() => this._popoverCreated()); + }); + /* jshint ignore:end */ + return; } - var self = this; - $(".jitsipopover").on("mouseenter", function () { - self.popoverIsHovered = true; - if(typeof self.onHoverPopover === "function") { - self.onHoverPopover(self.popoverIsHovered); + + popoverElem.html(content); + this._popoverCreated(); + }; + + /** + * Adds listeners and executes callbacks after the popover has been created + * and displayed. + * + * @private + * @returns {void} + */ + JitsiPopover.prototype._popoverCreated = function () { + const { onBeforePosition } = this.options; + + if (typeof onBeforePosition === 'function') { + onBeforePosition($(".jitsipopover")); + } + + $('.jitsipopover').on('mouseenter', () => { + this.popoverIsHovered = true; + if (typeof this.onHoverPopover === 'function') { + this.onHoverPopover(this.popoverIsHovered); } - }).on("mouseleave", function () { - self.popoverIsHovered = false; - self.hide(); - if(typeof self.onHoverPopover === "function") { - self.onHoverPopover(self.popoverIsHovered); + }).on('mouseleave', () => { + this.popoverIsHovered = false; + this.hide(); + if (typeof this.onHoverPopover === 'function') { + this.onHoverPopover(this.popoverIsHovered); } }); @@ -220,10 +266,30 @@ var JitsiPopover = (function () { this.options.content = content; if(!this.popoverShown) return; - $(".jitsipopover").remove(); + this.remove(); this.createPopover(); }; + /** + * Unmounts any present child React Component and removes the popover itself + * from the DOM. + * + * @returns {void} + */ + JitsiPopover.prototype.remove = function () { + const $popover = $('.jitsipopover'); + const $popoverContent = $popover.find('.jitsipopover__content'); + const attachedComponent = $popoverContent.get(0); + + if (attachedComponent) { + // ReactDOM will no-op if no React Component is found. + ReactDOM.unmountComponentAtNode(attachedComponent); + } + + $popover.off(); + $popover.remove(); + }; + JitsiPopover.enabled = true; return JitsiPopover; diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index b04d6fec6..9001cdb81 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -1,4 +1,18 @@ /* global $, APP, interfaceConfig, JitsiMeetJS */ + +/* eslint-disable no-unused-vars */ +import React from 'react'; + +import { + MuteButton, + KickButton, + REMOTE_CONTROL_MENU_STATES, + RemoteControlButton, + RemoteVideoMenu, + VolumeSlider +} from '../../../react/features/remote-video-menu'; +/* eslint-enable no-unused-vars */ + const logger = require("jitsi-meet-logger").getLogger(__filename); import ConnectionIndicator from './ConnectionIndicator'; @@ -58,6 +72,16 @@ function RemoteVideo(user, VideoLayout, emitter) { * @type {boolean} */ this.mutedWhileDisconnected = false; + + // Bind event handlers so they are only bound once for every instance. + // TODO The event handlers should be turned into actions so changes can be + // handled through reducers and middleware. + this._kickHandler = this._kickHandler.bind(this); + this._muteHandler = this._muteHandler.bind(this); + this._requestRemoteControlPermissions + = this._requestRemoteControlPermissions.bind(this); + this._setAudioVolume = this._setAudioVolume.bind(this); + this._stopRemoteControl = this._stopRemoteControl.bind(this); } RemoteVideo.prototype = Object.create(SmallVideo.prototype); @@ -133,92 +157,62 @@ RemoteVideo.prototype._isHovered = function () { * @private */ RemoteVideo.prototype._generatePopupContent = function () { - let popupmenuElement = document.createElement('ul'); - popupmenuElement.className = 'popupmenu'; - popupmenuElement.id = `remote_popupmenu_${this.id}`; - let menuItems = []; + const { controller } = APP.remoteControl; + let remoteControlState = null; + let onRemoteControlToggle; - if(APP.conference.isModerator) { - let muteTranslationKey; - let muteClassName; - if (this.isAudioMuted) { - muteTranslationKey = 'videothumbnail.muted'; - muteClassName = 'mutelink disabled'; + if (this._supportsRemoteControl) { + if (controller.getRequestedParticipant() === this.id) { + onRemoteControlToggle = () => {}; + remoteControlState = REMOTE_CONTROL_MENU_STATES.REQUESTING; + } else if (!controller.isStarted()) { + onRemoteControlToggle = this._requestRemoteControlPermissions; + remoteControlState = REMOTE_CONTROL_MENU_STATES.NOT_STARTED; } else { - muteTranslationKey = 'videothumbnail.domute'; - muteClassName = 'mutelink'; + onRemoteControlToggle = this._stopRemoteControl; + remoteControlState = REMOTE_CONTROL_MENU_STATES.STARTED; } - - let muteHandler = this._muteHandler.bind(this); - let kickHandler = this._kickHandler.bind(this); - - menuItems = [ - { - id: 'mutelink_' + this.id, - handler: muteHandler, - icon: 'icon-mic-disabled', - className: muteClassName, - data: { - i18n: muteTranslationKey - } - }, { - id: 'ejectlink_' + this.id, - handler: kickHandler, - icon: 'icon-kick', - data: { - i18n: 'videothumbnail.kick' - } - } - ]; } - if(this._supportsRemoteControl) { - let icon, handler, className; - if(APP.remoteControl.controller.getRequestedParticipant() - === this.id) { - handler = () => {}; - className = "requestRemoteControlLink disabled"; - icon = "remote-control-spinner fa fa-spinner fa-spin"; - } else if(!APP.remoteControl.controller.isStarted()) { - handler = this._requestRemoteControlPermissions.bind(this); - icon = "fa fa-play"; - className = "requestRemoteControlLink"; - } else { - handler = this._stopRemoteControl.bind(this); - icon = "fa fa-stop"; - className = "requestRemoteControlLink"; - } - menuItems.push({ - id: 'remoteControl_' + this.id, - handler, - icon, - className, - data: { - i18n: 'videothumbnail.remoteControl' - } - }); - } + let initialVolumeValue, onVolumeChange; - menuItems.forEach(el => { - let menuItem = this._generatePopupMenuItem(el); - popupmenuElement.appendChild(menuItem); - }); - - // feature check for volume setting as temasys objects cannot adjust volume + // Feature check for volume setting as temasys objects cannot adjust volume. if (this._canSetAudioVolume()) { - const volumeScale = 100; - const volumeSlider = this._generatePopupMenuSliderItem({ - handler: this._setAudioVolume.bind(this, volumeScale), - icon: 'icon-volume', - initialValue: this._getAudioElement().volume * volumeScale, - maxValue: volumeScale - }); - popupmenuElement.appendChild(volumeSlider); + initialVolumeValue = this._getAudioElement().volume; + onVolumeChange = this._setAudioVolume; } - APP.translation.translateElement($(popupmenuElement)); + const { isModerator } = APP.conference; + const participantID = this.id; - return popupmenuElement; + /* jshint ignore:start */ + return ( + + { isModerator + ? + : null } + { isModerator + ? + : null } + { remoteControlState + ? + : null } + { onVolumeChange + ? + : null } + + ); + /* jshint ignore:end */ }; /** @@ -312,92 +306,6 @@ RemoteVideo.prototype._kickHandler = function () { this.popover.forceHide(); }; -RemoteVideo.prototype._generatePopupMenuItem = function (opts = {}) { - let { - id, - handler, - icon, - data, - className - } = opts; - - handler = handler || $.noop; - - let menuItem = document.createElement('li'); - menuItem.className = 'popupmenu__item'; - - let linkItem = document.createElement('a'); - linkItem.className = 'popupmenu__link'; - - if (className) { - linkItem.className += ` ${className}`; - } - - if (icon) { - let indicator = document.createElement('span'); - indicator.className = 'popupmenu__icon'; - indicator.innerHTML = ``; - linkItem.appendChild(indicator); - } - - let textContent = document.createElement('span'); - textContent.className = 'popupmenu__text'; - - if (data) { - let dataKeys = Object.keys(data); - dataKeys.forEach(key => { - textContent.dataset[key] = data[key]; - }); - } - - linkItem.appendChild(textContent); - linkItem.id = id; - - linkItem.onclick = handler; - menuItem.appendChild(linkItem); - - return menuItem; -}; - -/** - * Create a div element with a slider. - * - * @param {object} options - Configuration for the div's display and slider. - * @param {string} options.icon - The classname for the icon to display. - * @param {int} options.maxValue - The maximum value on the slider. The default - * value is 100. - * @param {int} options.initialValue - The value the slider should start at. - * The default value is 0. - * @param {function} options.handler - The callback for slider value changes. - * @returns {Element} A div element with a slider. - */ -RemoteVideo.prototype._generatePopupMenuSliderItem = function (options) { - const template = `
- - - -
- - -
-
`; - - const menuItem = document.createElement('li'); - menuItem.className = 'popupmenu__item'; - menuItem.innerHTML = template; - - const slider = menuItem.getElementsByClassName('popupmenu__slider')[0]; - slider.oninput = function () { - options.handler(Number(slider.value)); - }; - - return menuItem; -}; - /** * Get the remote participant's audio element. * @@ -420,12 +328,11 @@ RemoteVideo.prototype._canSetAudioVolume = function () { /** * Change the remote participant's volume level. * - * @param {int} scale - The maximum value the slider can go to. * @param {int} newVal - The value to set the slider to. */ -RemoteVideo.prototype._setAudioVolume = function (scale, newVal) { +RemoteVideo.prototype._setAudioVolume = function (newVal) { if (this._canSetAudioVolume()) { - this._getAudioElement().volume = newVal / scale; + this._getAudioElement().volume = newVal; } }; diff --git a/react/features/remote-video-menu/components/KickButton.js b/react/features/remote-video-menu/components/KickButton.js new file mode 100644 index 000000000..7504c6a56 --- /dev/null +++ b/react/features/remote-video-menu/components/KickButton.js @@ -0,0 +1,55 @@ +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +import RemoteVideoMenuButton from './RemoteVideoMenuButton'; + +/** + * Implements a React {@link Component} which displays a button for kicking out + * a participant from the conference. + * + * @extends Component + */ +class KickButton extends Component { + /** + * {@code KickButton} component's property types. + * + * @static + */ + static propTypes = { + /** + * The callback to invoke when the component is clicked. + */ + onClick: React.PropTypes.func, + + /** + * The ID of the participant linked to the onClick callback. + */ + participantID: React.PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { onClick, participantID, t } = this.props; + + return ( + + ); + } +} + +export default translate(KickButton); diff --git a/react/features/remote-video-menu/components/MuteButton.js b/react/features/remote-video-menu/components/MuteButton.js new file mode 100644 index 000000000..2f59e3040 --- /dev/null +++ b/react/features/remote-video-menu/components/MuteButton.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +import RemoteVideoMenuButton from './RemoteVideoMenuButton'; + +/** + * Implements a React {@link Component} which displays a button for audio muting + * a participant in the conference. + * + * @extends Component + */ +class MuteButton extends Component { + /** + * {@code MuteButton} component's property types. + * + * @static + */ + static propTypes = { + /** + * Whether or not the participant is currently audio muted. + */ + isAudioMuted: React.PropTypes.bool, + + /** + * The callback to invoke when the component is clicked. + */ + onClick: React.PropTypes.func, + + /** + * The ID of the participant linked to the onClick callback. + */ + participantID: React.PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { isAudioMuted, onClick, participantID, t } = this.props; + const muteConfig = isAudioMuted ? { + translationKey: 'videothumbnail.muted', + muteClassName: 'mutelink disabled' + } : { + translationKey: 'videothumbnail.domute', + muteClassName: 'mutelink' + }; + + return ( + + ); + } +} + +export default translate(MuteButton); diff --git a/react/features/remote-video-menu/components/RemoteControlButton.js b/react/features/remote-video-menu/components/RemoteControlButton.js new file mode 100644 index 000000000..77c8ec5b9 --- /dev/null +++ b/react/features/remote-video-menu/components/RemoteControlButton.js @@ -0,0 +1,99 @@ +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +import RemoteVideoMenuButton from './RemoteVideoMenuButton'; + +// TODO: Move these enums into the store after further reactification of the +// non-react RemoteVideo component. +export const REMOTE_CONTROL_MENU_STATES = { + NOT_SUPPORTED: 0, + NOT_STARTED: 1, + REQUESTING: 2, + STARTED: 3 +}; + +/** + * Implements a React {@link Component} which displays a button showing the + * current state of remote control for a participant and can start or stop a + * remote control session. + * + * @extends Component + */ +class RemoteControlButton extends Component { + /** + * {@code RemoteControlButton} component's property types. + * + * @static + */ + static propTypes = { + /** + * The callback to invoke when the component is clicked. + */ + onClick: React.PropTypes.func, + + /** + * The ID of the participant linked to the onClick callback. + */ + participantID: React.PropTypes.string, + + /** + * The current status of remote control. Should be a number listed in + * the enum REMOTE_CONTROL_MENU_STATES. + */ + remoteControlState: React.PropTypes.number, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {null|ReactElement} + */ + render() { + const { + onClick, + participantID, + remoteControlState, + t + } = this.props; + + let className, icon; + + switch (remoteControlState) { + case REMOTE_CONTROL_MENU_STATES.NOT_STARTED: + className = 'requestRemoteControlLink'; + icon = 'fa fa-play'; + break; + case REMOTE_CONTROL_MENU_STATES.REQUESTING: + className = 'requestRemoteControlLink disabled'; + icon = 'remote-control-spinner fa fa-spinner fa-spin'; + break; + case REMOTE_CONTROL_MENU_STATES.STARTED: + className = 'requestRemoteControlLink'; + icon = 'fa fa-stop'; + break; + case REMOTE_CONTROL_MENU_STATES.NOT_SUPPORTED: + + // Intentionally fall through. + default: + return null; + } + + return ( + + ); + } +} + +export default translate(RemoteControlButton); diff --git a/react/features/remote-video-menu/components/RemoteVideoMenu.js b/react/features/remote-video-menu/components/RemoteVideoMenu.js new file mode 100644 index 000000000..45a427025 --- /dev/null +++ b/react/features/remote-video-menu/components/RemoteVideoMenu.js @@ -0,0 +1,43 @@ +import React, { Component } from 'react'; + +/** + * React {@code Component} responsible for displaying other components as a menu + * for manipulating remote participant state. + * + * @extends {Component} + */ +export default class RemoteVideoMenu extends Component { + /** + * {@code RemoteVideoMenu}'s property types. + * + * @static + */ + static propTypes = { + /** + * The components to place as the body of the {@code RemoteVideoMenu}. + */ + children: React.PropTypes.node, + + /** + * The id attribute to be added to the component's DOM for retrieval + * when querying the DOM. Not used directly by the component. + */ + id: React.PropTypes.string + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( +
    + { this.props.children } +
+ ); + } +} diff --git a/react/features/remote-video-menu/components/RemoteVideoMenuButton.js b/react/features/remote-video-menu/components/RemoteVideoMenuButton.js new file mode 100644 index 000000000..8b1487502 --- /dev/null +++ b/react/features/remote-video-menu/components/RemoteVideoMenuButton.js @@ -0,0 +1,76 @@ +import React, { Component } from 'react'; + +/** + * React {@code Component} for displaying an action in {@code RemoteVideoMenu}. + * + * @extends {Component} + */ +export default class RemoteVideoMenuButton extends Component { + /** + * {@code RemoteVideoMenuButton}'s property types. + * + * @static + */ + static propTypes = { + /** + * Text to display within the component that describes the onClick + * action. + */ + buttonText: React.PropTypes.string, + + /** + * Additional CSS classes to add to the component. + */ + displayClass: React.PropTypes.string, + + /** + * The CSS classes for the icon that will display within the component. + */ + iconClass: React.PropTypes.string, + + /** + * The id attribute to be added to the component's DOM for retrieval + * when querying the DOM. Not used directly by the component. + */ + id: React.PropTypes.string, + + /** + * Callback to invoke when the component is clicked. + */ + onClick: React.PropTypes.func + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + buttonText, + displayClass, + iconClass, + id, + onClick + } = this.props; + + const linkClassName = `popupmenu__link ${displayClass || ''}`; + + return ( +
  • + + + + + + { buttonText } + + +
  • + ); + } +} diff --git a/react/features/remote-video-menu/components/VolumeSlider.js b/react/features/remote-video-menu/components/VolumeSlider.js new file mode 100644 index 000000000..ae73794c5 --- /dev/null +++ b/react/features/remote-video-menu/components/VolumeSlider.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; + +/** + * Used to modify initialValue, which is expected to be a decimal value between + * 0 and 1, and converts it to a number representable by an input slider, which + * recognizes whole numbers. + */ +const VOLUME_SLIDER_SCALE = 100; + +/** + * Implements a React {@link Component} which displays an input slider for + * adjusting the local volume of a remote participant. + * + * @extends Component + */ +class VolumeSlider extends Component { + /** + * {@code VolumeSlider} component's property types. + * + * @static + */ + static propTypes = { + /** + * The value of the audio slider should display at when the component + * first mounts. Changes will be stored in state. The value should be a + * number between 0 and 1. + */ + initialValue: React.PropTypes.number, + + /** + * The callback to invoke when the audio slider value changes. + */ + onChange: React.PropTypes.func + }; + + /** + * Initializes a new {@code VolumeSlider} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + /** + * The volume of the participant's audio element. The value will + * be represented by a slider. + * + * @type {Number} + */ + volumeLevel: (props.initialValue || 0) * VOLUME_SLIDER_SCALE + }; + + // Bind event handlers so they are only bound once for every instance. + this._onVolumeChange = this._onVolumeChange.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( +
  • +
    + + + +
    + +
    +
    +
  • + ); + } + + /** + * Sets the internal state of the volume level for the volume slider. + * Invokes the prop onVolumeChange to notify of volume changes. + * + * @param {Object} event - DOM Event for slider change. + * @private + * @returns {void} + */ + _onVolumeChange(event) { + const volumeLevel = event.currentTarget.value; + + this.props.onChange(volumeLevel / VOLUME_SLIDER_SCALE); + this.setState({ volumeLevel }); + } +} + +export default VolumeSlider; diff --git a/react/features/remote-video-menu/components/index.js b/react/features/remote-video-menu/components/index.js new file mode 100644 index 000000000..214fda9e8 --- /dev/null +++ b/react/features/remote-video-menu/components/index.js @@ -0,0 +1,8 @@ +export { default as KickButton } from './KickButton'; +export { default as MuteButton } from './MuteButton'; +export { + REMOTE_CONTROL_MENU_STATES, + default as RemoteControlButton +} from './RemoteControlButton'; +export { default as RemoteVideoMenu } from './RemoteVideoMenu'; +export { default as VolumeSlider } from './VolumeSlider'; diff --git a/react/features/remote-video-menu/index.js b/react/features/remote-video-menu/index.js new file mode 100644 index 000000000..07635cbbc --- /dev/null +++ b/react/features/remote-video-menu/index.js @@ -0,0 +1 @@ +export * from './components';