From eb70c611c2263371c758c14a55ca8122f2b8ddd3 Mon Sep 17 00:00:00 2001 From: Gabriel Borlea Date: Tue, 28 Dec 2021 16:35:21 +0200 Subject: [PATCH] feat(speaker-stats) new design for web and mobile --- config.js | 3 + css/modals/speaker_stats/_speaker_stats.scss | 94 ++----- lang/main.json | 1 + .../features/facial-recognition/constants.js | 7 +- react/features/speaker-stats/actionTypes.js | 8 + react/features/speaker-stats/actions.any.js | 14 +- .../components/AbstractSpeakerStatsList.js | 27 +- .../components/native/SpeakerStats.js | 32 ++- .../components/native/SpeakerStatsItem.js | 46 ++-- .../components/native/SpeakerStatsLabels.js | 35 --- .../components/native/SpeakerStatsSearch.js | 60 +++++ .../components/native/TimeElapsed.js | 9 +- .../speaker-stats/components/native/styles.js | 87 ++++--- .../components/web/FacialExpressionsSwitch.js | 97 ++++++++ .../components/web/SpeakerStats.js | 234 ++++++++---------- .../components/web/SpeakerStatsItem.js | 140 ++++++----- .../components/web/SpeakerStatsLabels.js | 123 +++++---- .../components/web/SpeakerStatsList.js | 52 +++- .../components/web/SpeakerStatsSearch.js | 71 ++++-- react/features/speaker-stats/constants.js | 11 +- react/features/speaker-stats/functions.js | 21 ++ react/features/speaker-stats/middleware.js | 14 +- react/features/speaker-stats/reducer.js | 12 +- 23 files changed, 709 insertions(+), 489 deletions(-) delete mode 100644 react/features/speaker-stats/components/native/SpeakerStatsLabels.js create mode 100644 react/features/speaker-stats/components/native/SpeakerStatsSearch.js create mode 100644 react/features/speaker-stats/components/web/FacialExpressionsSwitch.js diff --git a/config.js b/config.js index d0cb146e48..3981072c6b 100644 --- a/config.js +++ b/config.js @@ -742,6 +742,9 @@ var config = { // Enables detecting faces of participants and get their expression and send it to other participants // enableFacialRecognition: true, + // Enables displaying facial expressions in speaker stats + // enableDisplayFacialExpressions: true, + // Controls the percentage of automatic feedback shown to participants when callstats is enabled. // The default value is 100%. If set to 0, no automatic feedback will be requested // feedbackPercentage: 100, diff --git a/css/modals/speaker_stats/_speaker_stats.scss b/css/modals/speaker_stats/_speaker_stats.scss index b137c1b8fd..51e037564d 100644 --- a/css/modals/speaker_stats/_speaker_stats.scss +++ b/css/modals/speaker_stats/_speaker_stats.scss @@ -1,80 +1,30 @@ .speaker-stats { list-style: none; - padding: 0; - width: 100%; - font-weight: 500; - - .speaker-stats-item__status-dot { - position: relative; - display: block; - width: 9px; - height: 9px; - border-radius: 50%; - margin: 0 auto; - - &.status-active { - background: green; + .row{ + display: flex; + align-items: center; + .avatar { + width: 32px; + margin-right: 16px; } - - &.status-inactive { - background: gray; + .name-time { + width: calc(100% - 48px); + display: flex; + justify-content: space-between; + align-items: center; + } - } - - .status-user-left { - color: $placeHolderColor; - } - - .speaker-stats-item__status, - .speaker-stats-item__name, - .speaker-stats-item__time, - .speaker-stats-item__name_expressions_on, - .speaker-stats-item__time_expressions_on, - .speaker-stats-item__expression { - display: inline-block; - margin: 5px 0; - vertical-align: middle; - } - .speaker-stats-item__status { - width: 5%; - } - .speaker-stats-item__name { - width: 40%; - } - .speaker-stats-item__time { - width: 55%; - } - .speaker-stats-item__name_expressions_on { - width: 20%; - } - .speaker-stats-item__time_expressions_on { - width: 25%; - } - - .speaker-stats-item__expression { - width: 7%; - text-align: center; - } - - @media(max-width: 750px) { - .speaker-stats-item__name_expressions_on { - width: 25%; + .name-time_expressions-on { + width: calc(47% - 48px); } - .speaker-stats-item__time_expressions_on { - width: 30%; + .expressions { + width: calc(53% - 29px); + display: flex; + justify-content: space-between; + .expression { + width: 30px; + text-align: center; + } } - .speaker-stats-item__expression { - width: 10%; - } - } - - .speaker-stats-item__name, - .speaker-stats-item__time, - .speaker-stats-item__name_expressions_on, - .speaker-stats-item__time_expressions_on, - .speaker-stats-item__expression { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } } diff --git a/lang/main.json b/lang/main.json index 67a9432f9b..4a41a8f4eb 100644 --- a/lang/main.json +++ b/lang/main.json @@ -927,6 +927,7 @@ "speakerStats": { "angry": "Angry", "disgusted": "Disgusted", + "displayEmotions": "Display emotions", "fearful": "Fearful", "happy": "Happy", "hours": "{{count}}h", diff --git a/react/features/facial-recognition/constants.js b/react/features/facial-recognition/constants.js index 90642fe641..4eae4222c3 100644 --- a/react/features/facial-recognition/constants.js +++ b/react/features/facial-recognition/constants.js @@ -6,10 +6,13 @@ export const FACIAL_EXPRESSION_EMOJIS = { sad: '🙁', surprised: '😮', angry: '😠', - fearful: '😨', - disgusted: '🤢' + fearful: '😨' + + // disgusted: '🤢' }; +export const FACIAL_EXPRESSIONS = [ 'happy', 'neutral', 'sad', 'surprised', 'angry', 'fearful' ]; + /** * Time used for detection interval when facial expressions worker uses webgl backend. */ diff --git a/react/features/speaker-stats/actionTypes.js b/react/features/speaker-stats/actionTypes.js index f61549bb18..75aca2f7f0 100644 --- a/react/features/speaker-stats/actionTypes.js +++ b/react/features/speaker-stats/actionTypes.js @@ -47,3 +47,11 @@ export const INIT_REORDER_STATS = 'INIT_REORDER_STATS'; */ export const RESET_SEARCH_CRITERIA = 'RESET_SEARCH_CRITERIA' +/** + * Action type to toggle the facial expressions grid. + * { + * type: TOGGLE_FACIAL_EXPRESSIONS + * } + */ +export const TOGGLE_FACIAL_EXPRESSIONS = 'SHOW_FACIAL_EXPRESSIONS'; + diff --git a/react/features/speaker-stats/actions.any.js b/react/features/speaker-stats/actions.any.js index a0be70b153..205af93030 100644 --- a/react/features/speaker-stats/actions.any.js +++ b/react/features/speaker-stats/actions.any.js @@ -5,7 +5,8 @@ import { INIT_UPDATE_STATS, UPDATE_STATS, INIT_REORDER_STATS, - RESET_SEARCH_CRITERIA + RESET_SEARCH_CRITERIA, + TOGGLE_FACIAL_EXPRESSIONS } from './actionTypes'; /** @@ -68,3 +69,14 @@ export function resetSearchCriteria() { type: RESET_SEARCH_CRITERIA }; } + +/** + * Toggles the facial expressions grid. + * + * @returns {Object} + */ +export function toggleFacialExpressions() { + return { + type: TOGGLE_FACIAL_EXPRESSIONS + }; +} diff --git a/react/features/speaker-stats/components/AbstractSpeakerStatsList.js b/react/features/speaker-stats/components/AbstractSpeakerStatsList.js index 6a5b8006aa..30ce320c09 100644 --- a/react/features/speaker-stats/components/AbstractSpeakerStatsList.js +++ b/react/features/speaker-stats/components/AbstractSpeakerStatsList.js @@ -7,7 +7,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { getLocalParticipant } from '../../base/participants'; import { initUpdateStats } from '../actions'; import { - REDUCE_EXPRESSIONS_THRESHOLD, SPEAKER_STATS_RELOAD_INTERVAL } from '../constants'; @@ -15,17 +14,18 @@ import { * Component that renders the list of speaker stats. * * @param {Function} speakerStatsItem - React element tu use when rendering. + * @param {Object} itemStyles - Styles for the speaker stats item. * @returns {Function} */ -const abstractSpeakerStatsList = (speakerStatsItem: Function): Function[] => { +const abstractSpeakerStatsList = (speakerStatsItem: Function, itemStyles?: Object): Function[] => { const dispatch = useDispatch(); const { t } = useTranslation(); const conference = useSelector(state => state['features/base/conference'].conference); - const speakerStats = useSelector(state => state['features/speaker-stats'].stats); + const { stats: speakerStats, showFacialExpressions } = useSelector(state => state['features/speaker-stats']); const localParticipant = useSelector(getLocalParticipant); - const { clientWidth } = useSelector(state => state['features/base/responsive-ui']); - const { defaultRemoteDisplayName, enableFacialRecognition } = useSelector( + const { defaultRemoteDisplayName } = useSelector( state => state['features/base/config']) || {}; + const { enableDisplayFacialExpressions } = useSelector(state => state['features/base/config']) || {}; const { facialExpressions: localFacialExpressions } = useSelector( state => state['features/facial-recognition']) || {}; @@ -48,7 +48,7 @@ const abstractSpeakerStatsList = (speakerStatsItem: Function): Function[] => { ? `${localParticipant.name} (${meString})` : meString ); - if (enableFacialRecognition) { + if (enableDisplayFacialExpressions) { stats[userId].setFacialExpressions(localFacialExpressions); } } @@ -77,26 +77,25 @@ const abstractSpeakerStatsList = (speakerStatsItem: Function): Function[] => { }, []); const localSpeakerStats = Object.keys(speakerStats).length === 0 ? getLocalSpeakerStats() : speakerStats; - const userIds = Object.keys(localSpeakerStats); + const userIds = Object.keys(localSpeakerStats).filter(id => localSpeakerStats[id] && !localSpeakerStats[id].hidden); return userIds.map(userId => { const statsModel = localSpeakerStats[userId]; - - if (!statsModel || statsModel.hidden) { - return null; - } const props = {}; props.isDominantSpeaker = statsModel.isDominantSpeaker(); props.dominantSpeakerTime = statsModel.getTotalDominantSpeakerTime(); props.participantId = userId; props.hasLeft = statsModel.hasLeft(); - if (enableFacialRecognition) { + if (showFacialExpressions) { props.facialExpressions = statsModel.getFacialExpressions(); } - props.showFacialExpressions = enableFacialRecognition; - props.reduceExpressions = clientWidth < REDUCE_EXPRESSIONS_THRESHOLD; + props.hidden = statsModel.hidden; + props.showFacialExpressions = showFacialExpressions; props.displayName = statsModel.getDisplayName() || defaultRemoteDisplayName; + if (itemStyles) { + props.styles = itemStyles; + } props.t = t; return speakerStatsItem(props); diff --git a/react/features/speaker-stats/components/native/SpeakerStats.js b/react/features/speaker-stats/components/native/SpeakerStats.js index b12b66e300..f3807ec61e 100644 --- a/react/features/speaker-stats/components/native/SpeakerStats.js +++ b/react/features/speaker-stats/components/native/SpeakerStats.js @@ -1,11 +1,15 @@ // @flow -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import JitsiScreen from '../../../base/modal/components/JitsiScreen'; +import { escapeRegexp } from '../../../base/util'; +import { resetSearchCriteria, initSearch } from '../../actions'; + -import SpeakerStatsLabels from './SpeakerStatsLabels'; import SpeakerStatsList from './SpeakerStatsList'; +import SpeakerStatsSearch from './SpeakerStatsSearch'; import style from './styles'; /** @@ -13,12 +17,22 @@ import style from './styles'; * * @returns {React$Element} */ -const SpeakerStats = () => ( - - - - -); +const SpeakerStats = () => { + const dispatch = useDispatch(); + const onSearch = useCallback((criteria = '') => { + dispatch(initSearch(escapeRegexp(criteria))); + } + , [ dispatch ]); + + useEffect(() => () => dispatch(resetSearchCriteria()), []); + + return ( + + + + + ); +}; export default SpeakerStats; diff --git a/react/features/speaker-stats/components/native/SpeakerStatsItem.js b/react/features/speaker-stats/components/native/SpeakerStatsItem.js index 8aa7b10cd9..3bcbfd2088 100644 --- a/react/features/speaker-stats/components/native/SpeakerStatsItem.js +++ b/react/features/speaker-stats/components/native/SpeakerStatsItem.js @@ -3,6 +3,8 @@ import React from 'react'; import { View, Text } from 'react-native'; +import { Avatar, StatelessAvatar } from '../../../base/avatar'; +import { getInitials } from '../../../base/avatar/functions'; import BaseTheme from '../../../base/ui/components/BaseTheme.native'; import TimeElapsed from './TimeElapsed'; @@ -36,32 +38,42 @@ type Props = { isDominantSpeaker: boolean }; -const SpeakerStatsItem = (props: Props) => { - /** - * @inheritdoc - * @returns {ReactElement} - */ - const dotColor = props.isDominantSpeaker - ? BaseTheme.palette.icon05 : BaseTheme.palette.icon03; - - return ( +const SpeakerStatsItem = (props: Props) => + ( - - + + { + props.hasLeft ? ( + + ) : ( + + ) + } - - - { props.displayName } + + + {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 deleted file mode 100644 index d1f8fc54a0..0000000000 --- a/react/features/speaker-stats/components/native/SpeakerStatsLabels.js +++ /dev/null @@ -1,35 +0,0 @@ -/* @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/SpeakerStatsSearch.js b/react/features/speaker-stats/components/native/SpeakerStatsSearch.js new file mode 100644 index 0000000000..88a50c1b63 --- /dev/null +++ b/react/features/speaker-stats/components/native/SpeakerStatsSearch.js @@ -0,0 +1,60 @@ +// @flow +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { withTheme } from 'react-native-paper'; +import { useSelector } from 'react-redux'; + +import { IconSearch, Icon } from '../../../base/icons'; +import ClearableInput from '../../../participants-pane/components/native/ClearableInput'; +import { isSpeakerStatsSearchDisabled } from '../../functions'; + +import styles from './styles'; + +/** + * The type of the React {@code Component} props of {@link SpeakerStatsSearch}. + */ +type Props = { + + /** + * The function to initiate the change in the speaker stats table. + */ + onSearch: Function, + + /** + * Theme used for styles. + */ + theme: Object +}; + +/** + * React component for display an individual user's speaker stats. + * + * @returns {React$Element} + */ +function SpeakerStatsSearch({ onSearch, theme }: Props) { + const { t } = useTranslation(); + + const disableSpeakerStatsSearch = useSelector(isSpeakerStatsSearchDisabled); + + if (disableSpeakerStatsSearch) { + return null; + } + + return ( + + } + selectionColor = { theme.palette.text01 } /> + ); +} + +export default withTheme(SpeakerStatsSearch); diff --git a/react/features/speaker-stats/components/native/TimeElapsed.js b/react/features/speaker-stats/components/native/TimeElapsed.js index 56f79c3d60..94d23ca991 100644 --- a/react/features/speaker-stats/components/native/TimeElapsed.js +++ b/react/features/speaker-stats/components/native/TimeElapsed.js @@ -11,6 +11,11 @@ import { createLocalizedTime } from '../timeFunctions'; */ type Props = { + /** + * Style for text. + */ + style: Object, + /** * The function to translate human-readable text. */ @@ -37,11 +42,11 @@ class TimeElapsed extends PureComponent { * @returns {ReactElement} */ render() { - const { time, t } = this.props; + const { style, time, t } = this.props; const timeElapsed = createLocalizedTime(time, t); return ( - + { timeElapsed } ); diff --git a/react/features/speaker-stats/components/native/styles.js b/react/features/speaker-stats/components/native/styles.js index ce840e3f17..8fc9377075 100644 --- a/react/features/speaker-stats/components/native/styles.js +++ b/react/features/speaker-stats/components/native/styles.js @@ -4,51 +4,58 @@ export default { speakerStatsContainer: { flexDirection: 'column', flex: 1, - height: 'auto' + height: 'auto', + paddingHorizontal: BaseTheme.spacing[3], + backgroundColor: BaseTheme.palette.ui02 }, speakerStatsItemContainer: { flexDirection: 'row', alignSelf: 'stretch', - height: 24 + height: BaseTheme.spacing[9], + alignItems: 'center' }, - 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' + speakerStatsAvatar: { + width: BaseTheme.spacing[5], + height: BaseTheme.spacing[5], + marginRight: BaseTheme.spacing[3] }, - speakerName: { - flex: 8, - alignSelf: 'stretch' - }, - speakerTime: { - flex: 12, - alignSelf: 'stretch' + speakerStatsNameTime: { + flexDirection: 'row', + flex: 1, + justifyContent: 'space-between', + alignItems: 'center' + }, + speakerStatsText: { + ...BaseTheme.typography.bodyShortRegularLarge, + color: BaseTheme.palette.text01 + }, + speakerStatsTime: { + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 4 + }, + speakerStatsDominant: { + backgroundColor: BaseTheme.palette.success02 + }, + speakerStatsLeft: { + color: BaseTheme.palette.text03 + }, + speakerStatsSearch: { + wrapper: { + marginLeft: 0, + marginRight: 0, + marginTop: BaseTheme.spacing[3], + marginBottom: BaseTheme.spacing[3], + flexDirection: 'row', + alignItems: 'center' + }, + input: { + textAlign: 'left' + }, + searchIcon: { + width: 10, + height: 20, + marginLeft: BaseTheme.spacing[3] + } } - - }; diff --git a/react/features/speaker-stats/components/web/FacialExpressionsSwitch.js b/react/features/speaker-stats/components/web/FacialExpressionsSwitch.js new file mode 100644 index 0000000000..0372594099 --- /dev/null +++ b/react/features/speaker-stats/components/web/FacialExpressionsSwitch.js @@ -0,0 +1,97 @@ +// @flow +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Switch } from '../../../base/react'; + + +const useStyles = makeStyles(theme => { + return { + switchContainer: { + display: 'flex', + alignItems: 'center', + '& svg': { + display: 'none' + + }, + '& div': { + width: 38, + '& > label': { + width: 32, + height: 20, + backgroundColor: theme.palette.ui05, + '&:not([data-checked]):hover': { + backgroundColor: theme.palette.ui05 + }, + '&[data-checked]': { + backgroundColor: theme.palette.action01, + '&:hover': { + backgroundColor: theme.palette.action01 + }, + '&::before': { + margin: '0 0 1.5px -3px', + backgroundColor: theme.palette.text01 + } + }, + '&:focus-within': { + borderColor: 'transparent' + }, + '&::before': { + width: 14, + height: 14, + margin: '0 0 1.5px 1.5px', + backgroundColor: theme.palette.text01 + } + } + } + }, + switchLabel: { + marginRight: 10, + ...theme.typography.bodyShortRegular, + lineHeight: `${theme.typography.bodyShortRegular.lineHeight}px` + } + }; +}); + +/** + * The type of the React {@code Component} props of {@link ToggleFacialExpressionsButton}. + */ +type Props = { + + /** + * The function to initiate the change in the speaker stats table. + */ + onChange: Function, + + /** + * The state of the button. + */ + showFacialExpressions: boolean, + +}; + +/** + * React component for toggling facial expressions grid. + * + * @returns {React$Element} + */ +export default function FacialExpressionsSwitch({ onChange, showFacialExpressions }: Props) { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +} diff --git a/react/features/speaker-stats/components/web/SpeakerStats.js b/react/features/speaker-stats/components/web/SpeakerStats.js index c5d78d225f..4374b6988a 100644 --- a/react/features/speaker-stats/components/web/SpeakerStats.js +++ b/react/features/speaker-stats/components/web/SpeakerStats.js @@ -1,142 +1,116 @@ // @flow -import React, { Component } from 'react'; -import type { Dispatch } from 'redux'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useCallback, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { Dialog } from '../../../base/dialog'; -import { translate } from '../../../base/i18n'; -import { connect } from '../../../base/redux'; import { escapeRegexp } from '../../../base/util'; -import { initSearch, resetSearchCriteria } from '../../actions'; - +import { resetSearchCriteria, toggleFacialExpressions, initSearch } from '../../actions'; +import { + DISPLAY_SWITCH_BREAKPOINT, + MOBILE_BREAKPOINT, + RESIZE_SEARCH_SWITCH_CONTAINER_BREAKPOINT +} from '../../constants'; + +import FacialExpressionsSwitch from './FacialExpressionsSwitch'; 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); - } - - /** - * Resets the search criteria when component will unmount. - * - * @private - * @returns {void} - */ - componentWillUnmount() { - this.props.dispatch(resetSearchCriteria()); - } - - /** - * 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']; - +const useStyles = makeStyles(theme => { return { - /** - * The local display name. - * - * @private - * @type {string|undefined} - */ - _showFacialExpressions: enableFacialRecognition, - _reduceExpressions: clientWidth < 750 + separator: { + position: 'absolute', + width: '100%', + height: 1, + left: 0, + backgroundColor: theme.palette.border02 + }, + searchSwitchContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%' + }, + searchSwitchContainerExpressionsOn: { + width: '58.5%', + [theme.breakpoints.down(RESIZE_SEARCH_SWITCH_CONTAINER_BREAKPOINT)]: { + width: '100%' + } + }, + searchContainer: { + width: '50%' + }, + searchContainerFullWidth: { + width: '100%' + } }; -} +}); + +const SpeakerStats = () => { + const { enableDisplayFacialExpressions } = useSelector(state => state['features/base/config']); + const { showFacialExpressions } = useSelector(state => state['features/speaker-stats']); + const { clientWidth } = useSelector(state => state['features/base/responsive-ui']); + const displaySwitch = enableDisplayFacialExpressions && clientWidth > DISPLAY_SWITCH_BREAKPOINT; + const displayLabels = clientWidth > MOBILE_BREAKPOINT; + const dispatch = useDispatch(); + const classes = useStyles(); + + const onToggleFacialExpressions = useCallback(() => + dispatch(toggleFacialExpressions()) + , [ dispatch ]); + + const onSearch = useCallback((criteria = '') => { + dispatch(initSearch(escapeRegexp(criteria))); + } + , [ dispatch ]); + + useEffect(() => { + showFacialExpressions && !displaySwitch && dispatch(toggleFacialExpressions()); + }, [ clientWidth ]); + useEffect(() => () => dispatch(resetSearchCriteria()), []); + + return ( + +
+
+
+ +
+ + { displaySwitch + && + } +
+ { displayLabels && ( + <> + +
+ + )} + +
+
+ + ); +}; -export default translate(connect(_mapStateToProps)(SpeakerStats)); +export default SpeakerStats; diff --git a/react/features/speaker-stats/components/web/SpeakerStatsItem.js b/react/features/speaker-stats/components/web/SpeakerStatsItem.js index 94a7a91ffe..bfcac544d0 100644 --- a/react/features/speaker-stats/components/web/SpeakerStatsItem.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsItem.js @@ -2,6 +2,11 @@ import React from 'react'; +import { Avatar, StatelessAvatar } from '../../../base/avatar'; +import { getInitials } from '../../../base/avatar/functions'; +import BaseTheme from '../../../base/ui/components/BaseTheme'; +import { FACIAL_EXPRESSIONS } from '../../../facial-recognition/constants.js'; + import TimeElapsed from './TimeElapsed'; /** @@ -20,11 +25,6 @@ type Props = { */ facialExpressions: Object, - /** - * True if the client width is les than 750. - */ - reduceExpressions: boolean, - /** * True if the facial recognition is not disabled. */ @@ -45,16 +45,26 @@ type Props = { */ hasLeft: boolean, + /** + * True if the participant is not shown in speaker stats. + */ + hidden: boolean, + /** * True if the participant is currently the dominant speaker. */ isDominantSpeaker: boolean, + /** + * Styles for the item. + */ + styles: Object, + /** * Invoked to obtain translated strings. */ t: Function -}; +} const SpeakerStatsItem = (props: Props) => { /** @@ -63,80 +73,68 @@ const SpeakerStatsItem = (props: Props) => { * @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}`; + const hasLeftClass = props.hasLeft ? props.styles.hasLeft : ''; + const rowDisplayClass = `row ${hasLeftClass} ${props.styles.item}`; + const expressionClass = 'expression'; + const nameTimeClass = `name-time${ + props.showFacialExpressions ? ' name-time_expressions-on' : '' + }`; + const timeClass = `${props.styles.time} ${props.isDominantSpeaker ? props.styles.dominant : ''}`; + + + const FacialExpressions = () => FACIAL_EXPRESSIONS.map( + expression => ( +
+ { props.facialExpressions[expression] } +
+ ) + ); return (
-
- +
+ { + props.hasLeft ? ( + + ) : ( + + ) + }
-
- { props.displayName } -
-
- +
+
+ { 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 } -
- - )} - - ) - } +
+ +
+ )}
); }; diff --git a/react/features/speaker-stats/components/web/SpeakerStatsLabels.js b/react/features/speaker-stats/components/web/SpeakerStatsLabels.js index acce91b293..e681c2bae5 100644 --- a/react/features/speaker-stats/components/web/SpeakerStatsLabels.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsLabels.js @@ -1,90 +1,79 @@ /* @flow */ +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; -import React, { Component } from 'react'; - -import { translate } from '../../../base/i18n'; import { Tooltip } from '../../../base/tooltip'; import { FACIAL_EXPRESSION_EMOJIS } from '../../../facial-recognition/constants.js'; +const useStyles = makeStyles(theme => { + return { + labels: { + padding: '22px 0 7px 0', + height: 20 + }, + emojis: { + paddingLeft: 27, + ...theme.typography.bodyShortRegularLarge, + lineHeight: `${theme.typography.bodyShortRegular.lineHeightLarge}px` + } + }; +}); + /** * The type of the React {@code Component} props of {@link SpeakerStatsLabels}. */ type Props = { - /** - * True if the client width is les than 750. - */ - reduceExpressions: boolean, - /** * True if the facial recognition is not disabled. */ showFacialExpressions: boolean, - - /** - * The function to translate human-readable text. - */ - t: Function }; -/** - * React component for labeling speaker stats column items. - * - * @augments Component - */ -class SpeakerStatsLabels extends Component { - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { t } = this.props; - - return ( -
-
+const SpeakerStatsLabels = (props: Props) => { + const { t } = useTranslation(); + const classes = useStyles(); + const FacialExpressionsLabels = () => Object.keys(FACIAL_EXPRESSION_EMOJIS).map( + expression => (
+ className = 'expression' + key = { expression }> + +
+ { FACIAL_EXPRESSION_EMOJIS[expression] } +
+ +
+
+ ) + ); + const nameTimeClass = `name-time${ + props.showFacialExpressions ? ' name-time_expressions-on' : '' + }`; + + return ( +
+
+ +
+
{ t('speakerStats.name') }
-
+
{ t('speakerStats.speakerTime') }
- { this.props.showFacialExpressions - && (this.props.reduceExpressions - ? Object.keys(FACIAL_EXPRESSION_EMOJIS) - .filter(expression => ![ 'angry', 'fearful', 'disgusted' ].includes(expression)) - : Object.keys(FACIAL_EXPRESSION_EMOJIS) - ).map( - expression => ( -
- -
- - { FACIAL_EXPRESSION_EMOJIS[expression] } -
- -
-
- - )) - }
- ); - } -} + { + props.showFacialExpressions + &&
+ +
+ } +
+ ); +}; -export default translate(SpeakerStatsLabels); +export default SpeakerStatsLabels; diff --git a/react/features/speaker-stats/components/web/SpeakerStatsList.js b/react/features/speaker-stats/components/web/SpeakerStatsList.js index 4074cd192e..5dca9162c7 100644 --- a/react/features/speaker-stats/components/web/SpeakerStatsList.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsList.js @@ -1,25 +1,71 @@ // @flow +import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; +import { MOBILE_BREAKPOINT } from '../../constants'; import abstractSpeakerStatsList from '../AbstractSpeakerStatsList'; import SpeakerStatsItem from './SpeakerStatsItem'; +const useStyles = makeStyles(theme => { + return { + list: { + marginTop: `${theme.spacing(3)}px` + }, + item: { + height: `${theme.spacing(7)}px`, + [theme.breakpoints.down(MOBILE_BREAKPOINT)]: { + height: `${theme.spacing(8)}px` + } + }, + avatar: { + height: `${theme.spacing(5)}px` + }, + expressions: { + paddingLeft: 29 + }, + hasLeft: { + color: theme.palette.text03 + }, + displayName: { + ...theme.typography.bodyShortRegular, + lineHeight: `${theme.typography.bodyShortRegular.lineHeight}px`, + [theme.breakpoints.down(MOBILE_BREAKPOINT)]: { + ...theme.typography.bodyShortRegularLarge, + lineHeight: `${theme.typography.bodyShortRegular.lineHeightLarge}px` + } + }, + time: { + padding: '2px 4px', + borderRadius: '4px', + ...theme.typography.labelBold, + lineHeight: `${theme.typography.labelBold.lineHeight}px`, + [theme.breakpoints.down(MOBILE_BREAKPOINT)]: { + ...theme.typography.bodyShortRegularLarge, + lineHeight: `${theme.typography.bodyShortRegular.lineHeightLarge}px` + } + }, + dominant: { + backgroundColor: theme.palette.success02 + } + }; +}); + /** * Component that renders the list of speaker stats. * * @returns {React$Element} */ const SpeakerStatsList = () => { - const items = abstractSpeakerStatsList(SpeakerStatsItem); + const classes = useStyles(); + const items = abstractSpeakerStatsList(SpeakerStatsItem, classes); return ( -
+
{items}
); }; - export default SpeakerStatsList; diff --git a/react/features/speaker-stats/components/web/SpeakerStatsSearch.js b/react/features/speaker-stats/components/web/SpeakerStatsSearch.js index 016e014842..a2411c26d5 100644 --- a/react/features/speaker-stats/components/web/SpeakerStatsSearch.js +++ b/react/features/speaker-stats/components/web/SpeakerStatsSearch.js @@ -1,25 +1,54 @@ /* @flow */ -import { FieldTextStateless as TextField } from '@atlaskit/field-text'; import { makeStyles } from '@material-ui/core/styles'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; +import { IconSearch, Icon } from '../../../base/icons'; import { getFieldValue } from '../../../base/react'; +import BaseTheme from '../../../base/ui/components/BaseTheme'; +import { MOBILE_BREAKPOINT } from '../../constants'; import { isSpeakerStatsSearchDisabled } from '../../functions'; const useStyles = makeStyles(theme => { return { + speakerStatsSearchContainer: { + position: 'relative' + }, + searchIcon: { + display: 'none', + [theme.breakpoints.down(MOBILE_BREAKPOINT)]: { + display: 'block', + position: 'absolute', + color: theme.palette.text03, + left: 16, + top: 13, + width: 20, + height: 20 + } + }, speakerStatsSearch: { - position: 'absolute', - right: '80px', - top: '8px', - - [theme.breakpoints.down('400')]: { - left: 20, - right: 0, - top: 42 + backgroundColor: theme.palette.field01, + border: '1px solid', + borderRadius: 6, + borderColor: theme.palette.border02, + color: theme.palette.text01, + padding: '10px 16px', + width: '100%', + height: 40, + '&::placeholder': { + color: theme.palette.text03, + ...theme.typography.bodyShortRegular, + lineHeight: `${theme.typography.bodyShortRegular.lineHeight}px` + }, + [theme.breakpoints.down(MOBILE_BREAKPOINT)]: { + height: 48, + padding: '13px 16px 13px 44px', + '&::placeholder': { + ...theme.typography.bodyShortRegularLarge, + lineHeight: `${theme.typography.bodyShortRegular.lineHeightLarge}px` + } } } }; @@ -45,15 +74,21 @@ type Props = { function SpeakerStatsSearch({ onSearch }: Props) { const classes = useStyles(); const { t } = useTranslation(); + const disableSpeakerStatsSearch = useSelector(isSpeakerStatsSearchDisabled); const [ searchValue, setSearchValue ] = useState(''); + + /** + * Callback for the onChange event of the field. + * + * @param {Object} evt - The static event. + * @returns {void} + */ const onChange = useCallback((evt: Event) => { const value = getFieldValue(evt); setSearchValue(value); - onSearch && onSearch(value); }, []); - const disableSpeakerStatsSearch = useSelector(isSpeakerStatsSearchDisabled); const preventDismiss = useCallback((evt: KeyboardEvent) => { if (evt.key === 'Enter') { evt.preventDefault(); @@ -65,17 +100,21 @@ function SpeakerStatsSearch({ onSearch }: Props) { } return ( -
- + +
); diff --git a/react/features/speaker-stats/constants.js b/react/features/speaker-stats/constants.js index 229d80b91e..1476fd04a5 100644 --- a/react/features/speaker-stats/constants.js +++ b/react/features/speaker-stats/constants.js @@ -1,6 +1,7 @@ -/** - * The with of the client at witch the facial expressions will be reduced to only 4. - */ -export const REDUCE_EXPRESSIONS_THRESHOLD = 750; - export const SPEAKER_STATS_RELOAD_INTERVAL = 1000; + +export const DISPLAY_SWITCH_BREAKPOINT = 600; + +export const RESIZE_SEARCH_SWITCH_CONTAINER_BREAKPOINT = 750; + +export const MOBILE_BREAKPOINT = 480; diff --git a/react/features/speaker-stats/functions.js b/react/features/speaker-stats/functions.js index f10fcc402c..63a3c37ef7 100644 --- a/react/features/speaker-stats/functions.js +++ b/react/features/speaker-stats/functions.js @@ -170,3 +170,24 @@ export function filterBySearchCriteria(state: Object, stats: ?Object) { return filteredStats; } + +/** + * Reset the hidden speaker stats. + * + * @param {Object} state - The redux state. + * @param {Object | undefined} stats - The unfiltered stats. + * + * @returns {Object} - Speaker stats. + * @public + */ +export function resetHiddenStats(state: Object, stats: ?Object) { + const resetStats = _.cloneDeep(stats ?? getSpeakerStats(state)); + + for (const id in resetStats) { + if (resetStats[id].hidden) { + resetStats[id].hidden = false; + } + } + + return resetStats; +} diff --git a/react/features/speaker-stats/middleware.js b/react/features/speaker-stats/middleware.js index f9aad62f74..78283dfc96 100644 --- a/react/features/speaker-stats/middleware.js +++ b/react/features/speaker-stats/middleware.js @@ -10,16 +10,16 @@ import { MiddlewareRegistry } from '../base/redux'; import { INIT_SEARCH, - INIT_UPDATE_STATS + INIT_UPDATE_STATS, + RESET_SEARCH_CRITERIA } from './actionTypes'; import { initReorderStats, updateStats } from './actions'; -import { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder } from './functions'; +import { filterBySearchCriteria, getSortedSpeakerStats, getPendingReorder, resetHiddenStats } from './functions'; MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { const result = next(action); switch (action.type) { - case INIT_SEARCH: { const state = getState(); const stats = filterBySearchCriteria(state); @@ -38,6 +38,14 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { dispatch(updateStats(pendingReorder ? getSortedSpeakerStats(state, stats) : stats)); } break; + + case RESET_SEARCH_CRITERIA: { + const state = getState(); + const stats = resetHiddenStats(state); + + dispatch(updateStats(stats)); + break; + } case PARTICIPANT_JOINED: case PARTICIPANT_LEFT: case PARTICIPANT_KICKED: diff --git a/react/features/speaker-stats/reducer.js b/react/features/speaker-stats/reducer.js index fb136ef783..3df0f6bd2d 100644 --- a/react/features/speaker-stats/reducer.js +++ b/react/features/speaker-stats/reducer.js @@ -8,7 +8,8 @@ import { INIT_SEARCH, UPDATE_STATS, INIT_REORDER_STATS, - RESET_SEARCH_CRITERIA + RESET_SEARCH_CRITERIA, + TOGGLE_FACIAL_EXPRESSIONS } from './actionTypes'; /** @@ -20,7 +21,8 @@ const INITIAL_STATE = { stats: {}, isOpen: false, pendingReorder: true, - criteria: null + criteria: null, + showFacialExpressions: false }; ReducerRegistry.register('features/speaker-stats', (state = _getInitialState(), action) => { @@ -33,6 +35,12 @@ ReducerRegistry.register('features/speaker-stats', (state = _getInitialState(), return _initReorderStats(state); case RESET_SEARCH_CRITERIA: return _updateCriteria(state, { criteria: null }); + case TOGGLE_FACIAL_EXPRESSIONS: { + return { + ...state, + showFacialExpressions: !state.showFacialExpressions + }; + } } return state;