feat(jitsipopover): convert to InlineDialog (#1804)

* feat(small-video): use InlineDialog for stats and remote menu

- Remove JitsiPopover and use InlineDialog instead.
- Bring the remote menu icon into react.
- Make vertical filmstrip position:fixed so popper (AtlasKit
  dependency) sets InlineDialogs and eventually tooltips to
  position:fixed.

* ref(remote-menu): hook KickButton to redux

* ref(remote-menu): hook MuteButton to redux

* modify padding, toggle dialogs

* pixel push margins to align dialogs, adjust padding of dialogs

* add comment about margin for dialog, add file I forgot

* modify indicator markup so the icon can be moved down while trigger stays at top of toolbar
This commit is contained in:
virtuacoplenny 2017-08-14 08:02:58 -07:00 committed by yanas
parent cd910e3074
commit 725d39ddcd
22 changed files with 613 additions and 627 deletions

View File

@ -1,8 +1,7 @@
%connection-info {
text-align: left;
font-size: 12px;
font-weight: 400;
color: $popoverFontColor;
color: $modalTextColor;
td {
padding: 2px 0;
@ -11,11 +10,14 @@
.connection-info
{
float: left;
padding: 5px;
padding-left: 0;
@extend %connection-info;
/**
* Apply negative margin to reduce the appearance of padding in AtlasKit
* InlineDialog.
*/
margin: -15px;
> table {
white-space: nowrap;
@extend %connection-info;
@ -40,4 +42,11 @@
@extend .connection-info__icon;
color: $uploadConnectionIconColor;
}
.showmore {
display: block;
margin: 10px auto;
text-align: center;
width: 90px;
}
}

View File

@ -1,92 +0,0 @@
.jitsipopover {
position: absolute;
top: 0;
left: 0;
z-index: $jitsipopoverZ;
display: table;
visibility: hidden;
max-width: 300px;
min-width: 100px;
text-align: left;
color: $popoverFontColor;
background-color: $popoverBg;
background-clip: padding-box;
border-radius: $borderRadius;
/*-webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);*/
/*box-shadow: 0 5px 10px rgba(0, 0, 0, 0.4);*/
white-space: normal;
margin-top: -$popoverMenuPadding;
&__menu-padding,
&__menu-padding-top {
position: absolute;
width: 100px;
}
/**
* Invisible padding is added to the bottom of the popover to extend its
* height so it does not close when moving the mouse from the trigger
* element towards the popover itself.
*/
&__menu-padding {
bottom: -$popoverMenuPadding;
height: $popoverMenuPadding;
}
/**
* Invisible padding is added to the top of the popover to extend its height
* so it does not close automatically when its height is shrunk from showing
* less video statistics.
*/
&__menu-padding-top {
height: 20px;
top: -20px;
}
&__showmore {
display: block;
text-align: center;
width: 90px;
margin: 10px auto;
}
> .arrow {
position: absolute;
display: block;
left: 50%;
bottom: -5px;
margin-left: -5px;
width: 0;
height: 0;
border-color: transparent;
border-top-color: $popoverBg;
border-style: solid;
border-width: 5px;
border-bottom-width: 0;
}
/**
* Override default "top" styles to support popovers appearing from the
* left of the popover trigger element.
*/
&.left {
margin-left: -$popoverMenuPadding;
margin-top: 0;
.arrow {
border-color: transparent transparent transparent $popoverBg;
border-width: 5px 0px 5px 5px;
margin-left: 0;
margin-top: -5px;
}
.jitsipopover {
&__menu-padding {
bottom: 0;
height: 100%;
width: $popoverMenuPadding;
}
}
}
}

View File

@ -3,37 +3,31 @@
**/
.popupmenu {
text-align: left;
padding: 0;
margin: 2px 0;
bottom: 0;
height: auto;
&:first-child {
margin-top: 2px;
}
white-space: nowrap;
&__item {
list-style-type: none;
text-align: left;
height: 35px;
&:hover {
background-color: $popupMenuSelectedItemBackground;
background-color: rgba(9, 30, 66, 0.04);
}
}
// Link Appearance
&__link,
&__contents {
color: $modalTextColor;
display: block;
box-sizing: border-box;
text-decoration: none;
color: #fff;
padding: 5px;
height: 100%;
font-size: 9pt;
width: 100%;
cursor: hand;
cursor: pointer;
padding: 0 5px;
&.disabled {
color: gray !important;
@ -46,6 +40,12 @@
vertical-align: middle;
}
&__link {
i {
cursor: pointer;
}
}
&__contents {
display: flex;
@ -73,7 +73,6 @@
display: inline-block;
min-width: 20px;
height: 100%;
text-align: center;
> * {
@include absoluteAligning();
@ -85,6 +84,15 @@
}
}
/**
* Override reset css styling modifying all lists and set negative margin to
* reduce the visibility of padding on AtlasKit
* InlineDialogs.
*/
ul.popupmenu {
margin: -15px;
}
span.remotevideomenu:hover ul.popupmenu, ul.popupmenu:hover {
display:block !important;
}

View File

@ -112,7 +112,6 @@ $tooltipsZ: 401;
$dropdownMaskZ: 900;
$dropdownZ: 901;
$centeredVideoLabelZ: 1010;
$jitsipopoverZ: 1012;
$popoverZ: 1015;
$overlayZ: 1016;

View File

@ -8,6 +8,14 @@
display: flex;
flex-direction: column-reverse;
height: 100%;
/**
* fixed positioning is necessary for remote menus and tooltips to pop
* out of the scrolling filmstrip. AtlasKit dialogs and tooltips use
* a library called popper which will position its elements fixed if
* any parent is also fixed.
*/
position: fixed;
z-index: $tooltipsZ;
/**
* Hide videos by making them slight to the right.
@ -60,13 +68,16 @@
* Move the remote video menu trigger to the bottom left of the
* video thumbnail.
*/
.remotevideomenu {
.remotevideomenu,
.remote-video-menu-trigger {
bottom: 0;
left: 0;
top: auto;
right: auto;
}
.remote-video-menu-trigger {
margin-bottom: 7px;
transform: translate3d(0,0,0);
}
#remoteVideos {
@ -75,11 +86,6 @@
}
.videocontainer {
&__toolbar,
&__toptoolbar {
transform: translate3d(0,0,0);
}
/**
* Move status icons to the bottom right of the thumbnail.
*/
@ -159,4 +165,17 @@
transition-delay: 0.1s;
}
}
/**
* Apply hardware acceleration to prevent flickering on scroll. The
* selectors are specific to icon wrappers to prevent fixed position dialogs
* and tooltips from getting a new location context due to translate3d.
*/
.connection-indicator,
.remote-video-menu-trigger,
.videocontainer__toolbar,
.raisehandindicator,
#dominantspeakerindicator {
transform: translate3d(0, 0, 0);
}
}

View File

@ -48,15 +48,30 @@
&__toolbar {
bottom: 0;
padding: 0 5px 0 5px;
height: $thumbnailToolbarHeight;
padding: 0 5px 0 5px;
}
&__toptoolbar {
$toolbarPadding: 5px;
$toolbarIconMargin: 5px;
top: 0;
padding: $toolbarPadding;
padding-bottom: 0;
/**
* Override text-align center as icons need to be left justified.
*/
text-align: left;
/**
* Intentionally use margin on the icon itself as AtlasKit InlineDialog
* positioning depends on the trigger (indicator icon).
*/
.indicator {
margin-top: $toolbarIconMargin;
}
.indicator:nth-child(1) {
margin-left: $toolbarIconMargin;
}
.connection-indicator,
span.indicator {
@ -71,6 +86,15 @@
}
}
.connection-indicator-container {
display: inline-block;
vertical-align: top;
.popover-trigger {
display: inline-block;
}
}
.connection-indicator,
span.indicator {
position: relative;
@ -78,7 +102,6 @@
text-align: center;
line-height: $thumbnailIndicatorSize;
padding: 0;
float: left;
@include circle($thumbnailIndicatorSize);
box-sizing: border-box;
z-index: $zindex3;
@ -124,6 +147,7 @@
.icon-connection,
.icon-connection-lost {
cursor: pointer;
font-size: 1em;
}
}
@ -309,13 +333,13 @@
background: $connectionIndicatorBg;
}
.remote-video-menu-trigger,
.remotevideomenu
{
display: inline-block;
position: absolute;
top: 0px;
right: 0;
margin-top: 7px;
z-index: $zindex3;
width: 18px;
height: 13px;
@ -326,6 +350,9 @@
cursor: hand;
}
}
.remote-video-menu-trigger {
margin-top: 7px;
}
/**
* Audio indicator on video thumbnails.

View File

@ -50,7 +50,6 @@
@import 'recording';
@import 'login_menu';
@import 'popover';
@import 'jitsi_popover';
@import 'contact_list';
@import 'chat';
@import 'ringing/ringing';

View File

@ -8,7 +8,6 @@ import Chat from "./side_pannels/chat/Chat";
import SidePanels from "./side_pannels/SidePanels";
import Avatar from "./avatar/Avatar";
import SideContainerToggler from "./side_pannels/SideContainerToggler";
import JitsiPopover from "./util/JitsiPopover";
import messageHandler from "./util/MessageHandler";
import UIUtil from "./util/UIUtil";
import { activateTooltips } from './util/Tooltip';
@ -322,7 +321,6 @@ UI.start = function () {
UI.showToolbar();
Filmstrip.setFilmstripOnly();
APP.store.dispatch(setNotificationsEnabled(false));
JitsiPopover.enabled = false;
}
if (interfaceConfig.VERTICAL_FILMSTRIP) {

View File

@ -1,276 +0,0 @@
/* 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: {
// Align the popover's right side to the target element.
my: 'right',
// Align the popover to the left side of the target element.
at: 'left',
// Force the popover to fit within the viewport.
collision: 'fit',
/**
* Callback invoked by jQuery UI tooltip.
*
* @param {Object} position - The top and bottom position the popover
* element should be set at.
* @param {Object} element. - Additional size and position information
* about the popover element and target.
* @param {Object} elements.element - Has position and size related data
* for the popover element itself.
* @param {Object} elements.target - Has position and size related data
* for the target element the popover displays from.
*/
using: function setPositionLeft(position, elements) {
const { element, target } = elements;
$('.jitsipopover').css({
left: element.left,
top: element.top,
visibility: 'visible'
});
// Move additional padding to the right edge of the popover and
// allow css to take care of width. The padding is used to maintain
// a hover state between the target and the popover.
$('.jitsipopover > .jitsipopover__menu-padding')
.css({ left: element.width });
// Find the distance from the top of the popover to the center of
// the target and use that value to position the arrow to point to
// it.
const verticalCenterOfTarget = target.height / 2;
const verticalDistanceFromTops = target.top - element.top;
const verticalPositionOfTargetCenter
= verticalDistanceFromTops + verticalCenterOfTarget;
$('.jitsipopover > .arrow').css({
left: element.width,
top: verticalPositionOfTargetCenter
});
}
},
top: {
my: "bottom",
at: "top",
collision: "fit",
using: function setPositionTop(position, elements) {
const { element, target } = elements;
const calcLeft = target.left - element.left + target.width / 2;
const paddingLeftPosition = calcLeft - 50;
const $jistiPopover = $('.jitsipopover');
$jistiPopover.css({
left: element.left,
top: element.top,
visibility: 'visible'
});
$jistiPopover.find('.arrow').css({ left: calcLeft });
$jistiPopover.find('.jitsipopover__menu-padding')
.css({ left: paddingLeftPosition });
$jistiPopover.find('.jitsipopover__menu-padding-top')
.css({ left: paddingLeftPosition });
}
}
};
export default (function () {
/**
* The default options
*/
const defaultOptions = {
skin: 'white',
content: '',
hasArrow: true,
onBeforePosition: undefined,
position: 'top'
};
/**
* Constructs new JitsiPopover and attaches it to the element
* @param element jquery selector
* @param options the options for the popover.
* @constructor
*/
function JitsiPopover(element, options)
{
this.options = Object.assign({}, defaultOptions, options);
this.elementIsHovered = false;
this.popoverIsHovered = false;
this.popoverShown = false;
element.data("jitsi_popover", this);
this.element = element;
this.template = this.getTemplate();
var self = this;
this.element.on("mouseenter", function () {
self.elementIsHovered = true;
self.show();
}).on("mouseleave", function () {
self.elementIsHovered = false;
setTimeout(function () {
self.hide();
}, 10);
});
}
/**
* Returns template for popover
*/
JitsiPopover.prototype.getTemplate = function () {
const { hasArrow, position, skin } = this.options;
let arrow = '';
if (hasArrow) {
arrow = '<div class="arrow"></div>';
}
return (
`<div class="jitsipopover ${skin} ${position}">
<div class="jitsipopover__menu-padding-top"></div>
${arrow}
<div class="jitsipopover__content"></div>
<div class="jitsipopover__menu-padding"></div>
</div>`
);
};
/**
* Shows the popover
*/
JitsiPopover.prototype.show = function () {
if(!JitsiPopover.enabled)
return;
this.createPopover();
this.popoverShown = true;
};
/**
* Hides the popover if not hovered or popover is not shown.
*/
JitsiPopover.prototype.hide = function () {
if(!this.elementIsHovered && !this.popoverIsHovered &&
this.popoverShown) {
this.forceHide();
}
};
/**
* Hides the popover and clears the document elements added by popover.
*/
JitsiPopover.prototype.forceHide = function () {
this.remove();
this.popoverShown = false;
if(this.popoverIsHovered) { //the browser is not firing hover events
//when the element was on hover if got removed.
this.popoverIsHovered = false;
this.onHoverPopover(this.popoverIsHovered);
}
};
/**
* Creates the popover html.
*/
JitsiPopover.prototype.createPopover = function () {
let $popover = $('.jitsipopover');
if (!$popover.length) {
$('body').append(this.template);
$popover = $('.jitsipopover');
$popover.on('mouseenter', () => {
this.popoverIsHovered = true;
if (typeof this.onHoverPopover === 'function') {
this.onHoverPopover(this.popoverIsHovered);
}
});
$popover.on('mouseleave', () => {
this.popoverIsHovered = false;
this.hide();
if (typeof this.onHoverPopover === 'function') {
this.onHoverPopover(this.popoverIsHovered);
}
});
}
const $popoverContent = $popover.find('.jitsipopover__content');
/* jshint ignore:start */
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
{ this.options.content }
</I18nextProvider>,
$popoverContent.get(0),
() => {
this.refreshPosition();
});
/* jshint ignore:end */
};
/**
* Adds a hover listener to the popover.
*/
JitsiPopover.prototype.addOnHoverPopover = function (listener) {
this.onHoverPopover = listener;
};
/**
* Refreshes the position of the popover.
*/
JitsiPopover.prototype.refreshPosition = function () {
const positionOptions = Object.assign(
{},
positionConfigurations[this.options.position],
{
of: this.element
}
);
$(".jitsipopover").position(positionOptions);
};
/**
* Updates the content of popover.
* @param content new content
*/
JitsiPopover.prototype.updateContent = function (content) {
this.options.content = content;
if (!this.popoverShown) {
return;
}
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');
if ($popoverContent.length) {
ReactDOM.unmountComponentAtNode($popoverContent.get(0));
}
$popover.off();
$popover.remove();
};
JitsiPopover.enabled = true;
return JitsiPopover;
})();

