2019-04-17 15:05:32 +00:00
|
|
|
// @flow
|
|
|
|
|
2022-04-05 13:00:32 +00:00
|
|
|
import _ from 'lodash';
|
|
|
|
|
2022-04-06 02:13:39 +00:00
|
|
|
import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
|
2019-04-17 15:05:32 +00:00
|
|
|
import {
|
|
|
|
getParticipantById,
|
|
|
|
getPinnedParticipant,
|
2019-04-26 18:11:53 +00:00
|
|
|
PARTICIPANT_LEFT,
|
2019-04-17 15:05:32 +00:00
|
|
|
pinParticipant
|
|
|
|
} from '../base/participants';
|
|
|
|
import { MiddlewareRegistry } from '../base/redux';
|
2022-09-15 07:57:48 +00:00
|
|
|
import { updateSettings } from '../base/settings';
|
2019-04-17 15:05:32 +00:00
|
|
|
import { setFilmstripVisible } from '../filmstrip';
|
2022-09-15 07:57:48 +00:00
|
|
|
import { addStageParticipant } from '../filmstrip/actions.web';
|
2019-04-17 15:05:32 +00:00
|
|
|
import { setTileView } from '../video-layout';
|
|
|
|
|
2020-05-20 10:57:03 +00:00
|
|
|
import {
|
|
|
|
setFollowMeModerator,
|
|
|
|
setFollowMeState
|
|
|
|
} from './actions';
|
2019-04-17 15:05:32 +00:00
|
|
|
import { FOLLOW_ME_COMMAND } from './constants';
|
2020-04-30 21:25:34 +00:00
|
|
|
import { isFollowMeActive } from './functions';
|
2019-08-21 14:50:00 +00:00
|
|
|
import logger from './logger';
|
2019-04-17 15:05:32 +00:00
|
|
|
|
2020-06-04 14:09:13 +00:00
|
|
|
import './subscriber';
|
|
|
|
|
2019-04-17 15:05:32 +00:00
|
|
|
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)
|
2021-11-04 21:10:43 +00:00
|
|
|
* control the user experience/interface (e.g. Filmstrip visibility) of (other)
|
2019-04-17 15:05:32 +00:00
|
|
|
* non-moderator participant.
|
|
|
|
*/
|
|
|
|
MiddlewareRegistry.register(store => next => action => {
|
|
|
|
switch (action.type) {
|
2022-04-06 02:13:39 +00:00
|
|
|
case CONFERENCE_JOIN_IN_PROGRESS: {
|
2019-04-17 15:05:32 +00:00
|
|
|
const { conference } = action;
|
|
|
|
|
|
|
|
conference.addCommandListener(
|
|
|
|
FOLLOW_ME_COMMAND, ({ attributes }, id) => {
|
|
|
|
_onFollowMeCommand(attributes, id, store);
|
|
|
|
});
|
2019-04-26 18:11:53 +00:00
|
|
|
break;
|
2019-04-17 15:05:32 +00:00
|
|
|
}
|
2019-04-26 18:11:53 +00:00
|
|
|
case PARTICIPANT_LEFT:
|
|
|
|
if (store.getState()['features/follow-me'].moderator === action.participant.id) {
|
|
|
|
store.dispatch(setFollowMeModerator());
|
|
|
|
}
|
|
|
|
break;
|
2019-04-17 15:05:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
2022-02-17 22:25:31 +00:00
|
|
|
if (participantSendingCommand) {
|
|
|
|
// The Command(s) API will send us our own commands and we don't want
|
|
|
|
// to act upon them.
|
|
|
|
if (participantSendingCommand.local) {
|
|
|
|
return;
|
|
|
|
}
|
2019-04-17 15:05:32 +00:00
|
|
|
|
2022-02-17 22:25:31 +00:00
|
|
|
if (participantSendingCommand.role !== 'moderator') {
|
|
|
|
logger.warn('Received follow-me command not from moderator');
|
2019-04-17 15:05:32 +00:00
|
|
|
|
2022-02-17 22:25:31 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// This is the case of jibri receiving commands from a hidden participant.
|
|
|
|
const { iAmRecorder } = state['features/base/config'];
|
|
|
|
const { conference } = state['features/base/conference'];
|
|
|
|
|
|
|
|
// As this participant is not stored in redux store we do the checks on the JitsiParticipant from lib-jitsi-meet
|
|
|
|
const participant = conference.getParticipantById(id);
|
|
|
|
|
|
|
|
if (!iAmRecorder || !participant || participant.getRole() !== 'moderator'
|
|
|
|
|| !participant.isHiddenFromRecorder()) {
|
|
|
|
logger.warn('Something went wrong with follow-me command');
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
2019-04-17 15:05:32 +00:00
|
|
|
}
|
|
|
|
|
2020-04-30 21:25:34 +00:00
|
|
|
if (!isFollowMeActive(state)) {
|
2019-04-26 18:11:53 +00:00
|
|
|
store.dispatch(setFollowMeModerator(id));
|
|
|
|
}
|
|
|
|
|
|
|
|
// just a command that follow me was turned off
|
|
|
|
if (attributes.off) {
|
|
|
|
store.dispatch(setFollowMeModerator());
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const oldState = state['features/follow-me'].state || {};
|
|
|
|
|
|
|
|
store.dispatch(setFollowMeState(attributes));
|
|
|
|
|
2019-04-17 15:05:32 +00:00
|
|
|
// XMPP will translate all booleans to strings, so explicitly check against
|
|
|
|
// the string form of the boolean {@code true}.
|
2019-04-26 18:11:53 +00:00
|
|
|
if (oldState.filmstripVisible !== attributes.filmstripVisible) {
|
|
|
|
store.dispatch(setFilmstripVisible(attributes.filmstripVisible === 'true'));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (oldState.tileViewEnabled !== attributes.tileViewEnabled) {
|
|
|
|
store.dispatch(setTileView(attributes.tileViewEnabled === 'true'));
|
|
|
|
}
|
2019-04-17 15:05:32 +00:00
|
|
|
|
|
|
|
// For now gate etherpad checks behind a web-app check to be extra safe
|
|
|
|
// against calling a web-app global.
|
2019-04-26 18:11:53 +00:00
|
|
|
if (typeof APP !== 'undefined'
|
|
|
|
&& oldState.sharedDocumentVisible !== attributes.sharedDocumentVisible) {
|
2019-04-17 15:05:32 +00:00
|
|
|
const isEtherpadVisible = attributes.sharedDocumentVisible === 'true';
|
|
|
|
const documentManager = APP.UI.getSharedDocumentManager();
|
|
|
|
|
|
|
|
if (documentManager
|
|
|
|
&& isEtherpadVisible !== state['features/etherpad'].editing) {
|
|
|
|
documentManager.toggleEtherpad();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-23 13:12:25 +00:00
|
|
|
const pinnedParticipant = getPinnedParticipant(state);
|
2019-04-17 15:05:32 +00:00
|
|
|
const idOfParticipantToPin = attributes.nextOnStage;
|
|
|
|
|
|
|
|
if (typeof idOfParticipantToPin !== 'undefined'
|
2020-07-23 13:12:25 +00:00
|
|
|
&& (!pinnedParticipant || idOfParticipantToPin !== pinnedParticipant.id)
|
2019-04-26 18:11:53 +00:00
|
|
|
&& oldState.nextOnStage !== attributes.nextOnStage) {
|
2019-04-17 15:05:32 +00:00
|
|
|
_pinVideoThumbnailById(store, idOfParticipantToPin);
|
2020-07-23 13:12:25 +00:00
|
|
|
} else if (typeof idOfParticipantToPin === 'undefined' && pinnedParticipant) {
|
2019-04-17 15:05:32 +00:00
|
|
|
store.dispatch(pinParticipant(null));
|
|
|
|
}
|
2022-04-05 13:00:32 +00:00
|
|
|
|
|
|
|
if (attributes.pinnedStageParticipants !== undefined) {
|
|
|
|
const stageParticipants = JSON.parse(attributes.pinnedStageParticipants);
|
|
|
|
|
|
|
|
if (!_.isEqual(stageParticipants, oldState.pinnedStageParticipants)) {
|
|
|
|
stageParticipants.forEach(p => store.dispatch(addStageParticipant(p.participantId, true)));
|
|
|
|
}
|
|
|
|
}
|
2022-04-07 08:31:53 +00:00
|
|
|
|
|
|
|
if (attributes.maxStageParticipants !== undefined
|
|
|
|
&& oldState.maxStageParticipants !== attributes.maxStageParticipants) {
|
2022-09-15 07:57:48 +00:00
|
|
|
store.dispatch(updateSettings({
|
|
|
|
maxStageParticipants: Number(attributes.maxStageParticipants)
|
|
|
|
}));
|
2022-04-07 08:31:53 +00:00
|
|
|
}
|
2019-04-17 15:05:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
}
|