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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Default: enabled (true).
|
||||
|
|
|
@ -173,6 +173,9 @@ export const colorMap = {
|
|||
// Quaternary color for disabled actions
|
||||
icon04: 'surface14',
|
||||
|
||||
// Quinary color for disabled actions
|
||||
icon05: 'surface04',
|
||||
|
||||
// Error message
|
||||
iconError: 'error06',
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ import AddPeopleDialog
|
|||
from '../../../invite/components/add-people-dialog/native/AddPeopleDialog';
|
||||
import LobbyScreen from '../../../lobby/components/native/LobbyScreen';
|
||||
import { ParticipantsPane } from '../../../participants-pane/components/native';
|
||||
import SpeakerStats
|
||||
from '../../../speaker-stats/components/native/SpeakerStats';
|
||||
import { getDisablePolls } from '../../functions';
|
||||
|
||||
import Conference from './Conference';
|
||||
|
@ -26,7 +28,8 @@ import {
|
|||
lobbyScreenOptions,
|
||||
navigationContainerTheme,
|
||||
participantsScreenOptions,
|
||||
sharedDocumentScreenOptions
|
||||
sharedDocumentScreenOptions,
|
||||
speakerStatsScreenOptions
|
||||
} from './ConferenceNavigatorScreenOptions';
|
||||
import { screen } from './routes';
|
||||
|
||||
|
@ -72,6 +75,12 @@ const ConferenceNavigationContainer = () => {
|
|||
...participantsScreenOptions,
|
||||
title: t('participantsPane.header')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { SpeakerStats }
|
||||
name = { screen.conference.speakerStats }
|
||||
options = {{
|
||||
...speakerStatsScreenOptions
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { LobbyScreen }
|
||||
name = { screen.lobby }
|
||||
|
|
|
@ -215,6 +215,13 @@ export const participantsScreenOptions = {
|
|||
...presentationScreenOptions
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for speaker stats modal.
|
||||
*/
|
||||
export const speakerStatsScreenOptions = {
|
||||
...presentationScreenOptions
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for shared document.
|
||||
*/
|
||||
|
|
|
@ -21,6 +21,7 @@ export const screen = {
|
|||
polls: 'Polls'
|
||||
}
|
||||
},
|
||||
speakerStats: 'Speaker Stats',
|
||||
participants: 'Participants',
|
||||
invite: 'Invite',
|
||||
sharedDocument: 'Shared document'
|
||||
|
|
|
@ -76,7 +76,7 @@ MiddlewareRegistry.register(store => next => action => {
|
|||
const { id, role } = action.participant;
|
||||
const localParticipant = getLocalParticipant(state);
|
||||
|
||||
if (localParticipant.id !== id) {
|
||||
if (localParticipant?.id !== id) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
|
|
|
@ -37,3 +37,4 @@ export const UPDATE_STATS = 'UPDATE_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 { default as SpeakerStats } from './SpeakerStats';
|
||||
export * from './_';
|
||||
|
|
|
@ -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 { translate } from '../../base/i18n';
|
||||
import { Tooltip } from '../../base/tooltip';
|
||||
import { FACIAL_EXPRESSION_EMOJIS } from '../../facial-recognition/constants.js';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { Tooltip } from '../../../base/tooltip';
|
||||
import { FACIAL_EXPRESSION_EMOJIS } from '../../../facial-recognition/constants.js';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SpeakerStatsLabels}.
|
||||
|
@ -24,7 +24,7 @@ type Props = {
|
|||
/**
|
||||
* The function to translate human-readable text.
|
||||
*/
|
||||
t: Function,
|
||||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -57,7 +57,7 @@ class SpeakerStatsLabels extends Component<Props> {
|
|||
}` }>
|
||||
{ t('speakerStats.speakerTime') }
|
||||
</div>
|
||||
{this.props.showFacialExpressions
|
||||
{ this.props.showFacialExpressions
|
||||
&& (this.props.reduceExpressions
|
||||
? Object.keys(FACIAL_EXPRESSION_EMOJIS)
|
||||
.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 { useSelector } from 'react-redux';
|
||||
|
||||
import { getFieldValue } from '../../base/react';
|
||||
import { isSpeakerStatsSearchDisabled } from '../functions';
|
||||
import { getFieldValue } from '../../../base/react';
|
||||
import { isSpeakerStatsSearchDisabled } from '../../functions';
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
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_VIEW_MODEL_ID = 'speakerStats';
|
||||
|
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
|
||||
import { getParticipantById, PARTICIPANT_ROLE } from '../base/participants';
|
||||
import {
|
||||
getParticipantById,
|
||||
PARTICIPANT_ROLE
|
||||
} from '../base/participants';
|
||||
import { objectSort } from '../base/util';
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,7 +8,10 @@ import {
|
|||
} from '../base/participants/actionTypes';
|
||||
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 { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder } from './functions';
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
*/
|
||||
const INITIAL_STATE = {
|
||||
stats: {},
|
||||
isOpen: false,
|
||||
pendingReorder: true,
|
||||
criteria: null
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ import { isReactionsEnabled } from '../../../reactions/functions.any';
|
|||
import { LiveStreamButton, RecordButton } from '../../../recording';
|
||||
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
||||
import { SharedVideoButton } from '../../../shared-video/components';
|
||||
import SpeakerStatsButton from '../../../speaker-stats/components/native/SpeakerStatsButton';
|
||||
import { ClosedCaptionButton } from '../../../subtitles';
|
||||
import { TileViewButton } from '../../../video-layout';
|
||||
import styles from '../../../video-menu/components/native/styles';
|
||||
|
@ -151,6 +152,7 @@ class OverflowMenu extends PureComponent<Props, State> {
|
|||
<Divider style = { styles.divider } />
|
||||
<SharedVideoButton { ...buttonProps } />
|
||||
<ScreenSharingButton { ...buttonProps } />
|
||||
<SpeakerStatsButton { ...buttonProps } />
|
||||
{!toolbarButtons.has('togglecamera') && <ToggleCameraButton { ...buttonProps } />}
|
||||
{!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />}
|
||||
<Divider style = { styles.divider } />
|
||||
|
|
|
@ -10,8 +10,7 @@ import {
|
|||
createToolbarEvent,
|
||||
sendAnalytics
|
||||
} from '../../../analytics';
|
||||
import { getToolbarButtons } from '../../../base/config';
|
||||
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
|
||||
import { getToolbarButtons, isToolbarButtonEnabled } from '../../../base/config';
|
||||
import { openDialog, toggleDialog } from '../../../base/dialog';
|
||||
import { isIosMobileBrowser, isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { translate } from '../../../base/i18n';
|
||||
|
@ -57,7 +56,7 @@ import {
|
|||
import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton';
|
||||
import { SettingsButton } from '../../../settings';
|
||||
import { SharedVideoButton } from '../../../shared-video/components';
|
||||
import { SpeakerStatsButton } from '../../../speaker-stats';
|
||||
import { SpeakerStatsButton } from '../../../speaker-stats/components/web';
|
||||
import {
|
||||
ClosedCaptionButton
|
||||
} from '../../../subtitles';
|
||||
|
@ -67,8 +66,7 @@ import {
|
|||
toggleTileView
|
||||
} from '../../../video-layout';
|
||||
import { VideoQualityDialog, VideoQualityButton } from '../../../video-quality/components';
|
||||
import { VideoBackgroundButton } from '../../../virtual-background';
|
||||
import { toggleBackgroundEffect } from '../../../virtual-background/actions';
|
||||
import { VideoBackgroundButton, toggleBackgroundEffect } from '../../../virtual-background';
|
||||
import { VIRTUAL_BACKGROUND_TYPE } from '../../../virtual-background/constants';
|
||||
import {
|
||||
setFullScreen,
|
||||
|
|
Loading…
Reference in New Issue