feat: (speaker-stats) add speaker stats feature to native
This commit is contained in:
parent
cc63ff1c3c
commit
70b8ccc097
|
@ -98,6 +98,12 @@ export const IOS_SCREENSHARING_ENABLED = 'ios.screensharing.enabled';
|
||||||
*/
|
*/
|
||||||
export const ANDROID_SCREENSHARING_ENABLED = 'android.screensharing.enabled';
|
export const ANDROID_SCREENSHARING_ENABLED = 'android.screensharing.enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag indicating if speaker statistics should be enabled in android.
|
||||||
|
* Default: enabled (true).
|
||||||
|
*/
|
||||||
|
export const ANDROID_SPEAKERSTATS_ENABLED = 'android.speakerstats.enabled';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flag indicating if kickout is enabled.
|
* Flag indicating if kickout is enabled.
|
||||||
* Default: enabled (true).
|
* Default: enabled (true).
|
||||||
|
|
|
@ -173,6 +173,9 @@ export const colorMap = {
|
||||||
// Quaternary color for disabled actions
|
// Quaternary color for disabled actions
|
||||||
icon04: 'surface14',
|
icon04: 'surface14',
|
||||||
|
|
||||||
|
// Quinary color for disabled actions
|
||||||
|
icon05: 'surface04',
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
iconError: 'error06',
|
iconError: 'error06',
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,8 @@ import AddPeopleDialog
|
||||||
from '../../../invite/components/add-people-dialog/native/AddPeopleDialog';
|
from '../../../invite/components/add-people-dialog/native/AddPeopleDialog';
|
||||||
import LobbyScreen from '../../../lobby/components/native/LobbyScreen';
|
import LobbyScreen from '../../../lobby/components/native/LobbyScreen';
|
||||||
import { ParticipantsPane } from '../../../participants-pane/components/native';
|
import { ParticipantsPane } from '../../../participants-pane/components/native';
|
||||||
|
import SpeakerStats
|
||||||
|
from '../../../speaker-stats/components/native/SpeakerStats';
|
||||||
import { getDisablePolls } from '../../functions';
|
import { getDisablePolls } from '../../functions';
|
||||||
|
|
||||||
import Conference from './Conference';
|
import Conference from './Conference';
|
||||||
|
@ -26,7 +28,8 @@ import {
|
||||||
lobbyScreenOptions,
|
lobbyScreenOptions,
|
||||||
navigationContainerTheme,
|
navigationContainerTheme,
|
||||||
participantsScreenOptions,
|
participantsScreenOptions,
|
||||||
sharedDocumentScreenOptions
|
sharedDocumentScreenOptions,
|
||||||
|
speakerStatsScreenOptions
|
||||||
} from './ConferenceNavigatorScreenOptions';
|
} from './ConferenceNavigatorScreenOptions';
|
||||||
import { screen } from './routes';
|
import { screen } from './routes';
|
||||||
|
|
||||||
|
@ -72,6 +75,12 @@ const ConferenceNavigationContainer = () => {
|
||||||
...participantsScreenOptions,
|
...participantsScreenOptions,
|
||||||
title: t('participantsPane.header')
|
title: t('participantsPane.header')
|
||||||
}} />
|
}} />
|
||||||
|
<ConferenceStack.Screen
|
||||||
|
component = { SpeakerStats }
|
||||||
|
name = { screen.conference.speakerStats }
|
||||||
|
options = {{
|
||||||
|
...speakerStatsScreenOptions
|
||||||
|
}} />
|
||||||
<ConferenceStack.Screen
|
<ConferenceStack.Screen
|
||||||
component = { LobbyScreen }
|
component = { LobbyScreen }
|
||||||
name = { screen.lobby }
|
name = { screen.lobby }
|
||||||
|
|
|
@ -215,6 +215,13 @@ export const participantsScreenOptions = {
|
||||||
...presentationScreenOptions
|
...presentationScreenOptions
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen options for speaker stats modal.
|
||||||
|
*/
|
||||||
|
export const speakerStatsScreenOptions = {
|
||||||
|
...presentationScreenOptions
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen options for shared document.
|
* Screen options for shared document.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const screen = {
|
||||||
polls: 'Polls'
|
polls: 'Polls'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
speakerStats: 'Speaker Stats',
|
||||||
participants: 'Participants',
|
participants: 'Participants',
|
||||||
invite: 'Invite',
|
invite: 'Invite',
|
||||||
sharedDocument: 'Shared document'
|
sharedDocument: 'Shared document'
|
||||||
|
|
|
@ -76,7 +76,7 @@ MiddlewareRegistry.register(store => next => action => {
|
||||||
const { id, role } = action.participant;
|
const { id, role } = action.participant;
|
||||||
const localParticipant = getLocalParticipant(state);
|
const localParticipant = getLocalParticipant(state);
|
||||||
|
|
||||||
if (localParticipant.id !== id) {
|
if (localParticipant?.id !== id) {
|
||||||
return next(action);
|
return next(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,3 +37,4 @@ export const UPDATE_STATS = 'UPDATE_STATS';
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const INIT_REORDER_STATS = 'INIT_REORDER_STATS';
|
export const INIT_REORDER_STATS = 'INIT_REORDER_STATS';
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
export * from './actions.any';
|
|
@ -0,0 +1,3 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
export * from './actions.any';
|
|
@ -0,0 +1,28 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import type { Dispatch } from 'redux';
|
||||||
|
|
||||||
|
import { IconPresentation } from '../../base/icons';
|
||||||
|
import { AbstractButton } from '../../base/toolbox/components';
|
||||||
|
import type { AbstractButtonProps } from '../../base/toolbox/components';
|
||||||
|
|
||||||
|
type Props = AbstractButtonProps & {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the navigation bar should be visible.
|
||||||
|
*/
|
||||||
|
dispatch: Dispatch<any>
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of a button for opening speaker stats dialog.
|
||||||
|
*/
|
||||||
|
class AbstractSpeakerStatsButton extends AbstractButton<Props, *> {
|
||||||
|
accessibilityLabel = 'toolbar.accessibilityLabel.speakerStats';
|
||||||
|
icon = IconPresentation;
|
||||||
|
label = 'toolbar.speakerStats';
|
||||||
|
tooltip = 'toolbar.speakerStats';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AbstractSpeakerStatsButton;
|
|
@ -0,0 +1,101 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { getLocalParticipant } from '../../base/participants';
|
||||||
|
import { initUpdateStats } from '../actions';
|
||||||
|
import {
|
||||||
|
SPEAKER_STATS_RELOAD_INTERVAL
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders the list of speaker stats.
|
||||||
|
*
|
||||||
|
* @param {Function} speakerStatsItem - React element tu use when rendering.
|
||||||
|
* @param {boolean} [isWeb=false] - Is for web in browser.
|
||||||
|
* @returns {Function}
|
||||||
|
*/
|
||||||
|
const abstractSpeakerStatsList = (speakerStatsItem: Function, isWeb: boolean = true): Function[] => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const conference = useSelector(state => state['features/base/conference'].conference);
|
||||||
|
const localParticipant = useSelector(getLocalParticipant);
|
||||||
|
const { enableFacialRecognition } = isWeb ? useSelector(state => state['features/base/config']) : {};
|
||||||
|
const { facialExpressions: localFacialExpressions } = isWeb
|
||||||
|
? useSelector(state => state['features/facial-recognition']) : {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the internal state with the latest speaker stats.
|
||||||
|
*
|
||||||
|
* @returns {Object}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const getLocalSpeakerStats = useCallback(() => {
|
||||||
|
const stats = conference.getSpeakerStats();
|
||||||
|
|
||||||
|
for (const userId in stats) {
|
||||||
|
if (stats[userId]) {
|
||||||
|
if (stats[userId].isLocalStats()) {
|
||||||
|
const meString = t('me');
|
||||||
|
|
||||||
|
stats[userId].setDisplayName(
|
||||||
|
localParticipant.name
|
||||||
|
? `${localParticipant.name} (${meString})`
|
||||||
|
: meString
|
||||||
|
);
|
||||||
|
if (enableFacialRecognition) {
|
||||||
|
stats[userId].setFacialExpressions(localFacialExpressions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats[userId].getDisplayName()) {
|
||||||
|
stats[userId].setDisplayName(
|
||||||
|
conference.getParticipantById(userId)?.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStats = useCallback(
|
||||||
|
() => dispatch(initUpdateStats(
|
||||||
|
() => getLocalSpeakerStats())), [ dispatch, initUpdateStats() ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateInterval = setInterval(() => updateStats(), SPEAKER_STATS_RELOAD_INTERVAL);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
};
|
||||||
|
}, [ dispatch, conference ]);
|
||||||
|
|
||||||
|
const userIds = Object.keys(getLocalSpeakerStats());
|
||||||
|
|
||||||
|
return userIds.map(userId => {
|
||||||
|
const statsModel = getLocalSpeakerStats()[userId];
|
||||||
|
|
||||||
|
if (!statsModel || statsModel.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const props = {};
|
||||||
|
|
||||||
|
props.isDominantSpeaker = statsModel.isDominantSpeaker();
|
||||||
|
props.dominantSpeakerTime = statsModel.getTotalDominantSpeakerTime();
|
||||||
|
props.hasLeft = statsModel.hasLeft();
|
||||||
|
if (enableFacialRecognition) {
|
||||||
|
props.facialExpressions = statsModel.getFacialExpressions();
|
||||||
|
}
|
||||||
|
props.showFacialExpressions = enableFacialRecognition;
|
||||||
|
props.displayName = statsModel.getDisplayName();
|
||||||
|
props.t = t;
|
||||||
|
|
||||||
|
return speakerStatsItem(props);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default abstractSpeakerStatsList;
|
|
@ -1,283 +0,0 @@
|
||||||
// @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';
|
|
||||||
import SpeakerStatsSearch from './SpeakerStatsSearch';
|
|
||||||
|
|
||||||
declare var interfaceConfig: Object;
|
|
||||||
|
|
||||||
declare var APP;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of the React {@code Component} props of {@link SpeakerStats}.
|
|
||||||
*/
|
|
||||||
type Props = {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The display name for the local participant obtained from the redux store.
|
|
||||||
*/
|
|
||||||
_localDisplayName: string,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The flag which shows if the facial recognition is enabled, obtained from the redux store.
|
|
||||||
* if enabled facial expressions are shown
|
|
||||||
*/
|
|
||||||
_enableFacialRecognition: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The facial expressions for the local participant obtained from the redux store.
|
|
||||||
*/
|
|
||||||
_localFacialExpressions: Array<Object>,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The flag which shows if all the facial expressions are shown or only 4
|
|
||||||
* if true show only 4, if false show all
|
|
||||||
*/
|
|
||||||
_reduceExpressions: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The speaker paricipant stats.
|
|
||||||
*/
|
|
||||||
_stats: Object,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The search criteria.
|
|
||||||
*/
|
|
||||||
_criteria: string | null,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The JitsiConference from which stats will be pulled.
|
|
||||||
*/
|
|
||||||
conference: Object,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redux store dispatch method.
|
|
||||||
*/
|
|
||||||
dispatch: Dispatch<any>,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The function to translate human-readable text.
|
|
||||||
*/
|
|
||||||
t: Function,
|
|
||||||
stats: Object,
|
|
||||||
|
|
||||||
lastFacialExpression: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React component for displaying a list of speaker stats.
|
|
||||||
*
|
|
||||||
* @augments Component
|
|
||||||
*/
|
|
||||||
class SpeakerStats extends Component<Props> {
|
|
||||||
_updateInterval: IntervalID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a new SpeakerStats instance.
|
|
||||||
*
|
|
||||||
* @param {Object} props - The read-only React Component props with which
|
|
||||||
* the new instance is to be initialized.
|
|
||||||
*/
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin polling for speaker stats updates.
|
|
||||||
*
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
componentDidMount() {
|
|
||||||
this._updateInterval = setInterval(() => this._updateStats(), SPEAKER_STATS_RELOAD_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop polling for speaker stats updates.
|
|
||||||
*
|
|
||||||
* @inheritdoc
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
componentWillUnmount() {
|
|
||||||
clearInterval(this._updateInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implements React's {@link Component#render()}.
|
|
||||||
*
|
|
||||||
* @inheritdoc
|
|
||||||
* @returns {ReactElement}
|
|
||||||
*/
|
|
||||||
render() {
|
|
||||||
const userIds = Object.keys(this.props._stats);
|
|
||||||
const items = userIds.map(userId => this._createStatsItem(userId));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
cancelKey = 'dialog.close'
|
|
||||||
submitDisabled = { true }
|
|
||||||
titleKey = 'speakerStats.speakerStats'
|
|
||||||
width = { this.props._enableFacialRecognition ? 'large' : 'medium' }>
|
|
||||||
<div className = 'speaker-stats'>
|
|
||||||
<SpeakerStatsSearch onSearch = { this._onSearch } />
|
|
||||||
<SpeakerStatsLabels
|
|
||||||
reduceExpressions = { this.props._reduceExpressions }
|
|
||||||
showFacialExpressions = { this.props._enableFacialRecognition } />
|
|
||||||
{ items }
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a SpeakerStatsItem instance for the passed in user id.
|
|
||||||
*
|
|
||||||
* @param {string} userId - User id used to look up the associated
|
|
||||||
* speaker stats from the jitsi library.
|
|
||||||
* @returns {SpeakerStatsItem|null}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_createStatsItem(userId) {
|
|
||||||
const statsModel = this.props._stats[userId];
|
|
||||||
|
|
||||||
if (!statsModel || statsModel.hidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDominantSpeaker = statsModel.isDominantSpeaker();
|
|
||||||
const dominantSpeakerTime = statsModel.getTotalDominantSpeakerTime();
|
|
||||||
const hasLeft = statsModel.hasLeft();
|
|
||||||
let facialExpressions;
|
|
||||||
|
|
||||||
if (this.props._enableFacialRecognition) {
|
|
||||||
facialExpressions = statsModel.getFacialExpressions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SpeakerStatsItem
|
|
||||||
displayName = { statsModel.getDisplayName() }
|
|
||||||
dominantSpeakerTime = { dominantSpeakerTime }
|
|
||||||
facialExpressions = { facialExpressions }
|
|
||||||
hasLeft = { hasLeft }
|
|
||||||
isDominantSpeaker = { isDominantSpeaker }
|
|
||||||
key = { userId }
|
|
||||||
reduceExpressions = { this.props._reduceExpressions }
|
|
||||||
showFacialExpressions = { this.props._enableFacialRecognition } />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onSearch: () => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search the existing participants by name.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
* @param {string} criteria - The search parameter.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
_onSearch(criteria = '') {
|
|
||||||
this.props.dispatch(initSearch(escapeRegexp(criteria)));
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateStats: () => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the internal state with the latest speaker stats.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_updateStats() {
|
|
||||||
this.props.dispatch(initUpdateStats(() => this._getSpeakerStats()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the internal state with the latest speaker stats.
|
|
||||||
*
|
|
||||||
* @returns {Object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_getSpeakerStats() {
|
|
||||||
const stats = { ...this.props.conference.getSpeakerStats() };
|
|
||||||
|
|
||||||
for (const userId in stats) {
|
|
||||||
if (stats[userId]) {
|
|
||||||
if (stats[userId].isLocalStats()) {
|
|
||||||
const { t } = this.props;
|
|
||||||
const meString = t('me');
|
|
||||||
|
|
||||||
stats[userId].setDisplayName(
|
|
||||||
this.props._localDisplayName
|
|
||||||
? `${this.props._localDisplayName} (${meString})`
|
|
||||||
: meString
|
|
||||||
);
|
|
||||||
if (this.props._enableFacialRecognition) {
|
|
||||||
stats[userId].setFacialExpressions(this.props._localFacialExpressions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats[userId].getDisplayName()) {
|
|
||||||
stats[userId].setDisplayName(
|
|
||||||
interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps (parts of) the redux state to the associated SpeakerStats's props.
|
|
||||||
*
|
|
||||||
* @param {Object} state - The redux state.
|
|
||||||
* @private
|
|
||||||
* @returns {{
|
|
||||||
* _localDisplayName: ?string,
|
|
||||||
* _stats: Object,
|
|
||||||
* _criteria: string,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
function _mapStateToProps(state) {
|
|
||||||
const localParticipant = getLocalParticipant(state);
|
|
||||||
const { enableFacialRecognition } = state['features/base/config'];
|
|
||||||
const { facialExpressions: localFacialExpressions } = state['features/facial-recognition'];
|
|
||||||
const { cameraTimeTracker: localCameraTimeTracker } = state['features/facial-recognition'];
|
|
||||||
const { clientWidth } = state['features/base/responsive-ui'];
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* The local display name.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @type {string|undefined}
|
|
||||||
*/
|
|
||||||
_localDisplayName: localParticipant && localParticipant.name,
|
|
||||||
_stats: getSpeakerStats(state),
|
|
||||||
_criteria: getSearchCriteria(state),
|
|
||||||
_enableFacialRecognition: enableFacialRecognition,
|
|
||||||
_localFacialExpressions: localFacialExpressions,
|
|
||||||
_localCameraTimeTracker: localCameraTimeTracker,
|
|
||||||
_reduceExpressions: clientWidth < 750
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default translate(connect(_mapStateToProps)(SpeakerStats));
|
|
|
@ -1,73 +0,0 @@
|
||||||
// @flow
|
|
||||||
|
|
||||||
import { createToolbarEvent, sendAnalytics } from '../../analytics';
|
|
||||||
import { openDialog } from '../../base/dialog';
|
|
||||||
import { translate } from '../../base/i18n';
|
|
||||||
import { IconPresentation } from '../../base/icons';
|
|
||||||
import { connect } from '../../base/redux';
|
|
||||||
import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components';
|
|
||||||
|
|
||||||
import SpeakerStats from './SpeakerStats';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of the React {@code Component} props of {@link SpeakerStatsButton}.
|
|
||||||
*/
|
|
||||||
type Props = AbstractButtonProps & {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The {@code JitsiConference} for the current conference.
|
|
||||||
*/
|
|
||||||
_conference: Object,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The redux {@code dispatch} function.
|
|
||||||
*/
|
|
||||||
dispatch: Function
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation of a button for opening speaker stats dialog.
|
|
||||||
*/
|
|
||||||
class SpeakerStatsButton extends AbstractButton<Props, *> {
|
|
||||||
accessibilityLabel = 'toolbar.accessibilityLabel.speakerStats';
|
|
||||||
icon = IconPresentation;
|
|
||||||
label = 'toolbar.speakerStats';
|
|
||||||
tooltip = 'toolbar.speakerStats';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
|
||||||
*
|
|
||||||
* @protected
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
_handleClick() {
|
|
||||||
const { _conference, dispatch, handleClick } = this.props;
|
|
||||||
|
|
||||||
if (handleClick) {
|
|
||||||
handleClick();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAnalytics(createToolbarEvent('speaker.stats'));
|
|
||||||
dispatch(openDialog(SpeakerStats, {
|
|
||||||
conference: _conference
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps (parts of) the Redux state to the associated
|
|
||||||
* {@code SpeakerStatsButton} component's props.
|
|
||||||
*
|
|
||||||
* @param {Object} state - The Redux state.
|
|
||||||
* @private
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function mapStateToProps(state) {
|
|
||||||
return {
|
|
||||||
_conference: state['features/base/conference'].conference
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default translate(connect(mapStateToProps)(SpeakerStatsButton));
|
|
|
@ -1,148 +0,0 @@
|
||||||
/* @flow */
|
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
import { translate } from '../../base/i18n';
|
|
||||||
|
|
||||||
import TimeElapsed from './TimeElapsed';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of the React {@code Component} props of {@link SpeakerStatsItem}.
|
|
||||||
*/
|
|
||||||
type Props = {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the participant.
|
|
||||||
*/
|
|
||||||
displayName: string,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The total milliseconds the participant has been dominant speaker.
|
|
||||||
*/
|
|
||||||
dominantSpeakerTime: number,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The object that has as keys the facial expressions of the
|
|
||||||
* participant and as values a number that represents the count .
|
|
||||||
*/
|
|
||||||
facialExpressions: Object,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True if the participant is no longer in the meeting.
|
|
||||||
*/
|
|
||||||
hasLeft: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True if the participant is currently the dominant speaker.
|
|
||||||
*/
|
|
||||||
isDominantSpeaker: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True if the client width is les than 750.
|
|
||||||
*/
|
|
||||||
reduceExpressions: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True if the facial recognition is not disabled.
|
|
||||||
*/
|
|
||||||
showFacialExpressions: boolean,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invoked to obtain translated strings.
|
|
||||||
*/
|
|
||||||
t: Function
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React component for display an individual user's speaker stats.
|
|
||||||
*
|
|
||||||
* @augments Component
|
|
||||||
*/
|
|
||||||
class SpeakerStatsItem extends Component<Props> {
|
|
||||||
/**
|
|
||||||
* Implements React's {@link Component#render()}.
|
|
||||||
*
|
|
||||||
* @inheritdoc
|
|
||||||
* @returns {ReactElement}
|
|
||||||
*/
|
|
||||||
render() {
|
|
||||||
const hasLeftClass = this.props.hasLeft ? 'status-user-left' : '';
|
|
||||||
const rowDisplayClass = `speaker-stats-item ${hasLeftClass}`;
|
|
||||||
|
|
||||||
const dotClass = this.props.isDominantSpeaker
|
|
||||||
? 'status-active' : 'status-inactive';
|
|
||||||
const speakerStatusClass = `speaker-stats-item__status-dot ${dotClass}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className = { rowDisplayClass }>
|
|
||||||
<div className = 'speaker-stats-item__status'>
|
|
||||||
<span className = { speakerStatusClass } />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { this.props.t('speakerStats.speakerStats') }
|
|
||||||
className = { `speaker-stats-item__name${
|
|
||||||
this.props.showFacialExpressions ? '_expressions_on' : ''
|
|
||||||
}` }>
|
|
||||||
{ this.props.displayName }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { this.props.t('speakerStats.speakerTime') }
|
|
||||||
className = { `speaker-stats-item__time${
|
|
||||||
this.props.showFacialExpressions ? '_expressions_on' : ''
|
|
||||||
}` }>
|
|
||||||
<TimeElapsed
|
|
||||||
time = { this.props.dominantSpeakerTime } />
|
|
||||||
</div>
|
|
||||||
{ this.props.showFacialExpressions
|
|
||||||
&& (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Happy' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.happy }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Neutral' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.neutral }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Sad' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.sad }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Surprised' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.surprised }
|
|
||||||
</div>
|
|
||||||
{!this.props.reduceExpressions && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Angry' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.angry }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Fearful' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.fearful }
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label = { 'Disgusted' }
|
|
||||||
className = 'speaker-stats-item__expression'>
|
|
||||||
{ this.props.facialExpressions.disgusted }
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default translate(SpeakerStatsItem);
|
|
|
@ -1,131 +0,0 @@
|
||||||
/* @flow */
|
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
import { translate } from '../../base/i18n';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of the React {@code Component} props of {@link TimeElapsed}.
|
|
||||||
*/
|
|
||||||
type Props = {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The function to translate human-readable text.
|
|
||||||
*/
|
|
||||||
t: Function,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The milliseconds to be converted into a human-readable format.
|
|
||||||
*/
|
|
||||||
time: number
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React component for displaying total time elapsed. Converts a total count of
|
|
||||||
* milliseconds into a more humanized form: "# hours, # minutes, # seconds".
|
|
||||||
* With a time of 0, "0s" will be displayed.
|
|
||||||
*
|
|
||||||
* @augments Component
|
|
||||||
*/
|
|
||||||
class TimeElapsed extends Component<Props> {
|
|
||||||
/**
|
|
||||||
* Implements React's {@link Component#render()}.
|
|
||||||
*
|
|
||||||
* @inheritdoc
|
|
||||||
* @returns {ReactElement}
|
|
||||||
*/
|
|
||||||
render() {
|
|
||||||
const { time } = this.props;
|
|
||||||
const hours = _getHoursCount(time);
|
|
||||||
const minutes = _getMinutesCount(time);
|
|
||||||
const seconds = _getSecondsCount(time);
|
|
||||||
const timeElapsed = [];
|
|
||||||
|
|
||||||
if (hours) {
|
|
||||||
const hourPassed
|
|
||||||
= this._createTimeDisplay(hours, 'speakerStats.hours', 'hours');
|
|
||||||
|
|
||||||
timeElapsed.push(hourPassed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hours || minutes) {
|
|
||||||
const minutesPassed
|
|
||||||
= this._createTimeDisplay(
|
|
||||||
minutes,
|
|
||||||
'speakerStats.minutes',
|
|
||||||
'minutes');
|
|
||||||
|
|
||||||
timeElapsed.push(minutesPassed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondsPassed
|
|
||||||
= this._createTimeDisplay(
|
|
||||||
seconds,
|
|
||||||
'speakerStats.seconds',
|
|
||||||
'seconds');
|
|
||||||
|
|
||||||
timeElapsed.push(secondsPassed);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{ timeElapsed }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a ReactElement to display the passed in count and a count noun.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {number} count - The number used for display and to check for
|
|
||||||
* count noun plurality.
|
|
||||||
* @param {string} countNounKey - Translation key for the time's count noun.
|
|
||||||
* @param {string} countType - What is being counted. Used as the element's
|
|
||||||
* key for react to iterate upon.
|
|
||||||
* @returns {ReactElement}
|
|
||||||
*/
|
|
||||||
_createTimeDisplay(count, countNounKey, countType) {
|
|
||||||
const { t } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key = { countType } >
|
|
||||||
{ t(countNounKey, { count }) }
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default translate(TimeElapsed);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts how many whole hours are included in the given time total.
|
|
||||||
*
|
|
||||||
* @param {number} milliseconds - The millisecond total to get hours from.
|
|
||||||
* @private
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function _getHoursCount(milliseconds) {
|
|
||||||
return Math.floor(milliseconds / (60 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts how many whole minutes are included in the given time total.
|
|
||||||
*
|
|
||||||
* @param {number} milliseconds - The millisecond total to get minutes from.
|
|
||||||
* @private
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function _getMinutesCount(milliseconds) {
|
|
||||||
return Math.floor(milliseconds / (60 * 1000) % 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts how many whole seconds are included in the given time total.
|
|
||||||
*
|
|
||||||
* @param {number} milliseconds - The millisecond total to get seconds from.
|
|
||||||
* @private
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function _getSecondsCount(milliseconds) {
|
|
||||||
return Math.floor(milliseconds / 1000 % 60);
|
|
||||||
}
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './native';
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './web';
|
|
@ -1,2 +1 @@
|
||||||
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
|
export * from './_';
|
||||||
export { default as SpeakerStats } from './SpeakerStats';
|
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||||
|
|
||||||
|
import SpeakerStatsLabels from './SpeakerStatsLabels';
|
||||||
|
import SpeakerStatsList from './SpeakerStatsList';
|
||||||
|
import style from './styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders the list of speaker stats.
|
||||||
|
*
|
||||||
|
* @returns {React$Element<any>}
|
||||||
|
*/
|
||||||
|
const SpeakerStats = () => (
|
||||||
|
<JitsiScreen
|
||||||
|
hasTabNavigator = { false }
|
||||||
|
style = { style.speakerStatsContainer }>
|
||||||
|
<SpeakerStatsLabels />
|
||||||
|
<SpeakerStatsList />
|
||||||
|
</JitsiScreen>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SpeakerStats;
|
|
@ -0,0 +1,28 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import { createToolbarEvent, sendAnalytics } from '../../../analytics';
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
|
import { connect } from '../../../base/redux';
|
||||||
|
import { navigate } from '../../../conference/components/native/ConferenceNavigationContainerRef';
|
||||||
|
import { screen } from '../../../conference/components/native/routes';
|
||||||
|
import AbstractSpeakerStatsButton from '../AbstractSpeakerStatsButton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of a button for opening speaker stats dialog.
|
||||||
|
*/
|
||||||
|
class SpeakerStatsButton extends AbstractSpeakerStatsButton {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_handleClick() {
|
||||||
|
sendAnalytics(createToolbarEvent('speaker.stats'));
|
||||||
|
|
||||||
|
return navigate(screen.conference.speakerStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translate(connect()(SpeakerStatsButton));
|
|
@ -0,0 +1,62 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
|
||||||
|
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||||
|
|
||||||
|
import TimeElapsed from './TimeElapsed';
|
||||||
|
import style from './styles';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the participant.
|
||||||
|
*/
|
||||||
|
displayName: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total milliseconds the participant has been dominant speaker.
|
||||||
|
*/
|
||||||
|
dominantSpeakerTime: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is no longer in the meeting.
|
||||||
|
*/
|
||||||
|
hasLeft: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is currently the dominant speaker.
|
||||||
|
*/
|
||||||
|
isDominantSpeaker: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpeakerStatsItem = (props: Props) => {
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
const dotColor = props.isDominantSpeaker
|
||||||
|
? BaseTheme.palette.icon05 : BaseTheme.palette.icon03;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key = { props.displayName + props.dominantSpeakerTime }
|
||||||
|
style = { style.speakerStatsItemContainer }>
|
||||||
|
<View style = { style.speakerStatsItemStatus }>
|
||||||
|
<View style = { [ style.speakerStatsItemStatusDot, { backgroundColor: dotColor } ] } />
|
||||||
|
</View>
|
||||||
|
<View style = { [ style.speakerStatsItemStatus, style.speakerStatsItemName ] }>
|
||||||
|
<Text>
|
||||||
|
{ props.displayName }
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style = { [ style.speakerStatsItemStatus, style.speakerStatsItemTime ] }>
|
||||||
|
<TimeElapsed
|
||||||
|
time = { props.dominantSpeakerTime } />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpeakerStatsItem;
|
|
@ -0,0 +1,35 @@
|
||||||
|
/* @flow */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import style from './styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React component for labeling speaker stats column items.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const SpeakerStatsLabels = () => {
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style = { style.speakerStatsLabelContainer } >
|
||||||
|
<View style = { style.dummyElement } />
|
||||||
|
<View style = { style.speakerName }>
|
||||||
|
<Text>
|
||||||
|
{ t('speakerStats.name') }
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style = { style.speakerTime }>
|
||||||
|
<Text>
|
||||||
|
{ t('speakerStats.speakerTime') }
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpeakerStatsLabels;
|
|
@ -0,0 +1,26 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import abstractSpeakerStatsList from '../AbstractSpeakerStatsList';
|
||||||
|
|
||||||
|
import SpeakerStatsItem from './SpeakerStatsItem';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders the list of speaker stats.
|
||||||
|
*
|
||||||
|
* @returns {React$Element<any>}
|
||||||
|
*/
|
||||||
|
const SpeakerStatsList = () => {
|
||||||
|
const items = abstractSpeakerStatsList(SpeakerStatsItem, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{items}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default SpeakerStatsList;
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* @flow */
|
||||||
|
|
||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
|
import { createLocalizedTime } from '../timeFunctions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the React {@code Component} props of {@link TimeElapsed}.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function to translate human-readable text.
|
||||||
|
*/
|
||||||
|
t: Function,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The milliseconds to be converted into a human-readable format.
|
||||||
|
*/
|
||||||
|
time: number
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React component for displaying total time elapsed. Converts a total count of
|
||||||
|
* milliseconds into a more humanized form: "# hours, # minutes, # seconds".
|
||||||
|
* With a time of 0, "0s" will be displayed.
|
||||||
|
*
|
||||||
|
* @augments Component
|
||||||
|
*/
|
||||||
|
class TimeElapsed extends PureComponent<Props> {
|
||||||
|
/**
|
||||||
|
* Implements React's {@link Component#render()}.
|
||||||
|
*
|
||||||
|
* @inheritdoc
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
render() {
|
||||||
|
const { time, t } = this.props;
|
||||||
|
const timeElapsed = createLocalizedTime(time, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{ timeElapsed }
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translate(TimeElapsed);
|
|
@ -0,0 +1,4 @@
|
||||||
|
// @flow
|
||||||
|
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
|
||||||
|
export { default as SpeakerStats } from './SpeakerStats';
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
speakerStatsContainer: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
height: 'auto'
|
||||||
|
},
|
||||||
|
speakerStatsItemContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
height: 24
|
||||||
|
},
|
||||||
|
speakerStatsItemStatus: {
|
||||||
|
flex: 1,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
},
|
||||||
|
speakerStatsItemStatusDot: {
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
marginLeft: 7,
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 0
|
||||||
|
},
|
||||||
|
speakerStatsItemName: {
|
||||||
|
flex: 8,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
},
|
||||||
|
speakerStatsItemTime: {
|
||||||
|
flex: 12,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
},
|
||||||
|
speakerStatsLabelContainer: {
|
||||||
|
marginTop: BaseTheme.spacing[2],
|
||||||
|
marginBottom: BaseTheme.spacing[1],
|
||||||
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
|
dummyElement: {
|
||||||
|
flex: 1,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
},
|
||||||
|
speakerName: {
|
||||||
|
flex: 8,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
},
|
||||||
|
speakerTime: {
|
||||||
|
flex: 12,
|
||||||
|
alignSelf: 'stretch'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
|
@ -0,0 +1,90 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts how many whole hours are included in the given time total.
|
||||||
|
*
|
||||||
|
* @param {number} milliseconds - The millisecond total to get hours from.
|
||||||
|
* @private
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getHoursCount(milliseconds) {
|
||||||
|
return Math.floor(milliseconds / (60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts how many whole minutes are included in the given time total.
|
||||||
|
*
|
||||||
|
* @param {number} milliseconds - The millisecond total to get minutes from.
|
||||||
|
* @private
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getMinutesCount(milliseconds) {
|
||||||
|
return Math.floor(milliseconds / (60 * 1000) % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts how many whole seconds are included in the given time total.
|
||||||
|
*
|
||||||
|
* @param {number} milliseconds - The millisecond total to get seconds from.
|
||||||
|
* @private
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getSecondsCount(milliseconds) {
|
||||||
|
return Math.floor(milliseconds / 1000 % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates human readable localized time string.
|
||||||
|
*
|
||||||
|
* @param {number} time - Value in milliseconds.
|
||||||
|
* @param {Function} t - Translate function.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function createLocalizedTime(time: number, t: Function) {
|
||||||
|
const hours = getHoursCount(time);
|
||||||
|
const minutes = getMinutesCount(time);
|
||||||
|
const seconds = getSecondsCount(time);
|
||||||
|
const timeElapsed = [];
|
||||||
|
|
||||||
|
if (hours) {
|
||||||
|
const hourPassed
|
||||||
|
= createTimeDisplay(hours, 'speakerStats.hours', t);
|
||||||
|
|
||||||
|
timeElapsed.push(hourPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours || minutes) {
|
||||||
|
const minutesPassed
|
||||||
|
= createTimeDisplay(
|
||||||
|
minutes,
|
||||||
|
'speakerStats.minutes',
|
||||||
|
t);
|
||||||
|
|
||||||
|
timeElapsed.push(minutesPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsPassed
|
||||||
|
= createTimeDisplay(
|
||||||
|
seconds,
|
||||||
|
'speakerStats.seconds',
|
||||||
|
t);
|
||||||
|
|
||||||
|
timeElapsed.push(secondsPassed);
|
||||||
|
|
||||||
|
return timeElapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a string to display the passed in count and a count noun.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {number} count - The number used for display and to check for
|
||||||
|
* count noun plurality.
|
||||||
|
* @param {string} countNounKey - Translation key for the time's count noun.
|
||||||
|
* @param {Function} t - What is being counted. Used as the element's
|
||||||
|
* key for react to iterate upon.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function createTimeDisplay(count, countNounKey, t) {
|
||||||
|
return t(countNounKey, { count });
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import type { Dispatch } from 'redux';
|
||||||
|
|
||||||
|
import { Dialog } from '../../../base/dialog';
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
|
import { connect } from '../../../base/redux';
|
||||||
|
import { escapeRegexp } from '../../../base/util';
|
||||||
|
import { initSearch } from '../../actions';
|
||||||
|
|
||||||
|
import SpeakerStatsLabels from './SpeakerStatsLabels';
|
||||||
|
import SpeakerStatsList from './SpeakerStatsList';
|
||||||
|
import SpeakerStatsSearch from './SpeakerStatsSearch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the React {@code Component} props of {@link SpeakerStats}.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flag which shows if the facial recognition is enabled, obtained from the redux store.
|
||||||
|
* if enabled facial expressions are shown.
|
||||||
|
*/
|
||||||
|
_showFacialExpressions: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the client width is les than 750.
|
||||||
|
*/
|
||||||
|
_reduceExpressions: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search criteria.
|
||||||
|
*/
|
||||||
|
_criteria: string | null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redux store dispatch method.
|
||||||
|
*/
|
||||||
|
dispatch: Dispatch<any>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function to translate human-readable text.
|
||||||
|
*/
|
||||||
|
t: Function
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React component for displaying a list of speaker stats.
|
||||||
|
*
|
||||||
|
* @augments Component
|
||||||
|
*/
|
||||||
|
class SpeakerStats extends Component<Props> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a new SpeakerStats instance.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The read-only React Component props with which
|
||||||
|
* the new instance is to be initialized.
|
||||||
|
*/
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
// Bind event handlers so they are only bound once per instance.
|
||||||
|
this._onSearch = this._onSearch.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements React's {@link Component#render()}.
|
||||||
|
*
|
||||||
|
* @inheritdoc
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
cancelKey = 'dialog.close'
|
||||||
|
submitDisabled = { true }
|
||||||
|
titleKey = 'speakerStats.speakerStats'
|
||||||
|
width = { this.props._showFacialExpressions ? 'large' : 'medium' }>
|
||||||
|
<div className = 'speaker-stats'>
|
||||||
|
<SpeakerStatsSearch onSearch = { this._onSearch } />
|
||||||
|
<SpeakerStatsLabels
|
||||||
|
reduceExpressions = { this.props._reduceExpressions }
|
||||||
|
showFacialExpressions = { this.props._showFacialExpressions ?? false } />
|
||||||
|
<SpeakerStatsList />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSearch: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the existing participants by name.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
* @param {string} criteria - The search parameter.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
_onSearch(criteria = '') {
|
||||||
|
this.props.dispatch(initSearch(escapeRegexp(criteria)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps (parts of) the redux state to the associated SpeakerStats's props.
|
||||||
|
*
|
||||||
|
* @param {Object} state - The redux state.
|
||||||
|
* @private
|
||||||
|
* @returns {{
|
||||||
|
* _showFacialExpressions: ?boolean,
|
||||||
|
* _reduceExpressions: boolean,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function _mapStateToProps(state) {
|
||||||
|
const { enableFacialRecognition } = state['features/base/config'];
|
||||||
|
const { clientWidth } = state['features/base/responsive-ui'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* The local display name.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @type {string|undefined}
|
||||||
|
*/
|
||||||
|
_showFacialExpressions: enableFacialRecognition,
|
||||||
|
_reduceExpressions: clientWidth < 750
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translate(connect(_mapStateToProps)(SpeakerStats));
|
|
@ -0,0 +1,36 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import { createToolbarEvent, sendAnalytics } from '../../../analytics';
|
||||||
|
import { openDialog } from '../../../base/dialog';
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
|
import { connect } from '../../../base/redux';
|
||||||
|
import AbstractSpeakerStatsButton from '../AbstractSpeakerStatsButton';
|
||||||
|
|
||||||
|
import { SpeakerStats } from './';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of a button for opening speaker stats dialog.
|
||||||
|
*/
|
||||||
|
class SpeakerStatsButton extends AbstractSpeakerStatsButton {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_handleClick() {
|
||||||
|
const { dispatch, handleClick } = this.props;
|
||||||
|
|
||||||
|
if (handleClick) {
|
||||||
|
handleClick();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAnalytics(createToolbarEvent('speaker.stats'));
|
||||||
|
dispatch(openDialog(SpeakerStats));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translate(connect()(SpeakerStatsButton));
|
|
@ -0,0 +1,139 @@
|
||||||
|
/* @flow */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import TimeElapsed from './TimeElapsed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the React {@code Component} props of {@link SpeakerStatsItem}.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the participant.
|
||||||
|
*/
|
||||||
|
displayName: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object that has as keys the facial expressions of the
|
||||||
|
* participant and as values a number that represents the count .
|
||||||
|
*/
|
||||||
|
facialExpressions: Object,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the client width is les than 750.
|
||||||
|
*/
|
||||||
|
reduceExpressions: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the facial recognition is not disabled.
|
||||||
|
*/
|
||||||
|
showFacialExpressions: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total milliseconds the participant has been dominant speaker.
|
||||||
|
*/
|
||||||
|
dominantSpeakerTime: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is no longer in the meeting.
|
||||||
|
*/
|
||||||
|
hasLeft: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is currently the dominant speaker.
|
||||||
|
*/
|
||||||
|
isDominantSpeaker: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked to obtain translated strings.
|
||||||
|
*/
|
||||||
|
t: Function
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpeakerStatsItem = (props: Props) => {
|
||||||
|
/**
|
||||||
|
* Implements React's {@link Component#render()}.
|
||||||
|
*
|
||||||
|
* @inheritdoc
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
const hasLeftClass = props.hasLeft ? 'status-user-left' : '';
|
||||||
|
const rowDisplayClass = `speaker-stats-item ${hasLeftClass}`;
|
||||||
|
|
||||||
|
const dotClass = props.isDominantSpeaker
|
||||||
|
? 'status-active' : 'status-inactive';
|
||||||
|
const speakerStatusClass = `speaker-stats-item__status-dot ${dotClass}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className = { rowDisplayClass }
|
||||||
|
key = { props.displayName } >
|
||||||
|
<div className = 'speaker-stats-item__status'>
|
||||||
|
<span className = { speakerStatusClass } />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { props.t('speakerStats.speakerStats') }
|
||||||
|
className = { `speaker-stats-item__name${
|
||||||
|
props.showFacialExpressions ? '_expressions_on' : ''
|
||||||
|
}` }>
|
||||||
|
{ props.displayName }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { props.t('speakerStats.speakerTime') }
|
||||||
|
className = { `speaker-stats-item__time${
|
||||||
|
props.showFacialExpressions ? '_expressions_on' : ''
|
||||||
|
}` }>
|
||||||
|
<TimeElapsed
|
||||||
|
time = { props.dominantSpeakerTime } />
|
||||||
|
</div>
|
||||||
|
{ props.showFacialExpressions
|
||||||
|
&& (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Happy' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.happy }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Neutral' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.neutral }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Sad' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.sad }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Surprised' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.surprised }
|
||||||
|
</div>
|
||||||
|
{ !props.reduceExpressions && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Angry' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.angry }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Fearful' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.fearful }
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label = { 'Disgusted' }
|
||||||
|
className = 'speaker-stats-item__expression'>
|
||||||
|
{ props.facialExpressions.disgusted }
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpeakerStatsItem;
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
import { translate } from '../../base/i18n';
|
import { translate } from '../../../base/i18n';
|
||||||
import { Tooltip } from '../../base/tooltip';
|
import { Tooltip } from '../../../base/tooltip';
|
||||||
import { FACIAL_EXPRESSION_EMOJIS } from '../../facial-recognition/constants.js';
|
import { FACIAL_EXPRESSION_EMOJIS } from '../../../facial-recognition/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of the React {@code Component} props of {@link SpeakerStatsLabels}.
|
* The type of the React {@code Component} props of {@link SpeakerStatsLabels}.
|
||||||
|
@ -24,7 +24,7 @@ type Props = {
|
||||||
/**
|
/**
|
||||||
* The function to translate human-readable text.
|
* The function to translate human-readable text.
|
||||||
*/
|
*/
|
||||||
t: Function,
|
t: Function
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,7 +57,7 @@ class SpeakerStatsLabels extends Component<Props> {
|
||||||
}` }>
|
}` }>
|
||||||
{ t('speakerStats.speakerTime') }
|
{ t('speakerStats.speakerTime') }
|
||||||
</div>
|
</div>
|
||||||
{this.props.showFacialExpressions
|
{ this.props.showFacialExpressions
|
||||||
&& (this.props.reduceExpressions
|
&& (this.props.reduceExpressions
|
||||||
? Object.keys(FACIAL_EXPRESSION_EMOJIS)
|
? Object.keys(FACIAL_EXPRESSION_EMOJIS)
|
||||||
.filter(expression => ![ 'angry', 'fearful', 'disgusted' ].includes(expression))
|
.filter(expression => ![ 'angry', 'fearful', 'disgusted' ].includes(expression))
|
|
@ -0,0 +1,25 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import abstractSpeakerStatsList from '../AbstractSpeakerStatsList';
|
||||||
|
|
||||||
|
import SpeakerStatsItem from './SpeakerStatsItem';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders the list of speaker stats.
|
||||||
|
*
|
||||||
|
* @returns {React$Element<any>}
|
||||||
|
*/
|
||||||
|
const SpeakerStatsList = () => {
|
||||||
|
const items = abstractSpeakerStatsList(SpeakerStatsItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default SpeakerStatsList;
|
|
@ -6,8 +6,8 @@ import React, { useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getFieldValue } from '../../base/react';
|
import { getFieldValue } from '../../../base/react';
|
||||||
import { isSpeakerStatsSearchDisabled } from '../functions';
|
import { isSpeakerStatsSearchDisabled } from '../../functions';
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => {
|
const useStyles = makeStyles(theme => {
|
||||||
return {
|
return {
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* @flow */
|
||||||
|
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
|
import { createLocalizedTime } from '../timeFunctions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the React {@code Component} props of {@link TimeElapsed}.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function to translate human-readable text.
|
||||||
|
*/
|
||||||
|
t: Function,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The milliseconds to be converted into a human-readable format.
|
||||||
|
*/
|
||||||
|
time: number
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React component for displaying total time elapsed. Converts a total count of
|
||||||
|
* milliseconds into a more humanized form: "# hours, # minutes, # seconds".
|
||||||
|
* With a time of 0, "0s" will be displayed.
|
||||||
|
*
|
||||||
|
* @augments Component
|
||||||
|
*/
|
||||||
|
class TimeElapsed extends Component<Props> {
|
||||||
|
/**
|
||||||
|
* Implements React's {@link Component#render()}.
|
||||||
|
*
|
||||||
|
* @inheritdoc
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
render() {
|
||||||
|
const { time, t } = this.props;
|
||||||
|
const timeElapsed = createLocalizedTime(time, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{ timeElapsed }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translate(TimeElapsed);
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
|
||||||
|
export { default as SpeakerStats } from './SpeakerStats';
|
|
@ -1 +1,3 @@
|
||||||
export const SPEAKER_STATS_RELOAD_INTERVAL = 1000;
|
export const SPEAKER_STATS_RELOAD_INTERVAL = 1000;
|
||||||
|
|
||||||
|
export const SPEAKER_STATS_VIEW_MODEL_ID = 'speakerStats';
|
||||||
|
|
|
@ -2,7 +2,10 @@
|
||||||
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { getParticipantById, PARTICIPANT_ROLE } from '../base/participants';
|
import {
|
||||||
|
getParticipantById,
|
||||||
|
PARTICIPANT_ROLE
|
||||||
|
} from '../base/participants';
|
||||||
import { objectSort } from '../base/util';
|
import { objectSort } from '../base/util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -8,7 +8,10 @@ import {
|
||||||
} from '../base/participants/actionTypes';
|
} from '../base/participants/actionTypes';
|
||||||
import { MiddlewareRegistry } from '../base/redux';
|
import { MiddlewareRegistry } from '../base/redux';
|
||||||
|
|
||||||
import { INIT_SEARCH, INIT_UPDATE_STATS } from './actionTypes';
|
import {
|
||||||
|
INIT_SEARCH,
|
||||||
|
INIT_UPDATE_STATS
|
||||||
|
} from './actionTypes';
|
||||||
import { initReorderStats, updateStats } from './actions';
|
import { initReorderStats, updateStats } from './actions';
|
||||||
import { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder } from './functions';
|
import { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder } from './functions';
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
*/
|
*/
|
||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
stats: {},
|
stats: {},
|
||||||
|
isOpen: false,
|
||||||
pendingReorder: true,
|
pendingReorder: true,
|
||||||
criteria: null
|
criteria: null
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { isReactionsEnabled } from '../../../reactions/functions.any';
|
||||||
import { LiveStreamButton, RecordButton } from '../../../recording';
|
import { LiveStreamButton, RecordButton } from '../../../recording';
|
||||||
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
||||||
import { SharedVideoButton } from '../../../shared-video/components';
|
import { SharedVideoButton } from '../../../shared-video/components';
|
||||||
|
import SpeakerStatsButton from '../../../speaker-stats/components/native/SpeakerStatsButton';
|
||||||
import { ClosedCaptionButton } from '../../../subtitles';
|
import { ClosedCaptionButton } from '../../../subtitles';
|
||||||
import { TileViewButton } from '../../../video-layout';
|
import { TileViewButton } from '../../../video-layout';
|
||||||
import styles from '../../../video-menu/components/native/styles';
|
import styles from '../../../video-menu/components/native/styles';
|
||||||
|
@ -151,6 +152,7 @@ class OverflowMenu extends PureComponent<Props, State> {
|
||||||
<Divider style = { styles.divider } />
|
<Divider style = { styles.divider } />
|
||||||
<SharedVideoButton { ...buttonProps } />
|
<SharedVideoButton { ...buttonProps } />
|
||||||
<ScreenSharingButton { ...buttonProps } />
|
<ScreenSharingButton { ...buttonProps } />
|
||||||
|
<SpeakerStatsButton { ...buttonProps } />
|
||||||
{!toolbarButtons.has('togglecamera') && <ToggleCameraButton { ...buttonProps } />}
|
{!toolbarButtons.has('togglecamera') && <ToggleCameraButton { ...buttonProps } />}
|
||||||
{!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />}
|
{!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />}
|
||||||
<Divider style = { styles.divider } />
|
<Divider style = { styles.divider } />
|
||||||
|
|
|
@ -10,8 +10,7 @@ import {
|
||||||
createToolbarEvent,
|
createToolbarEvent,
|
||||||
sendAnalytics
|
sendAnalytics
|
||||||
} from '../../../analytics';
|
} from '../../../analytics';
|
||||||
import { getToolbarButtons } from '../../../base/config';
|
import { getToolbarButtons, isToolbarButtonEnabled } from '../../../base/config';
|
||||||
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
|
|
||||||
import { openDialog, toggleDialog } from '../../../base/dialog';
|
import { openDialog, toggleDialog } from '../../../base/dialog';
|
||||||
import { isIosMobileBrowser, isMobileBrowser } from '../../../base/environment/utils';
|
import { isIosMobileBrowser, isMobileBrowser } from '../../../base/environment/utils';
|
||||||
import { translate } from '../../../base/i18n';
|
import { translate } from '../../../base/i18n';
|
||||||
|
@ -57,7 +56,7 @@ import {
|
||||||
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
||||||
import { SettingsButton } from '../../../settings';
|
import { SettingsButton } from '../../../settings';
|
||||||
import { SharedVideoButton } from '../../../shared-video/components';
|
import { SharedVideoButton } from '../../../shared-video/components';
|
||||||
import { SpeakerStatsButton } from '../../../speaker-stats';
|
import { SpeakerStatsButton } from '../../../speaker-stats/components/web';
|
||||||
import {
|
import {
|
||||||
ClosedCaptionButton
|
ClosedCaptionButton
|
||||||
} from '../../../subtitles';
|
} from '../../../subtitles';
|
||||||
|
@ -67,8 +66,7 @@ import {
|
||||||
toggleTileView
|
toggleTileView
|
||||||
} from '../../../video-layout';
|
} from '../../../video-layout';
|
||||||
import { VideoQualityDialog, VideoQualityButton } from '../../../video-quality/components';
|
import { VideoQualityDialog, VideoQualityButton } from '../../../video-quality/components';
|
||||||
import { VideoBackgroundButton } from '../../../virtual-background';
|
import { VideoBackgroundButton, toggleBackgroundEffect } from '../../../virtual-background';
|
||||||
import { toggleBackgroundEffect } from '../../../virtual-background/actions';
|
|
||||||
import { VIRTUAL_BACKGROUND_TYPE } from '../../../virtual-background/constants';
|
import { VIRTUAL_BACKGROUND_TYPE } from '../../../virtual-background/constants';
|
||||||
import {
|
import {
|
||||||
setFullScreen,
|
setFullScreen,
|
||||||
|
|
Loading…
Reference in New Issue