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
This commit is contained in:
dimitardelchev93 2021-09-10 01:46:41 +03:00 committed by GitHub
parent db473dfef5
commit 5e152b4a42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 497 additions and 67 deletions

View File

@ -150,6 +150,13 @@ var config = {
// Specifies whether there will be a search field in speaker stats or not // Specifies whether there will be a search field in speaker stats or not
// disableSpeakerStatsSearch: false, // 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. // How many participants while in the tile view mode, before the receiving video quality is reduced from HD to SD.
// Use -1 to disable. // Use -1 to disable.
// maxFullResolutionParticipants: 2, // maxFullResolutionParticipants: 2,

View File

@ -42,6 +42,7 @@ import '../recording/middleware';
import '../rejoin/middleware'; import '../rejoin/middleware';
import '../room-lock/middleware'; import '../room-lock/middleware';
import '../rtcstats/middleware'; import '../rtcstats/middleware';
import '../speaker-stats/middleware';
import '../subtitles/middleware'; import '../subtitles/middleware';
import '../toolbox/middleware'; import '../toolbox/middleware';
import '../transcribing/middleware'; import '../transcribing/middleware';

View File

@ -46,6 +46,7 @@ import '../reactions/reducer';
import '../recent-list/reducer'; import '../recent-list/reducer';
import '../recording/reducer'; import '../recording/reducer';
import '../settings/reducer'; import '../settings/reducer';
import '../speaker-stats/reducer';
import '../subtitles/reducer'; import '../subtitles/reducer';
import '../screen-share/reducer'; import '../screen-share/reducer';
import '../toolbox/reducer'; import '../toolbox/reducer';

View File

@ -106,6 +106,7 @@ export default [
'disableShowMoreStats', 'disableShowMoreStats',
'disableRemoveRaisedHandOnFocus', 'disableRemoveRaisedHandOnFocus',
'disableSpeakerStatsSearch', 'disableSpeakerStatsSearch',
'speakerStatsOrder',
'disableSimulcast', 'disableSimulcast',
'disableThirdPartyRequests', 'disableThirdPartyRequests',
'disableTileView', 'disableTileView',

View File

@ -185,3 +185,19 @@ function parseShorthandColor(color) {
return [ r, g, b ]; 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 };
}, {});
}

View File

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

View File

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

View File

