From c7013f5c4b0b8bc8a9ccf1e93f95002c3e851f4a Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Wed, 17 Apr 2019 08:05:32 -0700 Subject: [PATCH] ref(follow-me): hook into redux (#3991) Use subscribers to detect state change and emit those out to other participants. Use middleware to register the command listener. --- modules/FollowMe.js | 546 ------------------ modules/UI/UI.js | 12 - modules/UI/etherpad/Etherpad.js | 4 - react/features/app/components/AbstractApp.js | 1 + react/features/base/conference/actions.js | 6 - react/features/etherpad/reducer.js | 53 +- react/features/follow-me/constants.js | 6 + react/features/follow-me/index.js | 2 + react/features/follow-me/middleware.js | 162 ++++++ react/features/follow-me/subscriber.js | 110 ++++ react/features/video-layout/middleware.web.js | 11 - react/features/video-layout/reducer.js | 12 + service/UI/UIEvents.js | 17 - 13 files changed, 330 insertions(+), 612 deletions(-) delete mode 100644 modules/FollowMe.js create mode 100644 react/features/follow-me/constants.js create mode 100644 react/features/follow-me/index.js create mode 100644 react/features/follow-me/middleware.js create mode 100644 react/features/follow-me/subscriber.js diff --git a/modules/FollowMe.js b/modules/FollowMe.js deleted file mode 100644 index 2428f5c1f..000000000 --- a/modules/FollowMe.js +++ /dev/null @@ -1,546 +0,0 @@ -/* global APP */ - -/* - * Copyright @ 2015 Atlassian Pty Ltd - * - * 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. - */ -const logger = require('jitsi-meet-logger').getLogger(__filename); - -import { - getPinnedParticipant, - pinParticipant -} from '../react/features/base/participants'; -import { setTileView } from '../react/features/video-layout'; -import UIEvents from '../service/UI/UIEvents'; -import VideoLayout from './UI/videolayout/VideoLayout'; - -/** - * The (name of the) command which transports the state (represented by - * {State} for the local state at the time of this writing) of a {FollowMe} - * (instance) between participants. - */ -const _COMMAND = 'follow-me'; - -/** - * The timeout after which a follow-me command that has been received will be - * ignored if not consumed. - * - * @type {number} in seconds - * @private - */ -const _FOLLOW_ME_RECEIVED_TIMEOUT = 30; - -/** - * Represents the set of {FollowMe}-related states (properties and their - * respective values) which are to be followed by a participant. {FollowMe} - * will send {_COMMAND} whenever a property of {State} changes (if the local - * participant is in her right to issue such a command, of course). - */ -class State { - /** - * Initializes a new {State} instance. - * - * @param propertyChangeCallback {Function} which is to be called when a - * property of the new instance has its value changed from an old value - * into a (different) new value. The function is supplied with the name of - * the property, the old value of the property before the change, and the - * new value of the property after the change. - */ - constructor(propertyChangeCallback) { - this._propertyChangeCallback = propertyChangeCallback; - } - - /** - * - */ - get filmstripVisible() { - return this._filmstripVisible; - } - - /** - * - */ - set filmstripVisible(b) { - const oldValue = this._filmstripVisible; - - if (oldValue !== b) { - this._filmstripVisible = b; - this._firePropertyChange('filmstripVisible', oldValue, b); - } - } - - /** - * - */ - get nextOnStage() { - return this._nextOnStage; - } - - /** - * - */ - set nextOnStage(id) { - const oldValue = this._nextOnStage; - - if (oldValue !== id) { - this._nextOnStage = id; - this._firePropertyChange('nextOnStage', oldValue, id); - } - } - - /** - * - */ - get sharedDocumentVisible() { - return this._sharedDocumentVisible; - } - - /** - * - */ - set sharedDocumentVisible(b) { - const oldValue = this._sharedDocumentVisible; - - if (oldValue !== b) { - this._sharedDocumentVisible = b; - this._firePropertyChange('sharedDocumentVisible', oldValue, b); - } - } - - /** - * A getter for this object instance to know the state of tile view. - * - * @returns {boolean} True if tile view is enabled. - */ - get tileViewEnabled() { - return this._tileViewEnabled; - } - - /** - * A setter for {@link tileViewEnabled}. Fires a property change event for - * other participants to follow. - * - * @param {boolean} b - Whether or not tile view is enabled. - * @returns {void} - */ - set tileViewEnabled(b) { - const oldValue = this._tileViewEnabled; - - if (oldValue !== b) { - this._tileViewEnabled = b; - this._firePropertyChange('tileViewEnabled', oldValue, b); - } - } - - /** - * Invokes {_propertyChangeCallback} to notify it that {property} had its - * value changed from {oldValue} to {newValue}. - * - * @param property the name of the property which had its value changed - * from {oldValue} to {newValue} - * @param oldValue the value of {property} before the change - * @param newValue the value of {property} after the change - */ - _firePropertyChange(property, oldValue, newValue) { - const propertyChangeCallback = this._propertyChangeCallback; - - if (propertyChangeCallback) { - propertyChangeCallback(property, oldValue, newValue); - } - } -} - -/** - * Represents the "Follow Me" feature which enables a moderator to - * (partially) control the user experience/interface (e.g. filmstrip - * visibility) of (other) non-moderator particiapnts. - * - * @author Lyubomir Marinov - */ -class FollowMe { - /** - * Initializes a new {FollowMe} instance. - * - * @param conference the {conference} which is to transport - * {FollowMe}-related information between participants - * @param UI the {UI} which is the source (model/state) to be sent to - * remote participants if the local participant is the moderator or the - * destination (model/state) to receive from the remote moderator if the - * local participant is not the moderator - */ - constructor(conference, UI) { - this._conference = conference; - this._UI = UI; - this.nextOnStageTimer = 0; - - // The states of the local participant which are to be followed (by the - // remote participants when the local participant is in her right to - // issue such commands). - this._local = new State(this._localPropertyChange.bind(this)); - - // Listen to "Follow Me" commands. I'm not sure whether a moderator can - // (in lib-jitsi-meet and/or Meet) become a non-moderator. If that's - // possible, then it may be easiest to always listen to commands. The - // listener will validate received commands before acting on them. - conference.commands.addCommandListener( - _COMMAND, - this._onFollowMeCommand.bind(this)); - } - - /** - * Sets the current state of all follow-me properties, which will fire a - * localPropertyChangeEvent and trigger a send of the follow-me command. - * @private - */ - _setFollowMeInitialState() { - this._filmstripToggled.bind(this, this._UI.isFilmstripVisible()); - - const pinnedId = VideoLayout.getPinnedId(); - - this._nextOnStage(pinnedId, Boolean(pinnedId)); - - // check whether shared document is enabled/initialized - if (this._UI.getSharedDocumentManager()) { - this._sharedDocumentToggled - .bind(this, this._UI.getSharedDocumentManager().isVisible()); - } - - this._tileViewToggled.bind( - this, - APP.store.getState()['features/video-layout'].tileViewEnabled); - } - - /** - * Adds listeners for the UI states of the local participant which are - * to be followed (by the remote participants). A non-moderator (very - * likely) can become a moderator so it may be easiest to always track - * the states of interest. - * @private - */ - _addFollowMeListeners() { - this.filmstripEventHandler = this._filmstripToggled.bind(this); - this._UI.addListener(UIEvents.TOGGLED_FILMSTRIP, - this.filmstripEventHandler); - - const self = this; - - this.pinnedEndpointEventHandler = function(videoId, isPinned) { - self._nextOnStage(videoId, isPinned); - }; - this._UI.addListener(UIEvents.PINNED_ENDPOINT, - this.pinnedEndpointEventHandler); - - this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this); - this._UI.addListener(UIEvents.TOGGLED_SHARED_DOCUMENT, - this.sharedDocEventHandler); - - this.tileViewEventHandler = this._tileViewToggled.bind(this); - this._UI.addListener(UIEvents.TOGGLED_TILE_VIEW, - this.tileViewEventHandler); - } - - /** - * Removes all follow me listeners. - * @private - */ - _removeFollowMeListeners() { - this._UI.removeListener(UIEvents.TOGGLED_FILMSTRIP, - this.filmstripEventHandler); - this._UI.removeListener(UIEvents.TOGGLED_SHARED_DOCUMENT, - this.sharedDocEventHandler); - this._UI.removeListener(UIEvents.PINNED_ENDPOINT, - this.pinnedEndpointEventHandler); - this._UI.removeListener(UIEvents.TOGGLED_TILE_VIEW, - this.tileViewEventHandler); - } - - /** - * Enables or disabled the follow me functionality - * - * @param enable {true} to enable the follow me functionality, {false} - - * to disable it - */ - enableFollowMe(enable) { - if (enable) { - this._setFollowMeInitialState(); - this._addFollowMeListeners(); - } else { - this._removeFollowMeListeners(); - } - } - - /** - * Notifies this instance that the (visibility of the) filmstrip was - * toggled (in the user interface of the local participant). - * - * @param filmstripVisible {Boolean} {true} if the filmstrip was shown (as a - * result of the toggle) or {false} if the filmstrip was hidden - */ - _filmstripToggled(filmstripVisible) { - this._local.filmstripVisible = filmstripVisible; - } - - /** - * Notifies this instance that the (visibility of the) shared document was - * toggled (in the user interface of the local participant). - * - * @param sharedDocumentVisible {Boolean} {true} if the shared document was - * shown (as a result of the toggle) or {false} if it was hidden - */ - _sharedDocumentToggled(sharedDocumentVisible) { - this._local.sharedDocumentVisible = sharedDocumentVisible; - } - - /** - * Notifies this instance that the tile view mode has been enabled or - * disabled. - * - * @param {boolean} enabled - True if tile view has been enabled, false - * if has been disabled. - * @returns {void} - */ - _tileViewToggled(enabled) { - this._local.tileViewEnabled = enabled; - } - - /** - * Changes the nextOnStage property value. - * - * @param smallVideo the {SmallVideo} that was pinned or unpinned - * @param isPinned indicates if the given {SmallVideo} was pinned or - * unpinned - * @private - */ - _nextOnStage(videoId, isPinned) { - if (!this._conference.isModerator) { - return; - } - - let nextOnStage = null; - - if (isPinned) { - nextOnStage = videoId; - } - - this._local.nextOnStage = nextOnStage; - } - - /** - * Sends the follow-me command, when a local property change occurs. - * - * @private - */ - _localPropertyChange() { // eslint-disable-next-line no-unused-vars - // Only a moderator is allowed to send commands. - const conference = this._conference; - - if (!conference.isModerator) { - return; - } - - const commands = conference.commands; - - // XXX The "Follow Me" command represents a snapshot of all states - // which are to be followed so don't forget to removeCommand before - // sendCommand! - - commands.removeCommand(_COMMAND); - const local = this._local; - - commands.sendCommandOnce( - _COMMAND, - { - attributes: { - filmstripVisible: local.filmstripVisible, - nextOnStage: local.nextOnStage, - sharedDocumentVisible: local.sharedDocumentVisible, - tileViewEnabled: local.tileViewEnabled - } - }); - } - - /** - * Notifies this instance about a &qout;Follow Me&qout; command (delivered - * by the Command(s) API of {this._conference}). - * - * @param attributes the attributes {Object} carried by the command - * @param id the identifier of the participant who issued the command. A - * notable idiosyncrasy of the Command(s) API to be mindful of here is that - * the command may be issued by the local participant. - */ - _onFollowMeCommand({ attributes }, id) { - // We require to know who issued the command because (1) only a - // moderator is allowed to send commands and (2) a command MUST be - // issued by a defined commander. - if (typeof id === 'undefined') { - return; - } - - // The Command(s) API will send us our own commands and we don't want - // to act upon them. - if (this._conference.isLocalId(id)) { - return; - } - - if (!this._conference.isParticipantModerator(id)) { - logger.warn('Received follow-me command not from moderator'); - - return; - } - - // Applies the received/remote command to the user experience/interface - // of the local participant. - this._onFilmstripVisible(attributes.filmstripVisible); - this._onNextOnStage(attributes.nextOnStage); - this._onSharedDocumentVisible(attributes.sharedDocumentVisible); - this._onTileViewEnabled(attributes.tileViewEnabled); - } - - /** - * Process a filmstrip open / close event received from FOLLOW-ME - * command. - * @param filmstripVisible indicates if the filmstrip has been shown or - * hidden - * @private - */ - _onFilmstripVisible(filmstripVisible) { - if (typeof filmstripVisible !== 'undefined') { - // XXX The Command(s) API doesn't preserve the types (of - // attributes, at least) at the time of this writing so take into - // account that what originated as a Boolean may be a String on - // receipt. - // eslint-disable-next-line eqeqeq, no-param-reassign - filmstripVisible = filmstripVisible == 'true'; - - // FIXME The UI (module) very likely doesn't (want to) expose its - // eventEmitter as a public field. I'm not sure at the time of this - // writing whether calling UI.toggleFilmstrip() is acceptable (from - // a design standpoint) either. - if (filmstripVisible !== this._UI.isFilmstripVisible()) { - this._UI.eventEmitter.emit(UIEvents.TOGGLE_FILMSTRIP); - } - } - } - - /** - * Process the id received from a FOLLOW-ME command. - * @param id the identifier of the next participant to show on stage or - * undefined if we're clearing the stage (we're unpining all pined and we - * rely on dominant speaker events) - * @private - */ - _onNextOnStage(id) { - let clickId = null; - let pin; - - // if there is an id which is not pinned we schedule it for pin only the - // first time - - if (typeof id !== 'undefined' && !VideoLayout.isPinned(id)) { - clickId = id; - pin = true; - } else if (typeof id === 'undefined' && VideoLayout.getPinnedId()) { - // if there is no id, but we have a pinned one, let's unpin - clickId = VideoLayout.getPinnedId(); - pin = false; - } - - if (clickId) { - this._pinVideoThumbnailById(clickId, pin); - } - } - - /** - * Process a shared document open / close event received from FOLLOW-ME - * command. - * @param sharedDocumentVisible indicates if the shared document has been - * opened or closed - * @private - */ - _onSharedDocumentVisible(sharedDocumentVisible) { - if (typeof sharedDocumentVisible !== 'undefined') { - // XXX The Command(s) API doesn't preserve the types (of - // attributes, at least) at the time of this writing so take into - // account that what originated as a Boolean may be a String on - // receipt. - // eslint-disable-next-line eqeqeq, no-param-reassign - sharedDocumentVisible = sharedDocumentVisible == 'true'; - - if (sharedDocumentVisible - !== this._UI.getSharedDocumentManager().isVisible()) { - this._UI.getSharedDocumentManager().toggleEtherpad(); - } - } - } - - /** - * Process a tile view enabled / disabled event received from FOLLOW-ME. - * - * @param {boolean} enabled - Whether or not tile view should be shown. - * @private - * @returns {void} - */ - _onTileViewEnabled(enabled) { - if (typeof enabled === 'undefined') { - return; - } - - APP.store.dispatch(setTileView(enabled === 'true')); - } - - /** - * Pins / unpins the video thumbnail given by clickId. - * - * @param clickId the identifier of the video thumbnail to pin or unpin - * @param pin {true} to pin, {false} to unpin - * @private - */ - _pinVideoThumbnailById(clickId, pin) { - const self = this; - const smallVideo = VideoLayout.getSmallVideo(clickId); - - // If the SmallVideo for the given clickId exists we proceed with the - // pin/unpin. - if (smallVideo) { - this.nextOnStageTimer = 0; - clearTimeout(this.nextOnStageTimout); - - if (pin) { - APP.store.dispatch(pinParticipant(clickId)); - } else { - const { id } = getPinnedParticipant(APP.store.getState()) || {}; - - if (id === clickId) { - APP.store.dispatch(pinParticipant(null)); - } - } - } else { - // If there's no SmallVideo object for the given id, lets wait and - // see if it's going to be created in the next 30sec. - this.nextOnStageTimout = setTimeout(function() { - if (self.nextOnStageTimer > _FOLLOW_ME_RECEIVED_TIMEOUT) { - self.nextOnStageTimer = 0; - - return; - } - - // eslint-disable-next-line no-invalid-this - this.nextOnStageTimer++; - self._pinVideoThumbnailById(clickId, pin); - }, 1000); - } - } -} - -export default FollowMe; diff --git a/modules/UI/UI.js b/modules/UI/UI.js index 9c60e83cc..373cf9c44 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -32,7 +32,6 @@ import { const EventEmitter = require('events'); UI.messageHandler = messageHandler; -import FollowMe from '../FollowMe'; const eventEmitter = new EventEmitter(); @@ -41,8 +40,6 @@ UI.eventEmitter = eventEmitter; let etherpadManager; let sharedVideoManager; -let followMeHandler; - const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = { microphone: {}, camera: {} @@ -86,9 +83,6 @@ const UIListeners = new Map([ ], [ UIEvents.TOGGLE_FILMSTRIP, () => UI.toggleFilmstrip() - ], [ - UIEvents.FOLLOW_ME_ENABLED, - enabled => followMeHandler && followMeHandler.enableFollowMe(enabled) ] ]); @@ -193,12 +187,6 @@ UI.initConference = function() { if (displayName) { UI.changeDisplayName('localVideoContainer', displayName); } - - // FollowMe attempts to copy certain aspects of the moderator's UI into the - // other participants' UI. Consequently, it needs (1) read and write access - // to the UI (depending on the moderator role of the local participant) and - // (2) APP.conference as means of communication between the participants. - followMeHandler = new FollowMe(APP.conference, UI); }; /** diff --git a/modules/UI/etherpad/Etherpad.js b/modules/UI/etherpad/Etherpad.js index 05f37290d..71577b05b 100644 --- a/modules/UI/etherpad/Etherpad.js +++ b/modules/UI/etherpad/Etherpad.js @@ -5,7 +5,6 @@ import { getToolboxHeight } from '../../../react/features/toolbox'; import VideoLayout from '../videolayout/VideoLayout'; import LargeContainer from '../videolayout/LargeContainer'; -import UIEvents from '../../../service/UI/UIEvents'; import Filmstrip from '../videolayout/Filmstrip'; /** @@ -250,9 +249,6 @@ export default class EtherpadManager { VideoLayout.showLargeVideoContainer( ETHERPAD_CONTAINER_TYPE, !isVisible); - this.eventEmitter - .emit(UIEvents.TOGGLED_SHARED_DOCUMENT, !isVisible); - APP.store.dispatch(setDocumentEditingState(!isVisible)); } } diff --git a/react/features/app/components/AbstractApp.js b/react/features/app/components/AbstractApp.js index ba1421ffd..4d07ddfc2 100644 --- a/react/features/app/components/AbstractApp.js +++ b/react/features/app/components/AbstractApp.js @@ -4,6 +4,7 @@ import React, { Fragment } from 'react'; import { BaseApp } from '../../base/app'; import { toURLString } from '../../base/util'; +import '../../follow-me'; import { OverlayContainer } from '../../overlay'; import { appNavigate } from '../actions'; diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 81643389e..36662e3c8 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -1,7 +1,5 @@ // @flow -import UIEvents from '../../../../service/UI/UIEvents'; - import { createStartMutedConfigurationEvent, sendAnalytics @@ -547,10 +545,6 @@ export function setDesktopSharingEnabled(desktopSharingEnabled: boolean) { * }} */ export function setFollowMe(enabled: boolean) { - if (typeof APP !== 'undefined') { - APP.UI.emitEvent(UIEvents.FOLLOW_ME_ENABLED, enabled); - } - return { type: SET_FOLLOW_ME, enabled diff --git a/react/features/etherpad/reducer.js b/react/features/etherpad/reducer.js index 80ad74be9..61590cd0b 100644 --- a/react/features/etherpad/reducer.js +++ b/react/features/etherpad/reducer.js @@ -7,24 +7,45 @@ import { SET_DOCUMENT_EDITING_STATUS } from './actionTypes'; +const DEFAULT_STATE = { + + /** + * Whether or not Etherpad is currently open. + * + * @public + * @type {boolean} + */ + editing: false, + + /** + * Whether or not Etherpad is ready to use. + * + * @public + * @type {boolean} + */ + initialized: false +}; + /** * Reduces the Redux actions of the feature features/etherpad. */ -ReducerRegistry.register('features/etherpad', (state = {}, action) => { - switch (action.type) { - case ETHERPAD_INITIALIZED: - return { - ...state, - initialized: true - }; +ReducerRegistry.register( + 'features/etherpad', + (state = DEFAULT_STATE, action) => { + switch (action.type) { + case ETHERPAD_INITIALIZED: + return { + ...state, + initialized: true + }; - case SET_DOCUMENT_EDITING_STATUS: - return { - ...state, - editing: action.editing - }; + case SET_DOCUMENT_EDITING_STATUS: + return { + ...state, + editing: action.editing + }; - default: - return state; - } -}); + default: + return state; + } + }); diff --git a/react/features/follow-me/constants.js b/react/features/follow-me/constants.js new file mode 100644 index 000000000..34fd83b76 --- /dev/null +++ b/react/features/follow-me/constants.js @@ -0,0 +1,6 @@ +/** + * The (name of the) command which transports the state (represented by + * {State} for the local state at the time of this writing) of a {FollowMe} + * (instance) between participants. + */ +export const FOLLOW_ME_COMMAND = 'follow-me'; diff --git a/react/features/follow-me/index.js b/react/features/follow-me/index.js new file mode 100644 index 000000000..fc9514bb2 --- /dev/null +++ b/react/features/follow-me/index.js @@ -0,0 +1,2 @@ +export * from './middleware'; +export * from './subscriber'; diff --git a/react/features/follow-me/middleware.js b/react/features/follow-me/middleware.js new file mode 100644 index 000000000..0ad0ede60 --- /dev/null +++ b/react/features/follow-me/middleware.js @@ -0,0 +1,162 @@ +// @flow + +import { CONFERENCE_WILL_JOIN } from '../base/conference'; +import { + getParticipantById, + getPinnedParticipant, + pinParticipant +} from '../base/participants'; +import { MiddlewareRegistry } from '../base/redux'; +import { setFilmstripVisible } from '../filmstrip'; +import { setTileView } from '../video-layout'; + +import { FOLLOW_ME_COMMAND } from './constants'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +declare var APP: Object; + +/** + * The timeout after which a follow-me command that has been received will be + * ignored if not consumed. + * + * @type {number} in seconds + * @private + */ +const _FOLLOW_ME_RECEIVED_TIMEOUT = 30; + +/** + * An instance of a timeout used as a workaround when attempting to pin a + * non-existent particapant, which may be caused by participant join information + * not being received yet. + * + * @type {TimeoutID} + */ +let nextOnStageTimeout; + +/** + * A count of how many seconds the nextOnStageTimeout has ticked while waiting + * for a participant to be discovered that should be pinned. This variable + * works in conjunction with {@code _FOLLOW_ME_RECEIVED_TIMEOUT} and + * {@code nextOnStageTimeout}. + * + * @type {number} + */ +let nextOnStageTimer = 0; + +/** + * Represents "Follow Me" feature which enables a moderator to (partially) + * control the user experience/interface (e.g. filmstrip visibility) of (other) + * non-moderator participant. + */ +MiddlewareRegistry.register(store => next => action => { + switch (action.type) { + case CONFERENCE_WILL_JOIN: { + const { conference } = action; + + conference.addCommandListener( + FOLLOW_ME_COMMAND, ({ attributes }, id) => { + _onFollowMeCommand(attributes, id, store); + }); + } + } + + return next(action); +}); + +/** + * Notifies this instance about a "Follow Me" command received by the Jitsi + * conference. + * + * @param {Object} attributes - The attributes carried by the command. + * @param {string} id - The identifier of the participant who issuing the + * command. A notable idiosyncrasy to be mindful of here is that the command + * may be issued by the local participant. + * @param {Object} store - The redux store. Used to calculate and dispatch + * updates. + * @private + * @returns {void} + */ +function _onFollowMeCommand(attributes = {}, id, store) { + const state = store.getState(); + + // We require to know who issued the command because (1) only a + // moderator is allowed to send commands and (2) a command MUST be + // issued by a defined commander. + if (typeof id === 'undefined') { + return; + } + + const participantSendingCommand = getParticipantById(state, id); + + // The Command(s) API will send us our own commands and we don't want + // to act upon them. + if (participantSendingCommand.local) { + return; + } + + if (participantSendingCommand.role !== 'moderator') { + logger.warn('Received follow-me command not from moderator'); + + return; + } + + // XMPP will translate all booleans to strings, so explicitly check against + // the string form of the boolean {@code true}. + store.dispatch(setFilmstripVisible(attributes.filmstripVisible === 'true')); + store.dispatch(setTileView(attributes.tileViewEnabled === 'true')); + + // For now gate etherpad checks behind a web-app check to be extra safe + // against calling a web-app global. + if (typeof APP !== 'undefined' && state['features/etherpad'].initialized) { + const isEtherpadVisible = attributes.sharedDocumentVisible === 'true'; + const documentManager = APP.UI.getSharedDocumentManager(); + + if (documentManager + && isEtherpadVisible !== state['features/etherpad'].editing) { + documentManager.toggleEtherpad(); + } + } + + const pinnedParticipant + = getPinnedParticipant(state, attributes.nextOnStage); + const idOfParticipantToPin = attributes.nextOnStage; + + if (typeof idOfParticipantToPin !== 'undefined' + && (!pinnedParticipant + || idOfParticipantToPin !== pinnedParticipant.id)) { + _pinVideoThumbnailById(store, idOfParticipantToPin); + } else if (typeof idOfParticipantToPin === 'undefined' + && pinnedParticipant) { + store.dispatch(pinParticipant(null)); + } +} + +/** + * Pins the video thumbnail given by clickId. + * + * @param {Object} store - The redux store. + * @param {string} clickId - The identifier of the participant to pin. + * @private + * @returns {void} + */ +function _pinVideoThumbnailById(store, clickId) { + if (getParticipantById(store.getState(), clickId)) { + clearTimeout(nextOnStageTimeout); + nextOnStageTimer = 0; + + store.dispatch(pinParticipant(clickId)); + } else { + nextOnStageTimeout = setTimeout(() => { + if (nextOnStageTimer > _FOLLOW_ME_RECEIVED_TIMEOUT) { + nextOnStageTimer = 0; + + return; + } + + nextOnStageTimer++; + + _pinVideoThumbnailById(store, clickId); + }, 1000); + } +} diff --git a/react/features/follow-me/subscriber.js b/react/features/follow-me/subscriber.js new file mode 100644 index 000000000..b702bffc3 --- /dev/null +++ b/react/features/follow-me/subscriber.js @@ -0,0 +1,110 @@ +// @flow + +import { StateListenerRegistry } from '../base/redux'; +import { getCurrentConference } from '../base/conference'; +import { + getPinnedParticipant, + isLocalParticipantModerator +} from '../base/participants'; + +import { FOLLOW_ME_COMMAND } from './constants'; + +/** + * Subscribes to changes to the Follow Me setting for the local participant to + * notify remote participants of current user interface status. + * + * @param sharedDocumentVisible {Boolean} {true} if the shared document was + * shown (as a result of the toggle) or {false} if it was hidden + */ +StateListenerRegistry.register( + /* selector */ state => state['features/base/conference'].followMeEnabled, + /* listener */ _sendFollowMeCommand); + +/** + * Subscribes to changes to the currently pinned participant in the user + * interface of the local participant. + */ +StateListenerRegistry.register( + /* selector */ state => { + const pinnedParticipant = getPinnedParticipant(state); + + return pinnedParticipant ? pinnedParticipant.id : null; + }, + /* listener */ _sendFollowMeCommand); + +/** + * Subscribes to changes to the shared document (etherpad) visibility in the + * user interface of the local participant. + * + * @param sharedDocumentVisible {Boolean} {true} if the shared document was + * shown (as a result of the toggle) or {false} if it was hidden + */ +StateListenerRegistry.register( + /* selector */ state => state['features/etherpad'].editing, + /* listener */ _sendFollowMeCommand); + +/** + * Subscribes to changes to the filmstrip visibility in the user interface of + * the local participant. + */ +StateListenerRegistry.register( + /* selector */ state => state['features/filmstrip'].visible, + /* listener */ _sendFollowMeCommand); + +/** + * Subscribes to changes to the tile view setting in the user interface of the + * local participant. + */ +StateListenerRegistry.register( + /* selector */ state => state['features/video-layout'].tileViewEnabled, + /* listener */ _sendFollowMeCommand); + +/** + * Private selector for returning state from redux that should be respected by + * other participants while follow me is enabled. + * + * @param {Object} state - The redux state. + * @returns {Object} + */ +function _getFollowMeState(state) { + const pinnedParticipant = getPinnedParticipant(state); + + return { + filmstripVisible: state['features/filmstrip'].visible, + nextOnStage: pinnedParticipant && pinnedParticipant.id, + sharedDocumentVisible: state['features/etherpad'].editing, + tileViewEnabled: state['features/video-layout'].tileViewEnabled + }; +} + +/** + * Sends the follow-me command, when a local property change occurs. + * + * @param {*} newSelectedValue - The changed selected value from the selector. + * @param {Object} store - The redux store. + * @private + * @returns {void} + */ +function _sendFollowMeCommand( + newSelectedValue, store) { // eslint-disable-line no-unused-vars + const state = store.getState(); + const conference = getCurrentConference(state); + + if (!conference || !state['features/base/conference'].followMeEnabled) { + return; + } + + // Only a moderator is allowed to send commands. + if (!isLocalParticipantModerator(state)) { + return; + } + + // XXX The "Follow Me" command represents a snapshot of all states + // which are to be followed so don't forget to removeCommand before + // sendCommand! + conference.removeCommand(FOLLOW_ME_COMMAND); + conference.sendCommandOnce( + FOLLOW_ME_COMMAND, + { attributes: _getFollowMeState(state) } + ); +} diff --git a/react/features/video-layout/middleware.web.js b/react/features/video-layout/middleware.web.js index a7f07b69c..b39a02dc2 100644 --- a/react/features/video-layout/middleware.web.js +++ b/react/features/video-layout/middleware.web.js @@ -1,7 +1,6 @@ // @flow import VideoLayout from '../../../modules/UI/videolayout/VideoLayout.js'; -import UIEvents from '../../../service/UI/UIEvents'; import { CONFERENCE_JOINED, CONFERENCE_WILL_LEAVE } from '../base/conference'; import { @@ -16,7 +15,6 @@ import { MiddlewareRegistry } from '../base/redux'; import { TRACK_ADDED } from '../base/tracks'; import { SET_FILMSTRIP_VISIBLE } from '../filmstrip'; -import { SET_TILE_VIEW } from './actionTypes'; import './middleware.any'; declare var APP: Object; @@ -73,22 +71,13 @@ MiddlewareRegistry.register(store => next => action => { case PIN_PARTICIPANT: VideoLayout.onPinChange(action.participant.id); - APP.UI.emitEvent( - UIEvents.PINNED_ENDPOINT, - action.participant.id, - Boolean(action.participant.id)); break; case SET_FILMSTRIP_VISIBLE: VideoLayout.resizeVideoArea(true, false); - APP.UI.emitEvent(UIEvents.TOGGLED_FILMSTRIP, action.visible); APP.API.notifyFilmstripDisplayChanged(action.visible); break; - case SET_TILE_VIEW: - APP.UI.emitEvent(UIEvents.TOGGLED_TILE_VIEW, action.enabled); - break; - case TRACK_ADDED: if (!action.track.local) { VideoLayout.onRemoteStreamAdded(action.track.jitsiTrack); diff --git a/react/features/video-layout/reducer.js b/react/features/video-layout/reducer.js index 3588582cc..b05222e90 100644 --- a/react/features/video-layout/reducer.js +++ b/react/features/video-layout/reducer.js @@ -12,6 +12,18 @@ const DEFAULT_STATE = { screenShares: [] }; +const DEFAULT_STATE = { + + /** + * The indicator which determines whether the video layout should display + * video thumbnails in a tiled layout. + * + * @public + * @type {boolean} + */ + tileViewEnabled: false +}; + const STORE_NAME = 'features/video-layout'; PersistenceRegistry.register(STORE_NAME, { diff --git a/service/UI/UIEvents.js b/service/UI/UIEvents.js index a153f3188..d4167e920 100644 --- a/service/UI/UIEvents.js +++ b/service/UI/UIEvents.js @@ -1,6 +1,5 @@ export default { NICKNAME_CHANGED: 'UI.nickname_changed', - PINNED_ENDPOINT: 'UI.pinned_endpoint', /** * Notifies that local user changed email. @@ -43,28 +42,12 @@ export default { */ TOGGLE_FILMSTRIP: 'UI.toggle_filmstrip', - /** - * Notifies that the filmstrip was (actually) toggled. The event supplies a - * {Boolean} (primitive) value indicating the visibility of the filmstrip - * after the toggling (at the time of the event emission). - * - * @see {TOGGLE_FILMSTRIP} - */ - TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip', TOGGLE_SCREENSHARING: 'UI.toggle_screensharing', - TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document', - TOGGLED_TILE_VIEW: 'UI.toggled_tile_view', HANGUP: 'UI.hangup', LOGOUT: 'UI.logout', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed', - /** - * Notifies interested listeners that the follow-me feature is enabled or - * disabled. - */ - FOLLOW_ME_ENABLED: 'UI.follow_me_enabled', - /** * Notifies that flipX property of the local video is changed. */