feat: (speaker-stats) add speaker stats feature to native

This commit is contained in:
Andrei Oltean 2021-11-10 19:49:53 +02:00 committed by Calinteodor
parent cc63ff1c3c
commit 70b8ccc097
42 changed files with 949 additions and 653 deletions

View File

@ -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).

View File

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

View File

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

View File

@ -215,6 +215,13 @@ export const participantsScreenOptions = {
...presentationScreenOptions
};
/**
* Screen options for speaker stats modal.
*/
export const speakerStatsScreenOptions = {
...presentationScreenOptions
};
/**
* Screen options for shared document.
*/

View File

@ -21,6 +21,7 @@ export const screen = {
polls: 'Polls'
}
},
speakerStats: 'Speaker Stats',
participants: 'Participants',
invite: 'Invite',
sharedDocument: 'Shared document'

View File

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

View File

@ -37,3 +37,4 @@ export const UPDATE_STATS = 'UPDATE_STATS';
* }
*/
export const INIT_REORDER_STATS = 'INIT_REORDER_STATS';

View File

@ -0,0 +1,3 @@
// @flow
export * from './actions.any';

View File

@ -0,0 +1,3 @@
// @flow
export * from './actions.any';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './native';

View File

@ -0,0 +1 @@
export * from './web';

View File

@ -1,2 +1 @@
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
export { default as SpeakerStats } from './SpeakerStats';
export * from './_';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
// @flow
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
export { default as SpeakerStats } from './SpeakerStats';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -0,0 +1,2 @@
export { default as SpeakerStatsButton } from './SpeakerStatsButton';
export { default as SpeakerStats } from './SpeakerStats';

View File

@ -1 +1,3 @@
export const SPEAKER_STATS_RELOAD_INTERVAL = 1000;
export const SPEAKER_STATS_VIEW_MODEL_ID = 'speakerStats';

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import {
*/
const INITIAL_STATE = {
stats: {},
isOpen: false,
pendingReorder: true,
criteria: null
};

View File

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

View File

@ -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,