import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { createFeedbackOpenEvent } from '../../analytics/AnalyticsEvents'; import { sendAnalytics } from '../../analytics/functions'; import { IReduxState } from '../../app/types'; import { IJitsiConference } from '../../base/conference/reducer'; import { isMobileBrowser } from '../../base/environment/utils'; import Icon from '../../base/icons/components/Icon'; import { IconFavorite, IconFavoriteSolid } from '../../base/icons/svg'; import { withPixelLineHeight } from '../../base/styles/functions.web'; import Dialog from '../../base/ui/components/web/Dialog'; import Input from '../../base/ui/components/web/Input'; import { cancelFeedback, submitFeedback } from '../actions.web'; const useStyles = makeStyles()(theme => { return { dialog: { marginBottom: theme.spacing(1) }, rating: { display: 'flex', flexDirection: 'column-reverse' as const, alignItems: 'center', justifyContent: 'center', marginTop: theme.spacing(4), marginBottom: theme.spacing(3) }, ratingLabel: { ...withPixelLineHeight(theme.typography.bodyShortBold), color: theme.palette.text01, marginBottom: theme.spacing(2), height: '20px' }, stars: { display: 'flex' }, starBtn: { display: 'inline-block', cursor: 'pointer', marginRight: theme.spacing(3), '&:last-of-type': { marginRight: 0 }, '&.active svg': { fill: theme.palette.success01 }, '&:focus': { outline: `1px solid ${theme.palette.action01}`, borderRadius: '4px' } }, details: { '& textarea': { minHeight: '122px' } } }; }); /** * The scores to display for selecting. The score is the index in the array and * the value of the index is a translation key used for display in the dialog. */ const SCORES = [ 'feedback.veryBad', 'feedback.bad', 'feedback.average', 'feedback.good', 'feedback.veryGood' ]; const ICON_SIZE = 32; /** * The type of the React {@code Component} props of {@link FeedbackDialog}. */ interface IProps { /** * The JitsiConference that is being rated. The conference is passed in * because feedback can occur after a conference has been left, so * references to it may no longer exist in redux. */ conference: IJitsiConference; /** * Callback invoked when {@code FeedbackDialog} is unmounted. */ onClose: Function; } /** * A React {@code Component} for displaying a dialog to rate the current * conference quality, write a message describing the experience, and submit * the feedback. * * @param {IProps} props - Component's props. * @returns {JSX} */ const FeedbackDialog = ({ conference, onClose }: IProps) => { const { classes } = useStyles(); const dispatch = useDispatch(); const { t } = useTranslation(); const _message = useSelector((state: IReduxState) => state['features/feedback'].message); const _score = useSelector((state: IReduxState) => state['features/feedback'].score); /** * The currently entered feedback message. */ const [ message, setMessage ] = useState(_message); /** * The score selection index which is currently being hovered. The * value -1 is used as a sentinel value to match store behavior of * using -1 for no score having been selected. */ const [ mousedOverScore, setMousedOverScore ] = useState(-1); /** * The currently selected score selection index. The score will not * be 0 indexed so subtract one to map with SCORES. */ const [ score, setScore ] = useState(_score > -1 ? _score - 1 : _score); /** * An array of objects with click handlers for each of the scores listed in * the constant SCORES. This pattern is used for binding event handlers only * once for each score selection icon. */ const scoreClickConfigurations = useRef(SCORES.map((textKey, index) => { return { _onClick: () => onScoreSelect(index), _onKeyDown: (e: React.KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); onScoreSelect(index); } }, _onMouseOver: () => onScoreMouseOver(index) }; })); useEffect(() => { sendAnalytics(createFeedbackOpenEvent()); if (typeof APP !== 'undefined') { APP.API.notifyFeedbackPromptDisplayed(); } return () => { onClose?.(); }; }, []); /** * Dispatches an action notifying feedback was not submitted. The submitted * score will have one added as the rest of the app does not expect 0 * indexing. * * @private * @returns {boolean} Returns true to close the dialog. */ const onCancel = useCallback(() => { const scoreToSubmit = score > -1 ? score + 1 : score; dispatch(cancelFeedback(scoreToSubmit, message)); return true; }, [ score, message ]); /** * Updates the known entered feedback message. * * @param {string} newValue - The new value from updating the textfield for the * feedback message. * @private * @returns {void} */ const onMessageChange = useCallback((newValue: string) => { setMessage(newValue); }, []); /** * Updates the currently selected score. * * @param {number} newScore - The index of the selected score in SCORES. * @private * @returns {void} */ function onScoreSelect(newScore: number) { setScore(newScore); } /** * Sets the currently hovered score to null to indicate no hover is * occurring. * * @private * @returns {void} */ const onScoreContainerMouseLeave = useCallback(() => { setMousedOverScore(-1); }, []); /** * Updates the known state of the score icon currently behind hovered over. * * @param {number} newMousedOverScore - The index of the SCORES value currently * being moused over. * @private * @returns {void} */ function onScoreMouseOver(newMousedOverScore: number) { setMousedOverScore(newMousedOverScore); } /** * Dispatches the entered feedback for submission. The submitted score will * have one added as the rest of the app does not expect 0 indexing. * * @private * @returns {boolean} Returns true to close the dialog. */ const _onSubmit = useCallback(() => { const scoreToSubmit = score > -1 ? score + 1 : score; dispatch(submitFeedback(scoreToSubmit, message, conference)); return true; }, [ score, message, conference ]); const scoreToDisplayAsSelected = mousedOverScore > -1 ? mousedOverScore : score; const scoreIcons = scoreClickConfigurations.current.map( (config, index) => { const isFilled = index <= scoreToDisplayAsSelected; const activeClass = isFilled ? 'active' : ''; const className = `${classes.starBtn} ${activeClass}`; return ( {isFilled ? : } ); }); return (
{scoreIcons}

{t('feedback.accessibilityLabel.yourChoice', { rating: t(SCORES[scoreToDisplayAsSelected]) })}

{t(SCORES[scoreToDisplayAsSelected])}

); }; export default FeedbackDialog;