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.
This commit is contained in:
Leonard Kim 2017-05-31 08:42:50 -07:00 committed by hristoterezov
parent 73dd7440d0
commit da99f3b939
10 changed files with 605 additions and 180 deletions

View File

@ -1,5 +1,13 @@
/* global $ */ /* 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 = { const positionConfigurations = {
left: { left: {
@ -155,7 +163,7 @@ var JitsiPopover = (function () {
* Hides the popover and clears the document elements added by popover. * Hides the popover and clears the document elements added by popover.
*/ */
JitsiPopover.prototype.forceHide = function () { JitsiPopover.prototype.forceHide = function () {
$(".jitsipopover").remove(); this.remove();
this.popoverShown = false; this.popoverShown = false;
if(this.popoverIsHovered) { //the browser is not firing hover events if(this.popoverIsHovered) { //the browser is not firing hover events
//when the element was on hover if got removed. //when the element was on hover if got removed.
@ -170,21 +178,59 @@ var JitsiPopover = (function () {
JitsiPopover.prototype.createPopover = function () { JitsiPopover.prototype.createPopover = function () {
$("body").append(this.template); $("body").append(this.template);
let popoverElem = $(".jitsipopover > .jitsipopover__content"); let popoverElem = $(".jitsipopover > .jitsipopover__content");
popoverElem.html(this.options.content);
if(typeof this.options.onBeforePosition === "function") { const { content } = this.options;
this.options.onBeforePosition($(".jitsipopover"));
if (React.isValidElement(content)) {
/* jshint ignore:start */
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
{ content }
</I18nextProvider>,
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 () { popoverElem.html(content);
self.popoverIsHovered = true; this._popoverCreated();
if(typeof self.onHoverPopover === "function") { };
self.onHoverPopover(self.popoverIsHovered);
/**
* 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 () { }).on('mouseleave', () => {
self.popoverIsHovered = false; this.popoverIsHovered = false;
self.hide(); this.hide();
if(typeof self.onHoverPopover === "function") { if (typeof this.onHoverPopover === 'function') {
self.onHoverPopover(self.popoverIsHovered); this.onHoverPopover(this.popoverIsHovered);
} }
}); });
@ -220,10 +266,30 @@ var JitsiPopover = (function () {
this.options.content = content; this.options.content = content;
if(!this.popoverShown) if(!this.popoverShown)
return; return;
$(".jitsipopover").remove(); this.remove();
this.createPopover(); 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; JitsiPopover.enabled = true;
return JitsiPopover; return JitsiPopover;

View File

@ -1,4 +1,18 @@
/* global $, APP, interfaceConfig, JitsiMeetJS */ /* 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); const logger = require("jitsi-meet-logger").getLogger(__filename);
import ConnectionIndicator from './ConnectionIndicator'; import ConnectionIndicator from './ConnectionIndicator';
@ -58,6 +72,16 @@ function RemoteVideo(user, VideoLayout, emitter) {
* @type {boolean} * @type {boolean}
*/ */
this.mutedWhileDisconnected = false; 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); RemoteVideo.prototype = Object.create(SmallVideo.prototype);
@ -133,92 +157,62 @@ RemoteVideo.prototype._isHovered = function () {
* @private * @private
*/ */
RemoteVideo.prototype._generatePopupContent = function () { RemoteVideo.prototype._generatePopupContent = function () {
let popupmenuElement = document.createElement('ul'); const { controller } = APP.remoteControl;
popupmenuElement.className = 'popupmenu'; let remoteControlState = null;
popupmenuElement.id = `remote_popupmenu_${this.id}`; let onRemoteControlToggle;
let menuItems = [];
if(APP.conference.isModerator) { if (this._supportsRemoteControl) {
let muteTranslationKey; if (controller.getRequestedParticipant() === this.id) {
let muteClassName; onRemoteControlToggle = () => {};
if (this.isAudioMuted) { remoteControlState = REMOTE_CONTROL_MENU_STATES.REQUESTING;
muteTranslationKey = 'videothumbnail.muted'; } else if (!controller.isStarted()) {
muteClassName = 'mutelink disabled'; onRemoteControlToggle = this._requestRemoteControlPermissions;
remoteControlState = REMOTE_CONTROL_MENU_STATES.NOT_STARTED;
} else { } else {
muteTranslationKey = 'videothumbnail.domute'; onRemoteControlToggle = this._stopRemoteControl;
muteClassName = 'mutelink'; 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 initialVolumeValue, onVolumeChange;
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'
}
});
}
menuItems.forEach(el => { // Feature check for volume setting as temasys objects cannot adjust volume.
let menuItem = this._generatePopupMenuItem(el);
popupmenuElement.appendChild(menuItem);
});
// feature check for volume setting as temasys objects cannot adjust volume
if (this._canSetAudioVolume()) { if (this._canSetAudioVolume()) {
const volumeScale = 100; initialVolumeValue = this._getAudioElement().volume;
const volumeSlider = this._generatePopupMenuSliderItem({ onVolumeChange = this._setAudioVolume;
handler: this._setAudioVolume.bind(this, volumeScale),
icon: 'icon-volume',
initialValue: this._getAudioElement().volume * volumeScale,
maxValue: volumeScale
});
popupmenuElement.appendChild(volumeSlider);
} }
APP.translation.translateElement($(popupmenuElement)); const { isModerator } = APP.conference;
const participantID = this.id;
return popupmenuElement; /* jshint ignore:start */
return (
<RemoteVideoMenu id = { participantID }>
{ isModerator
? <MuteButton
isAudioMuted = { this.isAudioMuted }
onClick = { this._muteHandler }
participantID = { participantID } />
: null }
{ isModerator
? <KickButton
onClick = { this._kickHandler }
participantID = { participantID } />
: null }
{ remoteControlState
? <RemoteControlButton
onClick = { onRemoteControlToggle }
participantID = { participantID }
remoteControlState = { remoteControlState } />
: null }
{ onVolumeChange
? <VolumeSlider
initialValue = { initialVolumeValue }
onChange = { onVolumeChange } />
: null }
</RemoteVideoMenu>
);
/* jshint ignore:end */
}; };
/** /**
@ -312,92 +306,6 @@ RemoteVideo.prototype._kickHandler = function () {
this.popover.forceHide(); 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 = `<i class="${icon}"></i>`;
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 = `<div class='popupmenu__contents'>
<span class='popupmenu__icon'>
<i class=${options.icon}></i>
</span>
<div class='popupmenu__slider_container'>
<input class='popupmenu__slider'
type='range'
min='0'
max=${options.maxValue || 100}
value=${options.initialValue || 0}>
</input>
</div>
</div>`;
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. * Get the remote participant's audio element.
* *
@ -420,12 +328,11 @@ RemoteVideo.prototype._canSetAudioVolume = function () {
/** /**
* Change the remote participant's volume level. * 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. * @param {int} newVal - The value to set the slider to.
*/ */
RemoteVideo.prototype._setAudioVolume = function (scale, newVal) { RemoteVideo.prototype._setAudioVolume = function (newVal) {
if (this._canSetAudioVolume()) { if (this._canSetAudioVolume()) {
this._getAudioElement().volume = newVal / scale; this._getAudioElement().volume = newVal;
} }
}; };

View File

@ -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 (
<RemoteVideoMenuButton
buttonText = { t('videothumbnail.kick') }
iconClass = 'icon-kick'
id = { `ejectlink_${participantID}` }
onClick = { onClick } />
);
}
}
export default translate(KickButton);

View File

@ -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 (
<RemoteVideoMenuButton
buttonText = { t(muteConfig.translationKey) }
displayClass = { muteConfig.muteClassName }
iconClass = 'icon-mic-disabled'
id = { `mutelink_${participantID}` }
onClick = { onClick } />
);
}
}
export default translate(MuteButton);

View File

@ -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 (
<RemoteVideoMenuButton
buttonText = { t('videothumbnail.remoteControl') }
displayClass = { className }
iconClass = { icon }
id = { `remoteControl_${participantID}` }
onClick = { onClick } />
);
}
}
export default translate(RemoteControlButton);

View File

@ -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 (
<ul
className = 'popupmenu'
id = { this.props.id }>
{ this.props.children }
</ul>
);
}
}

View File

@ -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 (
<li className = 'popupmenu__item'>
<a
className = { linkClassName }
id = { id }
onClick = { onClick }>
<span className = 'popupmenu__icon'>
<i className = { iconClass } />
</span>
<span className = 'popupmenu__text'>
{ buttonText }
</span>
</a>
</li>
);
}
}

View File

@ -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 (
<li className = 'popupmenu__item'>
<div className = 'popupmenu__contents'>
<span className = 'popupmenu__icon'>
<i className = 'icon-volume' />
</span>
<div className = 'popupmenu__slider_container'>
<input
className = 'popupmenu__slider'
max = { VOLUME_SLIDER_SCALE }
min = { 0 }
onChange = { this._onVolumeChange }
type = 'range'
value = { this.state.volumeLevel } />
</div>
</div>
</li>
);
}
/**
* 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;

View File

@ -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';

View File

@ -0,0 +1 @@
export * from './components';