This commit is contained in:
21
react/features/follow-me/actionTypes.ts
Normal file
21
react/features/follow-me/actionTypes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The id of the Follow Me moderator.
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME_MODERATOR,
|
||||
* id: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME_MODERATOR = 'SET_FOLLOW_ME_MODERATOR';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the current known state of the
|
||||
* Follow Me feature.
|
||||
*
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME_STATE,
|
||||
* state: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME_STATE = 'SET_FOLLOW_ME_STATE';
|
||||
39
react/features/follow-me/actions.ts
Normal file
39
react/features/follow-me/actions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
SET_FOLLOW_ME_MODERATOR,
|
||||
SET_FOLLOW_ME_STATE
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Sets the current moderator id or clears it.
|
||||
*
|
||||
* @param {?string} id - The Follow Me moderator participant id.
|
||||
* @param {?boolean} forRecorder - Whether this is command only for recorder.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME_MODERATOR,
|
||||
* id: string,
|
||||
* forRecorder: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMeModerator(id?: string, forRecorder?: boolean) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME_MODERATOR,
|
||||
id,
|
||||
forRecorder
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Follow Me feature state.
|
||||
*
|
||||
* @param {?Object} state - The current state.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME_STATE,
|
||||
* state: Object
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMeState(state?: Object) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME_STATE,
|
||||
state
|
||||
};
|
||||
}
|
||||
6
react/features/follow-me/constants.ts
Normal file
6
react/features/follow-me/constants.ts
Normal file
@@ -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';
|
||||
28
react/features/follow-me/functions.ts
Normal file
28
react/features/follow-me/functions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { toState } from '../base/redux/functions';
|
||||
|
||||
/**
|
||||
* Returns true if follow me is active and false otherwise.
|
||||
*
|
||||
* @param {Object|Function} stateful - Object or function that can be resolved
|
||||
* to the Redux state.
|
||||
* @returns {boolean} - True if follow me is active and false otherwise.
|
||||
*/
|
||||
export function isFollowMeActive(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return Boolean(state['features/follow-me'].moderator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if follow me is active only for the recorder and false otherwise.
|
||||
*
|
||||
* @param {Object|Function} stateful - Object or function that can be resolved
|
||||
* to the Redux state.
|
||||
* @returns {boolean} - True if follow me is active and false otherwise.
|
||||
*/
|
||||
export function isFollowMeRecorderActive(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return Boolean(state['features/follow-me'].recorder);
|
||||
}
|
||||
3
react/features/follow-me/logger.ts
Normal file
3
react/features/follow-me/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/follow-me');
|
||||
238
react/features/follow-me/middleware.ts
Normal file
238
react/features/follow-me/middleware.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { differenceWith, isEqual } from 'lodash-es';
|
||||
|
||||
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';
|
||||
import { addStageParticipant, removeStageParticipant, 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, attributes.recorder));
|
||||
}
|
||||
|
||||
// just a command that follow me was turned off
|
||||
if (attributes.off) {
|
||||
store.dispatch(setFollowMeModerator());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// when recorder flag is on, follow me is handled only on recorder side
|
||||
if (attributes.recorder && !store.getState()['features/base/config'].iAmRecorder) {
|
||||
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);
|
||||
let oldStageParticipants = [];
|
||||
|
||||
if (oldState.pinnedStageParticipants !== undefined) {
|
||||
oldStageParticipants = JSON.parse(oldState.pinnedStageParticipants);
|
||||
}
|
||||
|
||||
if (!isEqual(stageParticipants, oldStageParticipants)) {
|
||||
const toRemove = differenceWith(oldStageParticipants, stageParticipants, isEqual);
|
||||
const toAdd = differenceWith(stageParticipants, oldStageParticipants, isEqual);
|
||||
|
||||
toRemove.forEach((p: { participantId: string; }) =>
|
||||
store.dispatch(removeStageParticipant(p.participantId)));
|
||||
toAdd.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);
|
||||
}
|
||||
}
|
||||
44
react/features/follow-me/reducer.ts
Normal file
44
react/features/follow-me/reducer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { set } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
SET_FOLLOW_ME_MODERATOR,
|
||||
SET_FOLLOW_ME_STATE
|
||||
} from './actionTypes';
|
||||
|
||||
export interface IFollowMeState {
|
||||
moderator?: string;
|
||||
recorder?: boolean;
|
||||
state?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for actions that contain the Follow Me feature active state, so that it can be stored.
|
||||
*/
|
||||
ReducerRegistry.register<IFollowMeState>(
|
||||
'features/follow-me',
|
||||
(state = {}, action): IFollowMeState => {
|
||||
switch (action.type) {
|
||||
|
||||
case SET_FOLLOW_ME_MODERATOR: {
|
||||
let newState = set(state, 'moderator', action.id);
|
||||
|
||||
if (action.id) {
|
||||
newState = set(newState, 'recorder', action.forRecorder);
|
||||
} else {
|
||||
// clear the state if feature becomes disabled
|
||||
newState = set(newState, 'state', undefined);
|
||||
newState = set(newState, 'recorder', undefined);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
case SET_FOLLOW_ME_STATE: {
|
||||
return set(state, 'state', action.state);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
141
react/features/follow-me/subscriber.ts
Normal file
141
react/features/follow-me/subscriber.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import {
|
||||
getPinnedParticipant,
|
||||
isLocalParticipantModerator
|
||||
} from '../base/participants/functions';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { getPinnedActiveParticipants, isStageFilmstripEnabled } from '../filmstrip/functions';
|
||||
import { shouldDisplayTileView } from '../video-layout/functions';
|
||||
|
||||
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.
|
||||
* Changing newSelectedValue param to off, when feature is turned of so we can
|
||||
* notify all listeners.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/conference'].followMeEnabled,
|
||||
/* listener */ (newSelectedValue, store) => _sendFollowMeCommand(newSelectedValue || 'off', store));
|
||||
|
||||
/**
|
||||
* 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 stage filmstrip participants.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ getPinnedActiveParticipants,
|
||||
/* listener */ _sendFollowMeCommand,
|
||||
{
|
||||
deepEquals: true
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Subscribes to changes to the max number of stage participants setting.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/settings'].maxStageParticipants,
|
||||
/* 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: IReduxState) {
|
||||
const pinnedParticipant = getPinnedParticipant(state);
|
||||
const stageFilmstrip = isStageFilmstripEnabled(state);
|
||||
|
||||
return {
|
||||
recorder: state['features/base/conference'].followMeRecorderEnabled,
|
||||
filmstripVisible: state['features/filmstrip'].visible,
|
||||
maxStageParticipants: stageFilmstrip ? state['features/base/settings'].maxStageParticipants : undefined,
|
||||
nextOnStage: pinnedParticipant?.id,
|
||||
pinnedStageParticipants: stageFilmstrip ? JSON.stringify(getPinnedActiveParticipants(state)) : undefined,
|
||||
sharedDocumentVisible: state['features/etherpad'].editing,
|
||||
tileViewEnabled: shouldDisplayTileView(state)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: any, store: IStore) {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (!conference) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only a moderator is allowed to send commands.
|
||||
if (!isLocalParticipantModerator(state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSelectedValue === 'off') {
|
||||
// if the change is to off, local user turned off follow me and
|
||||
// we want to signal this
|
||||
|
||||
conference.sendCommandOnce(
|
||||
FOLLOW_ME_COMMAND,
|
||||
{ attributes: { off: true } }
|
||||
);
|
||||
|
||||
return;
|
||||
} else if (!state['features/base/conference'].followMeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
conference.sendCommand(
|
||||
FOLLOW_ME_COMMAND,
|
||||
{ attributes: _getFollowMeState(state) }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user