diff --git a/react/features/base/flags/constants.js b/react/features/base/flags/constants.js index f34e3a0e2..72d16c145 100644 --- a/react/features/base/flags/constants.js +++ b/react/features/base/flags/constants.js @@ -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). diff --git a/react/features/base/ui/Tokens.js b/react/features/base/ui/Tokens.js index 6f2e38508..48147ccf3 100644 --- a/react/features/base/ui/Tokens.js +++ b/react/features/base/ui/Tokens.js @@ -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', diff --git a/react/features/conference/components/native/ConferenceNavigationContainer.js b/react/features/conference/components/native/ConferenceNavigationContainer.js index 4116c8179..a0a8681f0 100644 --- a/react/features/conference/components/native/ConferenceNavigationContainer.js +++ b/react/features/conference/components/native/ConferenceNavigationContainer.js @@ -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') }} /> + next => action => { const { id, role } = action.participant; const localParticipant = getLocalParticipant(state); - if (localParticipant.id !== id) { + if (localParticipant?.id !== id) { return next(action); } diff --git a/react/features/speaker-stats/actionTypes.js b/react/features/speaker-stats/actionTypes.js index 9867c9375..5d8ce6b57 100644 --- a/react/features/speaker-stats/actionTypes.js +++ b/react/features/speaker-stats/actionTypes.js @@ -37,3 +37,4 @@ export const UPDATE_STATS = 'UPDATE_STATS'; * } */ export const INIT_REORDER_STATS = 'INIT_REORDER_STATS'; + diff --git a/react/features/speaker-stats/actions.js b/react/features/speaker-stats/actions.any.js similarity index 100% rename from react/features/speaker-stats/actions.js rename to react/features/speaker-stats/actions.any.js diff --git a/react/features/speaker-stats/actions.native.js b/react/features/speaker-stats/actions.native.js new file mode 100644 index 000000000..7e5279fe7 --- /dev/null +++ b/react/features/speaker-stats/actions.native.js @@ -0,0 +1,3 @@ +// @flow + +export * from './actions.any'; diff --git a/react/features/speaker-stats/actions.web.js b/react/features/speaker-stats/actions.web.js new file mode 100644 index 000000000..7e5279fe7 --- /dev/null +++ b/react/features/speaker-stats/actions.web.js @@ -0,0 +1,3 @@ +// @flow + +export * from './actions.any'; diff --git a/react/features/speaker-stats/components/AbstractSpeakerStatsButton.js b/react/features/speaker-stats/components/AbstractSpeakerStatsButton.js new file mode 100644 index 000000000..05d8e4282 --- /dev/null +++ b/react/features/speaker-stats/components/AbstractSpeakerStatsButton.js @@ -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 +}; + + +/** + * Implementation of a button for opening speaker stats dialog. + */ +class AbstractSpeakerStatsButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.speakerStats'; + icon = IconPresentation; + label = 'toolbar.speakerStats'; + tooltip = 'toolbar.speakerStats'; +} + +export default AbstractSpeakerStatsButton; diff --git a/react/features/speaker-stats/components/AbstractSpeakerStatsList.js b/react/features/speaker-stats/components/AbstractSpeakerStatsList.js new file mode 100644 index 000000000..3092c8f24 --- /dev/null +++ b/react/features/speaker-stats/components/AbstractSpeakerStatsList.js @@ -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; diff --git a/react/features/speaker-stats/components/SpeakerStats.js b/react/features/speaker-stats/components/SpeakerStats.js deleted file mode 100644 index 75ed30861..000000000 --- a/react/features/speaker-stats/components/SpeakerStats.js +++ /dev/null @@ -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, - - /** - * 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, - - /** - * 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 { - _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 ( - -
- - - { items } -
-
- ); - } - - /** - * 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 ( - - ); - } - - _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)); diff --git a/react/features/speaker-stats/components/SpeakerStatsButton.js b/react/features/speaker-stats/components/SpeakerStatsButton.js deleted file mode 100644 index 4c56762b2..000000000 --- a/react/features/speaker-stats/components/SpeakerStatsButton.js +++ /dev/null @@ -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 { - 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)); diff --git a/react/features/speaker-stats/components/SpeakerStatsItem.js b/react/features/speaker-stats/components/SpeakerStatsItem.js deleted file mode 100644 index 1eba1b253..000000000 --- a/react/features/speaker-stats/components/SpeakerStatsItem.js +++ /dev/null @@ -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 { - /** - * 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 ( -
-
- -
-
- { this.props.displayName } -
-
- -
- { this.props.showFacialExpressions - && ( - <> -
- { this.props.facialExpressions.happy } -
-
- { this.props.facialExpressions.neutral } -
-
- { this.props.facialExpressions.sad } -
-
- { this.props.facialExpressions.surprised } -
- {!this.props.reduceExpressions && ( - <> -
- { this.props.facialExpressions.angry } -
-
- { this.props.facialExpressions.fearful } -
-
- { this.props.facialExpressions.disgusted } -
- - )} - - ) - } - - -
- ); - } -} - -export default translate(SpeakerStatsItem); diff --git a/react/features/speaker-stats/components/TimeElapsed.js b/react/features/speaker-stats/components/TimeElapsed.js deleted file mode 100644 index 670183091..000000000 --- a/react/features/speaker-stats/components/TimeElapsed.js +++ /dev/null @@ -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 { - /** - * 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 ( -
- { timeElapsed } -
- ); - } - - /** - * 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 ( - - { t(countNounKey, { count }) } - - ); - } -} - -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); -} diff --git a/react/features/speaker-stats/components/_.native.js b/react/features/speaker-stats/components/_.native.js new file mode 100644 index 000000000..738c4d2b8 --- /dev/null +++ b/react/features/speaker-stats/components/_.native.js @@ -0,0 +1 @@ +export * from './native'; diff --git a/react/features/speaker-stats/components/_.web.js b/react/features/speaker-stats/components/_.web.js new file mode 100644 index 000000000..b80c83af3 --- /dev/null +++ b/react/features/speaker-stats/components/_.web.js @@ -0,0 +1 @@ +export * from './web'; diff --git a/react/features/speaker-stats/components/index.js b/react/features/speaker-stats/components/index.js index 7787e1711..cda61441e 100644 --- a/react/features/speaker-stats/components/index.js +++ b/react/features/speaker-stats/components/index.js @@ -1,2 +1 @@ -export { default as SpeakerStatsButton } from './SpeakerStatsButton'; -export { default as SpeakerStats } from './SpeakerStats'; +export * from './_'; diff --git a/react/features/speaker-stats/components/native/SpeakerStats.js b/react/features/speaker-stats/components/native/SpeakerStats.js new file mode 100644 index 000000000..c7c8f9a02 --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStats.js @@ -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} + */ +const SpeakerStats = () => ( + + + + +); + +export default SpeakerStats; diff --git a/react/features/speaker-stats/components/native/SpeakerStatsButton.js b/react/features/speaker-stats/components/native/SpeakerStatsButton.js new file mode 100644 index 000000000..e9d557330 --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStatsButton.js @@ -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)); diff --git a/react/features/speaker-stats/components/native/SpeakerStatsItem.js b/react/features/speaker-stats/components/native/SpeakerStatsItem.js new file mode 100644 index 000000000..50eb3f873 --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStatsItem.js @@ -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 ( + + + + + + + { props.displayName } + + + + + + + ); +}; + +export default SpeakerStatsItem; diff --git a/react/features/speaker-stats/components/native/SpeakerStatsLabels.js b/react/features/speaker-stats/components/native/SpeakerStatsLabels.js new file mode 100644 index 000000000..d1f8fc54a --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStatsLabels.js @@ -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 ( + + + + + { t('speakerStats.name') } + + + + + { t('speakerStats.speakerTime') } + + + + ); +}; + +export default SpeakerStatsLabels; diff --git a/react/features/speaker-stats/components/native/SpeakerStatsList.js b/react/features/speaker-stats/components/native/SpeakerStatsList.js new file mode 100644 index 000000000..3fc6071c0 --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStatsList.js @@ -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} + */ +const SpeakerStatsList = () => { + const items = abstractSpeakerStatsList(SpeakerStatsItem, false); + + return ( + + {items} + + ); +}; + + +export default SpeakerStatsList; diff --git a/react/features/speaker-stats/components/native/TimeElapsed.js b/react/features/speaker-stats/components/native/TimeElapsed.js new file mode 100644 index 000000000..56f79c3d6 --- /dev/null +++ b/react/features/speaker-stats/components/native/TimeElapsed.js @@ -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 { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { time, t } = this.props; + const timeElapsed = createLocalizedTime(time, t); + + return ( + + { timeElapsed } + + ); + } +} + +export default translate(TimeElapsed); diff --git a/react/features/speaker-stats/components/native/index.js b/react/features/speaker-stats/components/native/index.js new file mode 100644 index 000000000..c414a4250 --- /dev/null +++ b/react/features/speaker-stats/components/native/index.js @@ -0,0 +1,4 @@ +// @flow +export { default as SpeakerStatsButton } from './SpeakerStatsButton'; +export { default as SpeakerStats } from './SpeakerStats'; + diff --git a/react/features/speaker-stats/components/native/styles.js b/react/features/speaker-stats/components/native/styles.js new file mode 100644 index 000000000..ce840e3f1 --- /dev/null +++ b/react/features/speaker-stats/components/native/styles.js @@ -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' + } + + +}; diff --git a/react/features/speaker-stats/components/timeFunctions.js b/react/features/speaker-stats/components/timeFunctions.js new file mode 100644 index 000000000..2b2ce7b1f --- /dev/null +++ b/react/features/speaker-stats/components/timeFunctions.js @@ -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 }); +} diff --git a/react/features/speaker-stats/components/web/SpeakerStats.js b/react/features/speaker-stats/components/web/SpeakerStats.js new file mode 100644 index 000000000..acb4095ec --- /dev/null +++ b/react/features/speaker-stats/components/web/SpeakerStats.js @@ -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, + + /** + * The function to translate human-readable text. + */ + t: Function +}; + +/** + * React component for displaying a list of speaker stats. + * + * @augments Component + */ +class SpeakerStats extends Component { + + /** + * 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 ( + +
+ + + +
+
+ ); + } + + _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)); diff --git a/react/features/speaker-stats/components/web/SpeakerStatsButton.js b/react/features/speaker-stats/components/web/SpeakerStatsButton.js new file mode 100644 index 000000000..0bbf280c8 --- /dev/null +++ b/react/features/speaker-stats/components/web/SpeakerStatsButton.js @@ -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)); diff --git a/react/features/speaker-stats/components/web/SpeakerStatsItem.js b/react/features/speaker-stats/components/web/SpeakerStatsItem.js new file mode 100644 index 000000000..fd7d19bb1 --- /dev/null +++ b/react/features/speaker-stats/components/web/SpeakerStatsItem.js @@ -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 ( +
+
+ +
+
+ { props.displayName } +
+
+ +
+ { props.showFacialExpressions + && ( + <> +
+ { props.facialExpressions.happy } +
+
+ { props.facialExpressions.neutral } +
+
+ { props.facialExpressions.sad } +
+
+ { props.facialExpressions.surprised } +
+ { !props.reduceExpressions && ( + <> +
+ { props.facialExpressions.angry } +
+
+ { props.facialExpressions.fearful } +
+
+ { props.facialExpressions.disgusted } +
+ + )} + + ) + } +
+ ); +}; + +export default SpeakerStatsItem; diff --git a/react/features/speaker-stats/components/SpeakerStatsLabels.js b/react/features/speaker-stats/components/web/SpeakerStatsLabels.js similarity index 91% rename from react/features/speaker-stats/components/SpeakerStatsLabels.js rename to react/features/speaker-stats/components/web/SpeakerStatsLabels.js index 5bcf30f2b..acce91b29 100644 --- a/react/features/speaker-stats/components/SpeakerStatsLabels.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsLabels.js @@ -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 { }` }> { t('speakerStats.speakerTime') } - {this.props.showFacialExpressions + { this.props.showFacialExpressions && (this.props.reduceExpressions ? Object.keys(FACIAL_EXPRESSION_EMOJIS) .filter(expression => ![ 'angry', 'fearful', 'disgusted' ].includes(expression)) diff --git a/react/features/speaker-stats/components/web/SpeakerStatsList.js b/react/features/speaker-stats/components/web/SpeakerStatsList.js new file mode 100644 index 000000000..4074cd192 --- /dev/null +++ b/react/features/speaker-stats/components/web/SpeakerStatsList.js @@ -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} + */ +const SpeakerStatsList = () => { + const items = abstractSpeakerStatsList(SpeakerStatsItem); + + return ( +
+ {items} +
+ ); +}; + + +export default SpeakerStatsList; diff --git a/react/features/speaker-stats/components/SpeakerStatsSearch.js b/react/features/speaker-stats/components/web/SpeakerStatsSearch.js similarity index 94% rename from react/features/speaker-stats/components/SpeakerStatsSearch.js rename to react/features/speaker-stats/components/web/SpeakerStatsSearch.js index 5b8bf1492..0c2a2a598 100644 --- a/react/features/speaker-stats/components/SpeakerStatsSearch.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsSearch.js @@ -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 { diff --git a/react/features/speaker-stats/components/web/TimeElapsed.js b/react/features/speaker-stats/components/web/TimeElapsed.js new file mode 100644 index 000000000..ce17c5354 --- /dev/null +++ b/react/features/speaker-stats/components/web/TimeElapsed.js @@ -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 { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { time, t } = this.props; + const timeElapsed = createLocalizedTime(time, t); + + return ( +
+ { timeElapsed } +
+ ); + } +} + +export default translate(TimeElapsed); diff --git a/react/features/speaker-stats/components/web/index.js b/react/features/speaker-stats/components/web/index.js new file mode 100644 index 000000000..7787e1711 --- /dev/null +++ b/react/features/speaker-stats/components/web/index.js @@ -0,0 +1,2 @@ +export { default as SpeakerStatsButton } from './SpeakerStatsButton'; +export { default as SpeakerStats } from './SpeakerStats'; diff --git a/react/features/speaker-stats/constants.js b/react/features/speaker-stats/constants.js index 94a9143cb..072538631 100644 --- a/react/features/speaker-stats/constants.js +++ b/react/features/speaker-stats/constants.js @@ -1 +1,3 @@ export const SPEAKER_STATS_RELOAD_INTERVAL = 1000; + +export const SPEAKER_STATS_VIEW_MODEL_ID = 'speakerStats'; diff --git a/react/features/speaker-stats/functions.js b/react/features/speaker-stats/functions.js index c2746bd2a..f10fcc402 100644 --- a/react/features/speaker-stats/functions.js +++ b/react/features/speaker-stats/functions.js @@ -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'; /** diff --git a/react/features/speaker-stats/middleware.js b/react/features/speaker-stats/middleware.js index f2f31f406..f9aad62f7 100644 --- a/react/features/speaker-stats/middleware.js +++ b/react/features/speaker-stats/middleware.js @@ -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'; diff --git a/react/features/speaker-stats/reducer.js b/react/features/speaker-stats/reducer.js index 1b9b9e187..707cc81f8 100644 --- a/react/features/speaker-stats/reducer.js +++ b/react/features/speaker-stats/reducer.js @@ -17,6 +17,7 @@ import { */ const INITIAL_STATE = { stats: {}, + isOpen: false, pendingReorder: true, criteria: null }; diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 60b7bcfbf..3eb30022a 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -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 { + {!toolbarButtons.has('togglecamera') && } {!toolbarButtons.has('tileview') && } diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index b3c5c4d18..9a2e7c157 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -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,