Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
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 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: {
|
|
...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'
|
|
}
|
|
},
|
|
|
|
title: {
|
|
fontSize: '1rem'
|
|
},
|
|
|
|
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;
|
|
|
|
/**
|
|
* The title to display in the dialog. Usually the reason that triggered the feedback.
|
|
*/
|
|
title?: string;
|
|
}
|
|
|
|
/**
|
|
* 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, title }: 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 (
|
|
<span
|
|
aria-label = { t(SCORES[index]) }
|
|
className = { className }
|
|
key = { index }
|
|
onClick = { config._onClick }
|
|
onKeyDown = { config._onKeyDown }
|
|
role = 'button'
|
|
tabIndex = { 0 }
|
|
{ ...(isMobileBrowser() ? {} : {
|
|
onMouseOver: config._onMouseOver
|
|
}) }>
|
|
{isFilled
|
|
? <Icon
|
|
size = { ICON_SIZE }
|
|
src = { IconFavoriteSolid } />
|
|
: <Icon
|
|
size = { ICON_SIZE }
|
|
src = { IconFavorite } />}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
|
|
return (
|
|
<Dialog
|
|
disableEnter = { true }
|
|
ok = {{
|
|
translationKey: 'dialog.Submit'
|
|
}}
|
|
onCancel = { onCancel }
|
|
onSubmit = { _onSubmit }
|
|
size = 'large'
|
|
titleKey = 'feedback.rateExperience'>
|
|
<div className = { classes.dialog }>
|
|
{title ? <div className = { classes.title }>{t(title)}</div> : null}
|
|
<div className = { classes.rating }>
|
|
<div
|
|
className = { classes.stars }
|
|
onMouseLeave = { onScoreContainerMouseLeave }>
|
|
{scoreIcons}
|
|
</div>
|
|
<div
|
|
className = { classes.ratingLabel } >
|
|
<p className = 'sr-only'>
|
|
{t('feedback.accessibilityLabel.yourChoice', {
|
|
rating: t(SCORES[scoreToDisplayAsSelected])
|
|
})}
|
|
</p>
|
|
<p
|
|
aria-hidden = { true }
|
|
id = 'starLabel'>
|
|
{t(SCORES[scoreToDisplayAsSelected])}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className = { classes.details }>
|
|
<Input
|
|
id = 'feedbackTextArea'
|
|
label = { t('feedback.detailsLabel') }
|
|
onChange = { onMessageChange }
|
|
textarea = { true }
|
|
value = { message } />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default FeedbackDialog;
|