226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
import _ from 'lodash';
|
|
|
|
import { IStore } from '../app/types';
|
|
import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
|
|
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
|
|
import { pinParticipant } from '../base/participants/actions';
|
|
import { getParticipantById, getPinnedParticipant } from '../base/participants/functions';
|
|
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
|
import { updateSettings } from '../base/settings/actions';
|
|
// eslint-disable-next-line lines-around-comment
|
|
// @ts-ignore
|
|
import { addStageParticipant, setFilmstripVisible } from '../filmstrip/actions';
|
|
import { setTileView } from '../video-layout/actions.any';
|
|
|
|
import {
|
|
setFollowMeModerator,
|
|
setFollowMeState
|
|
} from './actions';
|
|
import { FOLLOW_ME_COMMAND } from './constants';
|
|
import { isFollowMeActive } from './functions';
|
|
import logger from './logger';
|
|
|
|
import './subscriber';
|
|
|
|
/**
|
|
* 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: number;
|
|
|
|
/**
|
|
* 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_JOIN_IN_PROGRESS: {
|
|
const { conference } = action;
|
|
|
|
conference.addCommandListener(
|
|
FOLLOW_ME_COMMAND, ({ attributes }: any, id: string) => {
|
|
_onFollowMeCommand(attributes, id, store);
|
|
});
|
|
break;
|
|
}
|
|
case PARTICIPANT_LEFT:
|
|
if (store.getState()['features/follow-me'].moderator === action.participant.id) {
|
|
store.dispatch(setFollowMeModerator());
|
|
}
|
|
break;
|
|
}
|
|
|
|
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: any = {}, id: string, store: IStore) {
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
if (participantSendingCommand.role !== 'moderator') {
|
|
logger.warn('Received follow-me command not from moderator');
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!isFollowMeActive(state)) {
|
|
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));
|
|
|
|
// XMPP will translate all booleans to strings, so explicitly check against
|
|
// the string form of the boolean {@code true}.
|
|
if (oldState.filmstripVisible !== attributes.filmstripVisible) {
|
|
store.dispatch(setFilmstripVisible(attributes.filmstripVisible === 'true'));
|
|
}
|
|
|
|
if (oldState.tileViewEnabled !== attributes.tileViewEnabled) {
|
|
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'
|
|
&& oldState.sharedDocumentVisible !== attributes.sharedDocumentVisible) {
|
|
const isEtherpadVisible = attributes.sharedDocumentVisible === 'true';
|
|
const documentManager = APP.UI.getSharedDocumentManager();
|
|
|
|
if (documentManager
|
|
&& isEtherpadVisible !== state['features/etherpad'].editing) {
|
|
documentManager.toggleEtherpad();
|
|
}
|
|
}
|
|
|
|
const pinnedParticipant = getPinnedParticipant(state);
|
|
const idOfParticipantToPin = attributes.nextOnStage;
|
|
|
|
if (typeof idOfParticipantToPin !== 'undefined'
|
|
&& (!pinnedParticipant || idOfParticipantToPin !== pinnedParticipant.id)
|
|
&& oldState.nextOnStage !== attributes.nextOnStage) {
|
|
_pinVideoThumbnailById(store, idOfParticipantToPin);
|
|
} else if (typeof idOfParticipantToPin === 'undefined' && pinnedParticipant) {
|
|
store.dispatch(pinParticipant(null));
|
|
}
|
|
|
|
if (attributes.pinnedStageParticipants !== undefined) {
|
|
const stageParticipants = JSON.parse(attributes.pinnedStageParticipants);
|
|
|
|
if (!_.isEqual(stageParticipants, oldState.pinnedStageParticipants)) {
|
|
stageParticipants.forEach((p: { participantId: string; }) =>
|
|
store.dispatch(addStageParticipant(p.participantId, true)));
|
|
}
|
|
}
|
|
|
|
if (attributes.maxStageParticipants !== undefined
|
|
&& oldState.maxStageParticipants !== attributes.maxStageParticipants) {
|
|
store.dispatch(updateSettings({
|
|
maxStageParticipants: Number(attributes.maxStageParticipants)
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: IStore, clickId: string) {
|
|
if (getParticipantById(store.getState(), clickId)) {
|
|
clearTimeout(nextOnStageTimeout);
|
|
nextOnStageTimer = 0;
|
|
|
|
store.dispatch(pinParticipant(clickId));
|
|
} else {
|
|
nextOnStageTimeout = window.setTimeout(() => {
|
|
if (nextOnStageTimer > _FOLLOW_ME_RECEIVED_TIMEOUT) {
|
|
nextOnStageTimer = 0;
|
|
|
|
return;
|
|
}
|
|
|
|
nextOnStageTimer++;
|
|
|
|
_pinVideoThumbnailById(store, clickId);
|
|
}, 1000);
|
|
}
|
|
}
|