View File

@ -24,6 +24,9 @@ function LocalVideo(VideoLayout, emitter) {
this._buildContextMenu();
this.isLocal = true;
this.emitter = emitter;
this.statsPopoverLocation = interfaceConfig.VERTICAL_FILMSTRIP
? 'left bottom' : 'top center';
Object.defineProperty(this, 'id', {
get: function () {
return APP.conference.getMyUserId();
@ -67,23 +70,34 @@ LocalVideo.prototype.changeVideo = function (stream) {
this.videoStream = stream;
let localVideoClick = (event) => {
// TODO Checking the classList is a workround to allow events to bubble
// TODO Checking the classes is a workround to allow events to bubble
// into the DisplayName component if it was clicked. React's synthetic
// events will fire after jQuery handlers execute, so stop propogation
// at this point will prevent DisplayName from getting click events.
// This workaround should be removeable once LocalVideo is a React
// Component because then the components share the same eventing system.
const $source = $(event.target || event.srcElement);
const { classList } = event.target;
const clickedOnDisplayName = classList.contains('displayname')
|| classList.contains('editdisplayname');
const clickedOnDisplayName
= $source.parents('.displayNameContainer').length > 0;
const clickedOnPopover
= $source.parents('.connection-info').length > 0;
const clickedOnPopoverTrigger
= $source.parents('.popover-trigger').length > 0
|| classList.contains('popover-trigger');
const ignoreClick = clickedOnDisplayName
|| clickedOnPopoverTrigger
|| clickedOnPopover;
// FIXME: with Temasys plugin event arg is not an event, but
// the clicked object itself, so we have to skip this call
if (event.stopPropagation && !clickedOnDisplayName) {
if (event.stopPropagation && !ignoreClick) {
event.stopPropagation();
}
if (!clickedOnDisplayName) {
if (!ignoreClick) {
this.VideoLayout.handleVideoThumbClicked(this.id);
}
};

View File

@ -4,15 +4,14 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { I18nextProvider } from 'react-i18next';
import { i18next } from '../../../react/features/base/i18n';
import { PresenceLabel } from '../../../react/features/presence-status';
import {
MuteButton,
KickButton,
REMOTE_CONTROL_MENU_STATES,
RemoteControlButton,
RemoteVideoMenu,
VolumeSlider
RemoteVideoMenuTriggerButton
} from '../../../react/features/remote-video-menu';
/* eslint-enable no-unused-vars */
@ -21,13 +20,7 @@ const logger = require("jitsi-meet-logger").getLogger(__filename);
import SmallVideo from "./SmallVideo";
import UIUtils from "../util/UIUtil";
import UIEvents from '../../../service/UI/UIEvents';
import JitsiPopover from "../util/JitsiPopover";
const MUTED_DIALOG_BUTTON_VALUES = {
cancel: 0,
muted: 1
};
const ParticipantConnectionStatus
= JitsiMeetJS.constants.participantConnectionStatus;
@ -49,6 +42,8 @@ function RemoteVideo(user, VideoLayout, emitter) {
this._audioStreamElement = null;
this.hasRemoteVideoMenu = false;
this._supportsRemoteControl = false;
this.statsPopoverLocation = interfaceConfig.VERTICAL_FILMSTRIP
? 'left top' : 'top center';
this.addRemoteVideoContainer();
this.updateIndicators();
this.setDisplayName();
@ -78,8 +73,6 @@ function RemoteVideo(user, VideoLayout, emitter) {
// 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);
@ -107,39 +100,6 @@ RemoteVideo.prototype.addRemoteVideoContainer = function() {
return this.container;
};
/**
* Initializes the remote participant popup menu, by specifying previously
* constructed popupMenuElement, containing all the menu items.
*
* @param popupMenuElement a pre-constructed element, containing the menu items
* to display in the popup
*/
RemoteVideo.prototype._initPopupMenu = function (popupMenuElement) {
let options = {
content: popupMenuElement.outerHTML,
skin: "black",
hasArrow: false,
position: interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top'
};
let element = $("#" + this.videoSpanId + " .remotevideomenu");
this.popover = new JitsiPopover(element, options);
this.popover.addOnHoverPopover(isHovered => {
this.popupMenuIsHovered = isHovered;
this.updateView();
});
// override popover show method to make sure we will update the content
// before showing the popover
let origShowFunc = this.popover.show;
this.popover.show = function () {
// update content by forcing it, to finish even if popover
// is not visible
this.updateRemoteVideoMenu(this.isAudioMuted, true);
// call the original show, passing its actual this
origShowFunc.call(this.popover);
}.bind(this);
};
/**
* Checks whether current video is considered hovered. Currently it is hovered
* if the mouse is over the video, or if the connection indicator or the popup
@ -160,6 +120,17 @@ RemoteVideo.prototype._isHovered = function () {
* @private
*/
RemoteVideo.prototype._generatePopupContent = function () {
if (interfaceConfig.filmStripOnly) {
return;
}
const remoteVideoMenuContainer
= this.container.querySelector('.remotevideomenu');
if (!remoteVideoMenuContainer) {
return;
}
const { controller } = APP.remoteControl;
let remoteControlState = null;
let onRemoteControlToggle;
@ -189,35 +160,28 @@ RemoteVideo.prototype._generatePopupContent = function () {
const participantID = this.id;
/* jshint ignore:start */
return (
<RemoteVideoMenu id = { participantID }>
{ isModerator
? <MuteButton
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<RemoteVideoMenuTriggerButton
initialVolumeValue = { initialVolumeValue }
isAudioMuted = { this.isAudioMuted }
onClick = { this._muteHandler }
participantID = { participantID } />
: null }
{ isModerator
? <KickButton
onClick = { this._kickHandler }
participantID = { participantID } />
: null }
{ remoteControlState
? <RemoteControlButton
onClick = { onRemoteControlToggle }
isModerator = { isModerator }
onMenuDisplay = { this._onRemoteVideoMenuDisplay.bind(this) }
onRemoteControlToggle = { onRemoteControlToggle }
onVolumeChange = { onVolumeChange }
participantID = { participantID }
remoteControlState = { remoteControlState } />
: null }
{ onVolumeChange
? <VolumeSlider
initialValue = { initialVolumeValue }
onChange = { onVolumeChange } />
: null }
</RemoteVideoMenu>
);
</I18nextProvider>
</Provider>,
remoteVideoMenuContainer);
/* jshint ignore:end */
};
RemoteVideo.prototype._onRemoteVideoMenuDisplay = function () {
this.updateRemoteVideoMenu(this.isAudioMuted, true);
};
/**
* Sets the remote control supported value and initializes or updates the menu
* depending on the remote control is supported or not.
@ -288,27 +252,6 @@ RemoteVideo.prototype._stopRemoteControl = function () {
this.updateRemoteVideoMenu(this.isAudioMuted, true);
};
RemoteVideo.prototype._muteHandler = function () {
if (this.isAudioMuted)
return;
RemoteVideo.showMuteParticipantDialog().then(reason => {
if(reason === MUTED_DIALOG_BUTTON_VALUES.muted) {
this.emitter.emit(UIEvents.REMOTE_AUDIO_MUTED, this.id);
}
}).catch(e => {
//currently shouldn't be called
logger.error(e);
});
this.popover.forceHide();
};
RemoteVideo.prototype._kickHandler = function () {
this.emitter.emit(UIEvents.USER_KICKED, this.id);
this.popover.forceHide();
};
/**
* Get the remote participant's audio element.
*
@ -345,18 +288,10 @@ RemoteVideo.prototype._setAudioVolume = function (newVal) {
* @param isMuted the new muted state to update to
* @param force to work even if popover is not visible
*/
RemoteVideo.prototype.updateRemoteVideoMenu = function (isMuted, force) {
RemoteVideo.prototype.updateRemoteVideoMenu = function (isMuted) {
this.isAudioMuted = isMuted;
if (!this.popover) {
return;
}
// generate content, translate it and add it to document only if
// popover is visible or we force to do so.
if(this.popover.popoverShown || force) {
this.popover.updateContent(this._generatePopupContent());
}
this._generatePopupContent();
};
/**
@ -395,17 +330,9 @@ RemoteVideo.prototype.addRemoteVideoMenu = function () {
if (interfaceConfig.filmStripOnly) {
return;
}
var spanElement = document.createElement('span');
spanElement.className = 'remotevideomenu';
this.container.appendChild(spanElement);
this._generatePopupContent();
var menuElement = document.createElement('i');
menuElement.className = 'icon-thumb-menu';
menuElement.title = 'Remote user controls';
spanElement.appendChild(menuElement);
this._initPopupMenu(this._generatePopupContent());
this.hasRemoteVideoMenu = true;
};
@ -538,6 +465,8 @@ RemoteVideo.prototype.remove = function () {
this._unmountIndicators();
this.removeRemoteVideoMenu();
// Make sure that the large video is updated if are removing its
// corresponding small video.
this.VideoLayout.updateAfterThumbRemoved(this.id);
@ -591,17 +520,29 @@ RemoteVideo.prototype.addRemoteStreamElement = function (stream) {
// Add click handler.
let onClickHandler = (event) => {
let source = event.target || event.srcElement;
const $source = $(event.target || event.srcElement);
const { classList } = event.target;
// ignore click if it was done in popup menu
if ($(source).parents('.popupmenu').length === 0) {
const clickedOnPopover
= $source.parents('.connection-info').length > 0;
const clickedOnPopoverTrigger
= $source.parents('.popover-trigger').length > 0
|| classList.contains('popover-trigger');
const clickedOnRemoteMenu
= $source.parents('.remotevideomenu').length > 0;
const ignoreClick = clickedOnPopoverTrigger
|| clickedOnPopover
|| clickedOnRemoteMenu;
if (!ignoreClick) {
this.VideoLayout.handleVideoThumbClicked(this.id);
}
// On IE we need to populate this handler on video <object>
// and it does not give event instance as an argument,
// so we check here for methods.
if (event.stopPropagation && event.preventDefault) {
if (event.stopPropagation && event.preventDefault && !ignoreClick) {
event.stopPropagation();
event.preventDefault();
}
@ -665,8 +606,9 @@ RemoteVideo.prototype.setDisplayName = function(displayName) {
*/
RemoteVideo.prototype.removeRemoteVideoMenu = function() {
var menuSpan = $('#' + this.videoSpanId + '> .remotevideomenu');
if (menuSpan.length) {
this.popover.forceHide();
ReactDOM.unmountComponentAtNode(menuSpan.get(0));
menuSpan.remove();
this.hasRemoteVideoMenu = false;
}
@ -740,30 +682,12 @@ RemoteVideo.createContainer = function (spanId) {
presenceLabelContainer.className = 'presence-label-container';
container.appendChild(presenceLabelContainer);
const remoteVideoMenuContainer = document.createElement('span');
remoteVideoMenuContainer.className = 'remotevideomenu';
container.appendChild(remoteVideoMenuContainer);
var remotes = document.getElementById('filmstripRemoteVideosContainer');
return remotes.appendChild(container);
};
/**
* Shows 2 button dialog for confirmation from the user for muting remote
* participant.
*/
RemoteVideo.showMuteParticipantDialog = function () {
return new Promise(resolve => {
APP.UI.messageHandler.openTwoButtonDialog({
titleKey : "dialog.muteParticipantTitle",
msgString: "<div data-i18n='dialog.muteParticipantBody'></div>",
leftButtonKey: "dialog.muteParticipantButton",
dontShowAgain: {
id: "dontShowMuteParticipantDialog",
textKey: "dialog.doNotShowMessageAgain",
checked: true,
buttonValues: [true]
},
submitFunction: () => resolve(MUTED_DIALOG_BUTTON_VALUES.muted),
closeFunction: () => resolve(MUTED_DIALOG_BUTTON_VALUES.cancel)
});
});
};
export default RemoteVideo;

View File

@ -747,21 +747,24 @@ SmallVideo.prototype.updateIndicators = function () {
/* jshint ignore:start */
ReactDOM.render(
<div>
{ this._showConnectionIndicator
? <ConnectionIndicator
connectionStatus = { this._connectionStatus }
iconSize = { iconSize }
isLocalVideo = { this.isLocal }
onHover = { this._onPopoverHover }
showMoreLink = { this.isLocal }
userID = { this.id } />
: null }
{ this._showRaisedHand
? <RaisedHandIndicator iconSize = { iconSize } /> : null }
{ this._showDominantSpeaker
? <DominantSpeakerIndicator iconSize = { iconSize } /> : null }
</div>,
<I18nextProvider i18n = { i18next }>
<div>
{ this._showConnectionIndicator
? <ConnectionIndicator
connectionStatus = { this._connectionStatus }
isLocalVideo = { this.isLocal }
enableStatsDisplay = { !interfaceConfig.filmStripOnly }
statsPopoverPosition = { this.statsPopoverLocation }
userID = { this.id } />
: null }
{ this._showRaisedHand
? <RaisedHandIndicator iconSize = { iconSize } />
: null }
{ this._showDominantSpeaker
? <DominantSpeakerIndicator iconSize = { iconSize } />
: null }
</div>
</I18nextProvider>,
indicatorToolbar
);
/* jshint ignore:end */

View File

@ -10,6 +10,26 @@
*/
export const DOMINANT_SPEAKER_CHANGED = Symbol('DOMINANT_SPEAKER_CHANGED');
/**
* Create an action for removing a participant from the conference.
*
* {
* type: KICK_PARTICIPANT,
* id: string
* }
*/
export const KICK_PARTICIPANT = Symbol('KICK_PARTICIPANT');
/**
* Create an action for muting a remote participant.
*
* {
* type: MUTE_REMOTE_PARTICIPANT,
* id: string
* }
*/
export const MUTE_REMOTE_PARTICIPANT = Symbol('MUTE_REMOTE_PARTICIPANT');
/**
* Create an action for when the local participant's display name is updated.
*

View File

@ -1,5 +1,7 @@
import {
DOMINANT_SPEAKER_CHANGED,
KICK_PARTICIPANT,
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_DISPLAY_NAME_CHANGED,
PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED,
@ -50,6 +52,22 @@ export function localParticipantConnectionStatusChanged(connectionStatus) {
};
}
/**
* Create an action for removing a participant from the conference.
*
* @param {string} id - Participant's ID.
* @returns {{
* type: KICK_PARTICIPANT,
* id: string
* }}
*/
export function kickParticipant(id) {
return {
type: KICK_PARTICIPANT,
id
};
}
/**
* Action to signal that the ID of local participant has changed. It happens
* when the local participant joins a new conference or leaves an existing
@ -106,6 +124,22 @@ export function localParticipantRoleChanged(role) {
};
}
/**
* Create an action for muting another participant in the conference.
*
* @param {string} id - Participant's ID.
* @returns {{
* type: MUTE_REMOTE_PARTICIPANT,
* id: string
* }}
*/
export function muteRemoteParticipant(id) {
return {
type: MUTE_REMOTE_PARTICIPANT,
id
};
}
/**
* Action to update a participant's connection status.
*

View File

@ -7,7 +7,11 @@ import {
import { MiddlewareRegistry } from '../redux';
import { localParticipantIdChanged } from './actions';
import { PARTICIPANT_DISPLAY_NAME_CHANGED } from './actionTypes';
import {
KICK_PARTICIPANT,
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_DISPLAY_NAME_CHANGED
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID } from './constants';
import { getLocalParticipant } from './functions';
@ -30,6 +34,32 @@ MiddlewareRegistry.register(store => next => action => {
store.dispatch(localParticipantIdChanged(LOCAL_PARTICIPANT_DEFAULT_ID));
break;
case KICK_PARTICIPANT:
if (typeof APP !== 'undefined') {
APP.UI.emitEvent(UIEvents.USER_KICKED, action.id);
}
break;
case MUTE_REMOTE_PARTICIPANT:
if (typeof APP !== 'undefined') {
APP.UI.messageHandler.openTwoButtonDialog({
titleKey: 'dialog.muteParticipantTitle',
msgString:
'<div data-i18n="dialog.muteParticipantBody"></div>',
leftButtonKey: 'dialog.muteParticipantButton',
dontShowAgain: {
id: 'dontShowMuteParticipantDialog',
textKey: 'dialog.doNotShowMessageAgain',
checked: true,
buttonValues: [ true ]
},
submitFunction: () => {
APP.UI.emitEvent(UIEvents.REMOTE_AUDIO_MUTED, action.id);
}
});
}
break;
// TODO Remove this middleware when the local display name update flow is
// fully brought into redux.
case PARTICIPANT_DISPLAY_NAME_CHANGED: {

View File

@ -1,7 +1,6 @@
import AKInlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react';
import JitsiPopover from '../../../../modules/UI/util/JitsiPopover';
import { JitsiParticipantConnectionStatus } from '../../base/lib-jitsi-meet';
import { ConnectionStatsTable } from '../../connection-stats';
@ -67,21 +66,22 @@ class ConnectionIndicator extends Component {
*/
connectionStatus: React.PropTypes.string,
/**
* Whether or not clicking the indicator should display a popover for
* more details.
*/
enableStatsDisplay: React.PropTypes.bool,
/**
* Whether or not the displays stats are for local video.
*/
isLocalVideo: React.PropTypes.bool,
/**
* The callback to invoke when the hover state over the popover changes.
* Relative to the icon from where the popover for more connection
* details should display.
*/
onHover: React.PropTypes.func,
/**
* Whether or not the popover should display a link that can toggle
* a more detailed view of the stats.
*/
showMoreLink: React.PropTypes.bool,
statsPopoverPosition: React.PropTypes.string,
/**
* Invoked to obtain translated strings.
@ -104,16 +104,6 @@ class ConnectionIndicator extends Component {
constructor(props) {
super(props);
/**
* The internal reference to topmost DOM/HTML element backing the React
* {@code Component}. Accessed directly for associating an element as
* the trigger for a popover.
*
* @private
* @type {HTMLDivElement}
*/
this._rootElement = null;
this.state = {
/**
* Whether or not the popover content should display additional
@ -134,12 +124,14 @@ class ConnectionIndicator extends Component {
// Bind event handlers so they are only bound once for every instance.
this._onStatsUpdated = this._onStatsUpdated.bind(this);
this._onStatsClose = this._onStatsClose.bind(this);
this._onStatsToggle = this._onStatsToggle.bind(this);
this._onStatsUpdated = this._onStatsUpdated.bind(this);
this._onToggleShowMore = this._onToggleShowMore.bind(this);
this._setRootElement = this._setRootElement.bind(this);
}
/**
* Creates a popover instance to display when the component is hovered.
* Starts listening for stat updates.
*
* @inheritdoc
* returns {void}
@ -147,20 +139,10 @@ class ConnectionIndicator extends Component {
componentDidMount() {
statsEmitter.subscribeToClientStats(
this.props.userID, this._onStatsUpdated);
this.popover = new JitsiPopover($(this._rootElement), {
content: this._renderStatisticsTable(),
skin: 'black',
position: interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top'
});
this.popover.addOnHoverPopover(this.props.onHover);
}
/**
* Updates the contents of the popover. This is done manually because the
* popover is not a React Component yet and so is not automatiucally aware
* of changed data.
* Updates which user's stats are being listened to.
*
* @inheritdoc
* returns {void}
@ -172,22 +154,17 @@ class ConnectionIndicator extends Component {
statsEmitter.subscribeToClientStats(
this.props.userID, this._onStatsUpdated);
}
this.popover.updateContent(this._renderStatisticsTable());
}
/**
* Cleans up any popover instance that is linked to the component.
* Sets the state to hide the Statistics Table popover.
*
* @inheritdoc
* returns {void}
* @private
* @returns {void}
*/
componentWillUnmount() {
statsEmitter.unsubscribeToClientStats(
this.props.userID, this._onStatsUpdated);
this.popover.forceHide();
this.popover.remove();
}
/**
@ -198,16 +175,48 @@ class ConnectionIndicator extends Component {
*/
render() {
return (
<div
className = 'connection-indicator indicator'
ref = { this._setRootElement }>
<div className = 'connection indicatoricon'>
{ this._renderIcon() }
</div>
<div className = 'connection-indicator-container'>
<AKInlineDialog
content = { this._renderStatisticsTable() }
isOpen = { this.state.showStats }
onClose = { this._onStatsClose }
position = { this.props.statsPopoverPosition }>
<div
className = 'popover-trigger'
onClick = { this._onStatsToggle }>
<div className = 'connection-indicator indicator'>
<div className = 'connection indicatoricon'>
{ this._renderIcon() }
</div>
</div>
</div>
</AKInlineDialog>
</div>
);
}
/**
* Sets the state not to show the Statistics Table popover.
*
* @private
* @returns {void}
*/
_onStatsClose() {
this.setState({ showStats: false });
}
/**
* Sets the state to show or hide the Statistics Table popover.
*
* @private
* @returns {void}
*/
_onStatsToggle() {
if (this.props.enableStatsDisplay) {
this.setState({ showStats: !this.state.showStats });
}
}
/**
* Callback invoked when new connection stats associated with the passed in
* user ID are available. Will update the component's display of current
@ -314,17 +323,6 @@ class ConnectionIndicator extends Component {
transport = { transport } />
);
}
/**
* Sets an internal reference to the component's root element.
*
* @param {Object} element - The highest DOM element in the component.
* @private
* @returns {void}
*/
_setRootElement(element) {
this._rootElement = element;
}
}
export default ConnectionIndicator;

View File

@ -292,7 +292,7 @@ class ConnectionStatsTable extends Component {
return (
<a
className = 'jitsipopover__showmore link'
className = 'showmore link'
onClick = { this.props.onShowMore } >
{ this.props.t(translationKey) }
</a>

View File

@ -27,7 +27,7 @@ class BaseIndicator extends Component {
iconClassName: React.PropTypes.string,
/**
* The front size for the icon.
* The font size for the icon.
*/
iconSize: React.PropTypes.string,

View File

@ -1,6 +1,8 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { kickParticipant } from '../../base/participants';
import RemoteVideoMenuButton from './RemoteVideoMenuButton';
@ -18,7 +20,13 @@ class KickButton extends Component {
*/
static propTypes = {
/**
* The callback to invoke when the component is clicked.
* Invoked to signal the participant with the passed in participantID
* should be removed from the conference.
*/
dispatch: React.PropTypes.func,
/**
* Callback to invoke when {@code KickButton} is clicked.
*/
onClick: React.PropTypes.func,
@ -33,6 +41,19 @@ class KickButton extends Component {
t: React.PropTypes.func
};
/**
* Initializes a new {@code KickButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
@ -40,16 +61,32 @@ class KickButton extends Component {
* @returns {ReactElement}
*/
render() {
const { onClick, participantID, t } = this.props;
const { participantID, t } = this.props;
return (
<RemoteVideoMenuButton
buttonText = { t('videothumbnail.kick') }
iconClass = 'icon-kick'
id = { `ejectlink_${participantID}` }
onClick = { onClick } />
onClick = { this._onClick } />
);
}
/**
* Remove the participant with associated participantID from the conference.
*
* @private
* @returns {void}
*/
_onClick() {
const { dispatch, onClick, participantID } = this.props;
dispatch(kickParticipant(participantID));
if (onClick) {
onClick();
}
}
}
export default translate(KickButton);
export default translate(connect()(KickButton));

View File

@ -1,6 +1,8 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { muteRemoteParticipant } from '../../base/participants';
import RemoteVideoMenuButton from './RemoteVideoMenuButton';
@ -17,13 +19,19 @@ class MuteButton extends Component {
* @static
*/
static propTypes = {
/**
* Invoked to send a request for muting the participant with the passed
* in participantID.
*/
dispatch: React.PropTypes.func,
/**
* Whether or not the participant is currently audio muted.
*/
isAudioMuted: React.PropTypes.bool,
/**
* The callback to invoke when the component is clicked.
* Callback to invoke when {@code MuteButton} is clicked.
*/
onClick: React.PropTypes.func,
@ -38,6 +46,19 @@ class MuteButton extends Component {
t: React.PropTypes.func
};
/**
* Initializes a new {@code MuteButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
@ -45,7 +66,7 @@ class MuteButton extends Component {
* @returns {ReactElement}
*/
render() {
const { isAudioMuted, onClick, participantID, t } = this.props;
const { isAudioMuted, participantID, t } = this.props;
const muteConfig = isAudioMuted ? {
translationKey: 'videothumbnail.muted',
muteClassName: 'mutelink disabled'
@ -60,9 +81,26 @@ class MuteButton extends Component {
displayClass = { muteConfig.muteClassName }
iconClass = 'icon-mic-disabled'
id = { `mutelink_${participantID}` }
onClick = { onClick } />
onClick = { this._onClick } />
);
}
/**
* Dispatches a request to mute the participant with the passed in
* participantID.
*
* @private
* @returns {void}
*/
_onClick() {
const { dispatch, onClick, participantID } = this.props;
dispatch(muteRemoteParticipant(participantID));
if (onClick) {
onClick();
}
}
}
export default translate(MuteButton);
export default translate(connect()(MuteButton));

View File

@ -0,0 +1,194 @@
import AKInlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react';
import {
MuteButton,
KickButton,
RemoteControlButton,
RemoteVideoMenu,
VolumeSlider
} from './';
declare var $: Object;
declare var interfaceConfig: Object;
/**
* React {@code Component} for displaying an icon associated with opening the
* the {@code RemoteVideoMenu}.
*
* @extends {Component}
*/
class RemoteVideoMenuTriggerButton extends Component {
static propTypes = {
/**
* A value between 0 and 1 indicating the volume of the participant's
* audio element.
*/
initialVolumeValue: React.PropTypes.number,
/**
* Whether or not the participant is currently muted.
*/
isAudioMuted: React.PropTypes.bool,
/**
* Whether or not the participant is a conference moderator.
*/
isModerator: React.PropTypes.bool,
/**
* Callback to invoke when the popover has been displayed.
*/
onMenuDisplay: React.PropTypes.func,
/**
* Callback to invoke choosing to start a remote control session with
* the participant.
*/
onRemoteControlToggle: React.PropTypes.func,
/**
* Callback to invoke when changing the level of the participant's
* audio element.
*/
onVolumeChange: React.PropTypes.func,
/**
* The ID for the participant on which the remote video menu will act.
*/
participantID: React.PropTypes.string,
/**
* The current state of the participant's remote control session.
*/
remoteControlState: React.PropTypes.number
};
/**
* Initializes a new {#@code RemoteVideoMenuTriggerButton} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
showRemoteMenu: false
};
/**
* The internal reference to topmost DOM/HTML element backing the React
* {@code Component}. Accessed directly for associating an element as
* the trigger for a popover.
*
* @private
* @type {HTMLDivElement}
*/
this._rootElement = null;
// Bind event handlers so they are only bound once for every instance.
this._onRemoteMenuClose = this._onRemoteMenuClose.bind(this);
this._onRemoteMenuToggle = this._onRemoteMenuToggle.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<AKInlineDialog
content = { this._renderRemoteVideoMenu() }
isOpen = { this.state.showRemoteMenu }
onClose = { this._onRemoteMenuClose }
position = { interfaceConfig.VERTICAL_FILMSTRIP
? 'left middle' : 'top center' }
shouldFlip = { true }>
<span
className = 'popover-trigger remote-video-menu-trigger'
onClick = { this._onRemoteMenuToggle }>
<i
className = 'icon-thumb-menu'
title = 'Remote user controls' />
</span>
</AKInlineDialog>
);
}
/**
* Closes the {@code RemoteVideoMenu}.
*
* @private
* @returns {void}
*/
_onRemoteMenuClose() {
this.setState({ showRemoteMenu: false });
}
/**
* Opens or closes the {@code RemoteVideoMenu}.
*
* @private
* @returns {void}
*/
_onRemoteMenuToggle() {
const willShowRemoteMenu = !this.state.showRemoteMenu;
if (willShowRemoteMenu) {
this.props.onMenuDisplay();
}
this.setState({ showRemoteMenu: willShowRemoteMenu });
}
/**
* Creates a new {@code RemoteVideoMenu} with buttons for interacting with
* the remote participant.
*
* @private
* @returns {ReactElement}
*/
_renderRemoteVideoMenu() {
const {
initialVolumeValue,
isAudioMuted,
isModerator,
onRemoteControlToggle,
onVolumeChange,
remoteControlState,
participantID
} = this.props;
return (
<RemoteVideoMenu id = { participantID }>
{ isModerator
? <MuteButton
isAudioMuted = { isAudioMuted }
onClick = { this._onRemoteMenuClose }
participantID = { participantID } />
: null }
{ isModerator
? <KickButton
onClick = { this._onRemoteMenuClose }
participantID = { participantID } />
: null }
{ remoteControlState
? <RemoteControlButton
onClick = { onRemoteControlToggle }
participantID = { participantID }
remoteControlState = { remoteControlState } />
: null }
{ onVolumeChange
? <VolumeSlider
initialValue = { initialVolumeValue }
onChange = { onVolumeChange } />
: null }
</RemoteVideoMenu>
);
}
}
export default RemoteVideoMenuTriggerButton;

View File

@ -5,4 +5,7 @@ export {
default as RemoteControlButton
} from './RemoteControlButton';
export { default as RemoteVideoMenu } from './RemoteVideoMenu';
export {
default as RemoteVideoMenuTriggerButton
} from './RemoteVideoMenuTriggerButton';
export { default as VolumeSlider } from './VolumeSlider';