init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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';

View 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
};
}

View 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';

View 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);
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/follow-me');

View 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);
}
}

View 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;
});

View 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) }
);
}