From 5e152b4a42f9d8e705be795e869dffe87badd881 Mon Sep 17 00:00:00 2001 From: dimitardelchev93 <43634401+dimitardelchev93@users.noreply.github.com> Date: Fri, 10 Sep 2021 01:46:41 +0300 Subject: [PATCH] feat: Additional setting to order participants in speaker stats (#9751) * Additional setting to order participants in speaker stats #9742 * Setting to order speaker stats optimisations #9742 * Lint fixes #9742 * Replace APP references #9742 * Lint fixes #9742 * Setting to order speaker stats optimisations 2 #9742 * Lint fixes #9742 * Remove unnecessary param #9742 * Add more speaker-stats reducer _updateStats docs #9742 --- config.js | 7 + react/features/app/middlewares.any.js | 1 + react/features/app/reducers.any.js | 1 + react/features/base/config/configWhitelist.js | 1 + react/features/base/util/helpers.js | 16 ++ react/features/speaker-stats/actionTypes.js | 39 ++++ react/features/speaker-stats/actions.js | 58 ++++++ .../speaker-stats/components/SpeakerStats.js | 104 ++++------- react/features/speaker-stats/constants.js | 1 + react/features/speaker-stats/functions.js | 168 ++++++++++++++++++ react/features/speaker-stats/middleware.js | 49 +++++ react/features/speaker-stats/reducer.js | 119 +++++++++++++ 12 files changed, 497 insertions(+), 67 deletions(-) create mode 100644 react/features/speaker-stats/actionTypes.js create mode 100644 react/features/speaker-stats/actions.js create mode 100644 react/features/speaker-stats/constants.js create mode 100644 react/features/speaker-stats/middleware.js create mode 100644 react/features/speaker-stats/reducer.js diff --git a/config.js b/config.js index 0da882750..4b075768c 100644 --- a/config.js +++ b/config.js @@ -150,6 +150,13 @@ var config = { // Specifies whether there will be a search field in speaker stats or not // disableSpeakerStatsSearch: false, + // Specifies whether participants in speaker stats should be ordered or not, and with what priority + // speakerStatsOrder: [ + // 'role', <- Moderators on top + // 'name', <- Alphabetically by name + // 'hasLeft', <- The ones that have left in the bottom + // ] <- the order of the array elements determines priority + // How many participants while in the tile view mode, before the receiving video quality is reduced from HD to SD. // Use -1 to disable. // maxFullResolutionParticipants: 2, diff --git a/react/features/app/middlewares.any.js b/react/features/app/middlewares.any.js index 037f95113..106572154 100644 --- a/react/features/app/middlewares.any.js +++ b/react/features/app/middlewares.any.js @@ -42,6 +42,7 @@ import '../recording/middleware'; import '../rejoin/middleware'; import '../room-lock/middleware'; import '../rtcstats/middleware'; +import '../speaker-stats/middleware'; import '../subtitles/middleware'; import '../toolbox/middleware'; import '../transcribing/middleware'; diff --git a/react/features/app/reducers.any.js b/react/features/app/reducers.any.js index 1a46118f3..9eacb65ca 100644 --- a/react/features/app/reducers.any.js +++ b/react/features/app/reducers.any.js @@ -46,6 +46,7 @@ import '../reactions/reducer'; import '../recent-list/reducer'; import '../recording/reducer'; import '../settings/reducer'; +import '../speaker-stats/reducer'; import '../subtitles/reducer'; import '../screen-share/reducer'; import '../toolbox/reducer'; diff --git a/react/features/base/config/configWhitelist.js b/react/features/base/config/configWhitelist.js index 201de910f..056b1450a 100644 --- a/react/features/base/config/configWhitelist.js +++ b/react/features/base/config/configWhitelist.js @@ -106,6 +106,7 @@ export default [ 'disableShowMoreStats', 'disableRemoveRaisedHandOnFocus', 'disableSpeakerStatsSearch', + 'speakerStatsOrder', 'disableSimulcast', 'disableThirdPartyRequests', 'disableTileView', diff --git a/react/features/base/util/helpers.js b/react/features/base/util/helpers.js index bac6a3529..a01cd8780 100644 --- a/react/features/base/util/helpers.js +++ b/react/features/base/util/helpers.js @@ -185,3 +185,19 @@ function parseShorthandColor(color) { return [ r, g, b ]; } + +/** + * Sorts an object by a sort function, same functionality as array.sort(). + * + * @param {Object} object - The data object. + * @param {Function} callback - The sort function. + * @returns {void} + */ +export function objectSort(object: Object, callback: Function) { + return Object.entries(object) + .sort(([ , a ], [ , b ]) => callback(a, b)) + .reduce((row, [ key, value ]) => { + return { ...row, + [key]: value }; + }, {}); +} diff --git a/react/features/speaker-stats/actionTypes.js b/react/features/speaker-stats/actionTypes.js new file mode 100644 index 000000000..9867c9375 --- /dev/null +++ b/react/features/speaker-stats/actionTypes.js @@ -0,0 +1,39 @@ +// @flow + +/** + * Action type to start search. + * + * { + * type: INIT_SEARCH + * } + */ +export const INIT_SEARCH = 'INIT_SEARCH'; + +/** + * Action type to start stats retrieval. + * + * { + * type: INIT_UPDATE_STATS, + * getSpeakerStats: Function + * } + */ +export const INIT_UPDATE_STATS = 'INIT_UPDATE_STATS'; + +/** + * Action type to update stats. + * + * { + * type: UPDATE_STATS, + * stats: Object + * } + */ +export const UPDATE_STATS = 'UPDATE_STATS'; + +/** + * Action type to initiate reordering of the stats. + * + * { + * type: INIT_REORDER_STATS + * } + */ +export const INIT_REORDER_STATS = 'INIT_REORDER_STATS'; diff --git a/react/features/speaker-stats/actions.js b/react/features/speaker-stats/actions.js new file mode 100644 index 000000000..381d5da62 --- /dev/null +++ b/react/features/speaker-stats/actions.js @@ -0,0 +1,58 @@ +// @flow + +import { + INIT_SEARCH, + INIT_UPDATE_STATS, + UPDATE_STATS, + INIT_REORDER_STATS +} from './actionTypes'; + +/** + * Starts a search by criteria. + * + * @param {string} criteria - The search criteria. + * @returns {Object} + */ +export function initSearch(criteria: string) { + return { + type: INIT_SEARCH, + criteria + }; +} + +/** + * Gets the new stats and triggers update. + * + * @param {Function} getSpeakerStats - Function to get the speaker stats. + * @returns {Object} + */ +export function initUpdateStats(getSpeakerStats: Function) { + return { + type: INIT_UPDATE_STATS, + getSpeakerStats + }; +} + +/** + * Updates the stats with new stats. + * + * @param {Object} stats - The new stats. + * @returns {Object} + */ +export function updateStats(stats: Object) { + return { + type: UPDATE_STATS, + stats + }; +} + +/** + * Initiates reordering of the stats. + * + * @returns {Object} + */ +export function initReorderStats() { + return { + type: INIT_REORDER_STATS + }; +} diff --git a/react/features/speaker-stats/components/SpeakerStats.js b/react/features/speaker-stats/components/SpeakerStats.js index 044b527ab..73f75b926 100644 --- a/react/features/speaker-stats/components/SpeakerStats.js +++ b/react/features/speaker-stats/components/SpeakerStats.js @@ -1,12 +1,16 @@ // @flow import React, { Component } from 'react'; +import type { Dispatch } from 'redux'; import { Dialog } from '../../base/dialog'; import { translate } from '../../base/i18n'; import { getLocalParticipant } from '../../base/participants'; import { connect } from '../../base/redux'; import { escapeRegexp } from '../../base/util'; +import { initUpdateStats, initSearch } from '../actions'; +import { SPEAKER_STATS_RELOAD_INTERVAL } from '../constants'; +import { getSpeakerStats, getSearchCriteria } from '../functions'; import SpeakerStatsItem from './SpeakerStatsItem'; import SpeakerStatsLabels from './SpeakerStatsLabels'; @@ -24,39 +28,38 @@ type Props = { */ _localDisplayName: string, + /** + * The speaker paricipant stats. + */ + _stats: Object, + + /** + * The search criteria. + */ + _criteria: string, + /** * The JitsiConference from which stats will be pulled. */ conference: Object, + /** + * Redux store dispatch method. + */ + dispatch: Dispatch, + /** * The function to translate human-readable text. */ t: Function }; -/** - * The type of the React {@code Component} state of {@link SpeakerStats}. - */ -type State = { - - /** - * The stats summary provided by the JitsiConference. - */ - stats: Object, - - /** - * The search input criteria. - */ - criteria: string, -}; - /** * React component for displaying a list of speaker stats. * * @extends Component */ -class SpeakerStats extends Component { +class SpeakerStats extends Component { _updateInterval: IntervalID; /** @@ -68,14 +71,11 @@ class SpeakerStats extends Component { constructor(props) { super(props); - this.state = { - stats: this._getSpeakerStats(), - criteria: '' - }; - // Bind event handlers so they are only bound once per instance. this._updateStats = this._updateStats.bind(this); this._onSearch = this._onSearch.bind(this); + + this._updateStats(); } /** @@ -84,7 +84,7 @@ class SpeakerStats extends Component { * @inheritdoc */ componentDidMount() { - this._updateInterval = setInterval(this._updateStats, 1000); + this._updateInterval = setInterval(() => this._updateStats(), SPEAKER_STATS_RELOAD_INTERVAL); } /** @@ -104,14 +104,14 @@ class SpeakerStats extends Component { * @returns {ReactElement} */ render() { - const userIds = Object.keys(this.state.stats); + const userIds = Object.keys(this.props._stats); const items = userIds.map(userId => this._createStatsItem(userId)); return ( + titleKey = 'speakerStats.speakerStats'>
@@ -121,32 +121,6 @@ class SpeakerStats extends Component { ); } - /** - * Update the internal state with the latest speaker stats. - * - * @returns {void} - * @private - */ - _getSpeakerStats() { - const stats = { ...this.props.conference.getSpeakerStats() }; - - if (this.state?.criteria) { - const searchRegex = new RegExp(this.state.criteria, 'gi'); - - for (const id in stats) { - if (stats[id].hasOwnProperty('_isLocalStats')) { - const name = stats[id].isLocalStats() ? this.props._localDisplayName : stats[id].getDisplayName(); - - if (!name || !name.match(searchRegex)) { - delete stats[id]; - } - } - } - } - - return stats; - } - /** * Create a SpeakerStatsItem instance for the passed in user id. * @@ -156,9 +130,9 @@ class SpeakerStats extends Component { * @private */ _createStatsItem(userId) { - const statsModel = this.state.stats[userId]; + const statsModel = this.props._stats[userId]; - if (!statsModel) { + if (!statsModel || statsModel.hidden) { return null; } @@ -177,7 +151,7 @@ class SpeakerStats extends Component { = displayName ? `${displayName} (${meString})` : meString; } else { displayName - = this.state.stats[userId].getDisplayName() + = this.props._stats[userId].getDisplayName() || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME; } @@ -201,10 +175,7 @@ class SpeakerStats extends Component { * @protected */ _onSearch(criteria = '') { - this.setState({ - ...this.state, - criteria: escapeRegexp(criteria) - }); + this.props.dispatch(initSearch(escapeRegexp(criteria))); } _updateStats: () => void; @@ -216,12 +187,7 @@ class SpeakerStats extends Component { * @private */ _updateStats() { - const stats = this._getSpeakerStats(); - - this.setState({ - ...this.state, - stats - }); + this.props.dispatch(initUpdateStats(() => this.props.conference.getSpeakerStats())); } } @@ -231,7 +197,9 @@ class SpeakerStats extends Component { * @param {Object} state - The redux state. * @private * @returns {{ - * _localDisplayName: ?string + * _localDisplayName: ?string, + * _stats: Object, + * _criteria: string, * }} */ function _mapStateToProps(state) { @@ -244,7 +212,9 @@ function _mapStateToProps(state) { * @private * @type {string|undefined} */ - _localDisplayName: localParticipant && localParticipant.name + _localDisplayName: localParticipant && localParticipant.name, + _stats: getSpeakerStats(state), + _criteria: getSearchCriteria(state) }; } diff --git a/react/features/speaker-stats/constants.js b/react/features/speaker-stats/constants.js new file mode 100644 index 000000000..94a9143cb --- /dev/null +++ b/react/features/speaker-stats/constants.js @@ -0,0 +1 @@ +export const SPEAKER_STATS_RELOAD_INTERVAL = 1000; diff --git a/react/features/speaker-stats/functions.js b/react/features/speaker-stats/functions.js index 6239b0776..413cce0af 100644 --- a/react/features/speaker-stats/functions.js +++ b/react/features/speaker-stats/functions.js @@ -1,5 +1,10 @@ // @flow +import _ from 'lodash'; + +import { getLocalParticipant, getParticipantById, PARTICIPANT_ROLE } from '../base/participants'; +import { objectSort } from '../base/util'; + /** * Checks if the speaker stats search is disabled. * @@ -9,3 +14,166 @@ export function isSpeakerStatsSearchDisabled(state: Object) { return state['features/base/config']?.disableSpeakerStatsSearch; } + +/** + * Gets whether participants in speaker stats should be ordered or not, and with what priority. + * + * @param {*} state - The redux state. + * @returns {Array} - The speaker stats order array or an empty array. + */ +export function getSpeakerStatsOrder(state: Object) { + return state['features/base/config']?.speakerStatsOrder ?? [ + 'role', + 'name', + 'hasLeft' + ]; +} + +/** + * Gets speaker stats. + * + * @param {*} state - The redux state. + * @returns {Object} - The speaker stats. + */ +export function getSpeakerStats(state: Object) { + return state['features/speaker-stats']?.stats ?? {}; +} + +/** + * Gets speaker stats search criteria. + * + * @param {*} state - The redux state. + * @returns {string} - The search criteria. + */ +export function getSearchCriteria(state: Object) { + return state['features/speaker-stats']?.criteria ?? ''; +} + +/** + * Gets if speaker stats reorder is pending. + * + * @param {*} state - The redux state. + * @returns {boolean} - The pending reorder flag. + */ +export function getPendingReorder(state: Object) { + return state['features/speaker-stats']?.pendingReorder ?? false; +} + +/** + * Get sorted speaker stats based on a configuration setting. + * + * @param {Object} state - The redux state. + * @param {Object} stats - The current speaker stats. + * @returns {Object} - Ordered speaker stats. + * @public + */ +export function getSortedSpeakerStats(state: Object, stats: Object) { + const orderConfig = getSpeakerStatsOrder(state); + + if (orderConfig) { + const enhancedStats = getEnhancedStatsForOrdering(state, stats, orderConfig); + const sortedStats = objectSort(enhancedStats, (currentParticipant, nextParticipant) => { + if (orderConfig.includes('hasLeft')) { + if (nextParticipant.hasLeft() && !currentParticipant.hasLeft()) { + return -1; + } else if (currentParticipant.hasLeft() && !nextParticipant.hasLeft()) { + return 1; + } + } + + let result; + + for (const sortCriteria of orderConfig) { + switch (sortCriteria) { + case 'role': + if (!nextParticipant.isModerator && currentParticipant.isModerator) { + result = -1; + } else if (!currentParticipant.isModerator && nextParticipant.isModerator) { + result = 1; + } else { + result = 0; + } + break; + case 'name': + result = (currentParticipant.displayName || '').localeCompare( + nextParticipant.displayName || '' + ); + break; + } + + if (result !== 0) { + break; + } + } + + return result; + }); + + return sortedStats; + } +} + +/** + * Enhance speaker stats to include data needed for ordering. + * + * @param {Object} state - The redux state. + * @param {Object} stats - Speaker stats. + * @param {Array} orderConfig - Ordering configuration. + * @returns {Object} - Enhanced speaker stats. + * @public + */ +function getEnhancedStatsForOrdering(state, stats, orderConfig) { + if (!orderConfig) { + return stats; + } + + for (const id in stats) { + if (stats[id].hasOwnProperty('_hasLeft') && !stats[id].hasLeft()) { + if (orderConfig.includes('name')) { + const localParticipant = getLocalParticipant(state); + + if (stats[id].isLocalStats()) { + stats[id].setDisplayName(localParticipant.name); + } + } + + if (orderConfig.includes('role')) { + const participant = getParticipantById(state, stats[id].getUserId()); + + stats[id].isModerator = participant && participant.role === PARTICIPANT_ROLE.MODERATOR; + } + } + } + + return stats; +} + +/** + * Filter stats by search criteria. + * + * @param {Object} state - The redux state. + * @param {Object | undefined} stats - The unfiltered stats. + * + * @returns {Object} - Filtered speaker stats. + * @public + */ +export function filterBySearchCriteria(state: Object, stats: ?Object) { + const filteredStats = _.cloneDeep(stats ?? getSpeakerStats(state)); + const criteria = getSearchCriteria(state); + + if (criteria) { + const searchRegex = new RegExp(criteria, 'gi'); + + for (const id in filteredStats) { + if (filteredStats[id].hasOwnProperty('_isLocalStats')) { + const name = filteredStats[id].getDisplayName(); + + if (!name || !name.match(searchRegex)) { + filteredStats[id].hidden = true; + } + } + } + } + + return filteredStats; +} diff --git a/react/features/speaker-stats/middleware.js b/react/features/speaker-stats/middleware.js new file mode 100644 index 000000000..f2f31f406 --- /dev/null +++ b/react/features/speaker-stats/middleware.js @@ -0,0 +1,49 @@ +// @flow + +import { + PARTICIPANT_JOINED, + PARTICIPANT_KICKED, + PARTICIPANT_LEFT, + PARTICIPANT_UPDATED +} from '../base/participants/actionTypes'; +import { MiddlewareRegistry } from '../base/redux'; + +import { INIT_SEARCH, INIT_UPDATE_STATS } from './actionTypes'; +import { initReorderStats, updateStats } from './actions'; +import { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder } from './functions'; + +MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { + const result = next(action); + + switch (action.type) { + + case INIT_SEARCH: { + const state = getState(); + const stats = filterBySearchCriteria(state); + + dispatch(updateStats(stats)); + break; + } + + case INIT_UPDATE_STATS: + if (action.getSpeakerStats) { + const state = getState(); + const speakerStats = { ...action.getSpeakerStats() }; + const stats = filterBySearchCriteria(state, speakerStats); + const pendingReorder = getPendingReorder(state); + + dispatch(updateStats(pendingReorder ? getSortedSpeakerStats(state, stats) : stats)); + } + break; + case PARTICIPANT_JOINED: + case PARTICIPANT_LEFT: + case PARTICIPANT_KICKED: + case PARTICIPANT_UPDATED: { + dispatch(initReorderStats()); + + break; + } + } + + return result; +}); diff --git a/react/features/speaker-stats/reducer.js b/react/features/speaker-stats/reducer.js new file mode 100644 index 000000000..d27f94d15 --- /dev/null +++ b/react/features/speaker-stats/reducer.js @@ -0,0 +1,119 @@ +// @flow + +import _ from 'lodash'; + +import { ReducerRegistry } from '../base/redux'; + +import { + INIT_SEARCH, + UPDATE_STATS, + INIT_REORDER_STATS +} from './actionTypes'; + +/** + * The initial state of the feature speaker-stats. + * + * @type {Object} + */ +const INITIAL_STATE = { + stats: {}, + pendingReorder: true, + criteria: '' +}; + +ReducerRegistry.register('features/speaker-stats', (state = _getInitialState(), action) => { + switch (action.type) { + case INIT_SEARCH: + return _updateCriteria(state, action); + case UPDATE_STATS: + return _updateStats(state, action); + case INIT_REORDER_STATS: + return _initReorderStats(state); + } + + return state; +}); + +/** + * Gets the initial state of the feature speaker-stats. + * + * @returns {Object} + */ +function _getInitialState() { + return INITIAL_STATE; +} + +/** + * Reduces a specific Redux action INIT_SEARCH of the feature + * speaker-stats. + * + * @param {Object} state - The Redux state of the feature speaker-stats. + * @param {Action} action - The Redux action INIT_SEARCH to reduce. + * @private + * @returns {Object} The new state after the reduction of the specified action. + */ +function _updateCriteria(state, { criteria }) { + return _.assign( + {}, + state, + { criteria }, + ); +} + +/** + * Reduces a specific Redux action UPDATE_STATS of the feature + * speaker-stats. + * The speaker stats order is based on the stats object properties. + * When updating without reordering, the new stats object properties are reordered + * as the last in state, otherwise the order would be lost on each update. + * If there was already a pending reorder, the stats object properties already have + * the correct order, so the property order is not changing. + * + * @param {Object} state - The Redux state of the feature speaker-stats. + * @param {Action} action - The Redux action UPDATE_STATS to reduce. + * @private + * @returns {Object} - The new state after the reduction of the specified action. + */ +function _updateStats(state, { stats }) { + const finalStats = state.pendingReorder ? stats : state.stats; + + if (!state.pendingReorder) { + // Avoid reordering the speaker stats object properties + const finalKeys = Object.keys(stats); + + finalKeys.forEach(newStatId => { + finalStats[newStatId] = _.clone(stats[newStatId]); + }); + + Object.keys(finalStats).forEach(key => { + if (!finalKeys.includes(key)) { + delete finalStats[key]; + } + }); + } + + return _.assign( + {}, + state, + { + stats: finalStats, + pendingReorder: false + }, + ); +} + +/** + * Reduces a specific Redux action INIT_REORDER_STATS of the feature + * speaker-stats. + * + * @param {Object} state - The Redux state of the feature speaker-stats. + * @private + * @returns {Object} The new state after the reduction of the specified action. + */ +function _initReorderStats(state) { + return _.assign( + {}, + state, + { pendingReorder: true }, + ); +}