@ -1,12 +1,16 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import type { Dispatch } from 'redux';
import { Dialog } from '../../base/dialog'; import { Dialog } from '../../base/dialog';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { getLocalParticipant } from '../../base/participants'; import { getLocalParticipant } from '../../base/participants';
import { connect } from '../../base/redux'; import { connect } from '../../base/redux';
import { escapeRegexp } from '../../base/util'; 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 SpeakerStatsItem from './SpeakerStatsItem';
import SpeakerStatsLabels from './SpeakerStatsLabels'; import SpeakerStatsLabels from './SpeakerStatsLabels';
@ -24,39 +28,38 @@ type Props = {
*/ */
_localDisplayName: string, _localDisplayName: string,
/**
* The speaker paricipant stats.
*/
_stats: Object,
/**
* The search criteria.
*/
_criteria: string,
/** /**
* The JitsiConference from which stats will be pulled. * The JitsiConference from which stats will be pulled.
*/ */
conference: Object, conference: Object,
/**
* Redux store dispatch method.
*/
dispatch: Dispatch<any>,
/** /**
* The function to translate human-readable text. * The function to translate human-readable text.
*/ */
t: Function 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. * React component for displaying a list of speaker stats.
* *
* @extends Component * @extends Component
*/ */
class SpeakerStats extends Component<Props, State> { class SpeakerStats extends Component<Props> {
_updateInterval: IntervalID; _updateInterval: IntervalID;
/** /**
@ -68,14 +71,11 @@ class SpeakerStats extends Component<Props, State> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
stats: this._getSpeakerStats(),
criteria: ''
};
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._updateStats = this._updateStats.bind(this); this._updateStats = this._updateStats.bind(this);
this._onSearch = this._onSearch.bind(this); this._onSearch = this._onSearch.bind(this);
this._updateStats();
} }
/** /**
@ -84,7 +84,7 @@ class SpeakerStats extends Component<Props, State> {
* @inheritdoc * @inheritdoc
*/ */
componentDidMount() { componentDidMount() {
this._updateInterval = setInterval(this._updateStats, 1000); this._updateInterval = setInterval(() => this._updateStats(), SPEAKER_STATS_RELOAD_INTERVAL);
} }
/** /**
@ -104,14 +104,14 @@ class SpeakerStats extends Component<Props, State> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const userIds = Object.keys(this.state.stats); const userIds = Object.keys(this.props._stats);
const items = userIds.map(userId => this._createStatsItem(userId)); const items = userIds.map(userId => this._createStatsItem(userId));
return ( return (
<Dialog <Dialog
cancelKey = { 'dialog.close' } cancelKey = 'dialog.close'
submitDisabled = { true } submitDisabled = { true }
titleKey = { 'speakerStats.speakerStats' }> titleKey = 'speakerStats.speakerStats'>
<div className = 'speaker-stats'> <div className = 'speaker-stats'>
<SpeakerStatsSearch onSearch = { this._onSearch } /> <SpeakerStatsSearch onSearch = { this._onSearch } />
<SpeakerStatsLabels /> <SpeakerStatsLabels />
@ -121,32 +121,6 @@ class SpeakerStats extends Component<Props, State> {
); );
} }
/**
* 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. * Create a SpeakerStatsItem instance for the passed in user id.
* *
@ -156,9 +130,9 @@ class SpeakerStats extends Component<Props, State> {
* @private * @private
*/ */
_createStatsItem(userId) { _createStatsItem(userId) {
const statsModel = this.state.stats[userId]; const statsModel = this.props._stats[userId];
if (!statsModel) { if (!statsModel || statsModel.hidden) {
return null; return null;
} }
@ -177,7 +151,7 @@ class SpeakerStats extends Component<Props, State> {
= displayName ? `${displayName} (${meString})` : meString; = displayName ? `${displayName} (${meString})` : meString;
} else { } else {
displayName displayName
= this.state.stats[userId].getDisplayName() = this.props._stats[userId].getDisplayName()
|| interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME; || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME;
} }
@ -201,10 +175,7 @@ class SpeakerStats extends Component<Props, State> {
* @protected * @protected
*/ */
_onSearch(criteria = '') { _onSearch(criteria = '') {
this.setState({ this.props.dispatch(initSearch(escapeRegexp(criteria)));
...this.state,
criteria: escapeRegexp(criteria)
});
} }
_updateStats: () => void; _updateStats: () => void;
@ -216,12 +187,7 @@ class SpeakerStats extends Component<Props, State> {
* @private * @private
*/ */
_updateStats() { _updateStats() {
const stats = this._getSpeakerStats(); this.props.dispatch(initUpdateStats(() => this.props.conference.getSpeakerStats()));
this.setState({
...this.state,
stats
});
} }
} }
@ -231,7 +197,9 @@ class SpeakerStats extends Component<Props, State> {
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @private * @private
* @returns {{ * @returns {{
* _localDisplayName: ?string * _localDisplayName: ?string,
* _stats: Object,
* _criteria: string,
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
@ -244,7 +212,9 @@ function _mapStateToProps(state) {
* @private * @private
* @type {string|undefined} * @type {string|undefined}
*/ */
_localDisplayName: localParticipant && localParticipant.name _localDisplayName: localParticipant && localParticipant.name,
_stats: getSpeakerStats(state),
_criteria: getSearchCriteria(state)
}; };
} }

View File

@ -0,0 +1 @@
export const SPEAKER_STATS_RELOAD_INTERVAL = 1000;

View File

@ -1,5 +1,10 @@
// @flow // @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. * Checks if the speaker stats search is disabled.
* *
@ -9,3 +14,166 @@
export function isSpeakerStatsSearchDisabled(state: Object) { export function isSpeakerStatsSearchDisabled(state: Object) {
return state['features/base/config']?.disableSpeakerStatsSearch; 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<string>} - 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<string>} 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;
}

View File

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

View File

@ -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 },
);
}