You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FeedbackDialog.web.tsx 9.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import React, { useCallback, useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useDispatch, useSelector } from 'react-redux';
  4. import { makeStyles } from 'tss-react/mui';
  5. import { createFeedbackOpenEvent } from '../../analytics/AnalyticsEvents';
  6. import { sendAnalytics } from '../../analytics/functions';
  7. import { IReduxState } from '../../app/types';
  8. import { IJitsiConference } from '../../base/conference/reducer';
  9. import { isMobileBrowser } from '../../base/environment/utils';
  10. import Icon from '../../base/icons/components/Icon';
  11. import { IconFavorite, IconFavoriteSolid } from '../../base/icons/svg';
  12. import { withPixelLineHeight } from '../../base/styles/functions.web';
  13. import Dialog from '../../base/ui/components/web/Dialog';
  14. import Input from '../../base/ui/components/web/Input';
  15. import { cancelFeedback, submitFeedback } from '../actions.web';
  16. const useStyles = makeStyles()(theme => {
  17. return {
  18. dialog: {
  19. marginBottom: theme.spacing(1)
  20. },
  21. rating: {
  22. display: 'flex',
  23. flexDirection: 'column-reverse' as const,
  24. alignItems: 'center',
  25. justifyContent: 'center',
  26. marginTop: theme.spacing(4),
  27. marginBottom: theme.spacing(3)
  28. },
  29. ratingLabel: {
  30. ...withPixelLineHeight(theme.typography.bodyShortBold),
  31. color: theme.palette.text01,
  32. marginBottom: theme.spacing(2),
  33. height: '20px'
  34. },
  35. stars: {
  36. display: 'flex'
  37. },
  38. starBtn: {
  39. display: 'inline-block',
  40. cursor: 'pointer',
  41. marginRight: theme.spacing(3),
  42. '&:last-of-type': {
  43. marginRight: 0
  44. },
  45. '&.active svg': {
  46. fill: theme.palette.success01
  47. },
  48. '&:focus': {
  49. outline: `1px solid ${theme.palette.action01}`,
  50. borderRadius: '4px'
  51. }
  52. },
  53. details: {
  54. '& textarea': {
  55. minHeight: '122px'
  56. }
  57. }
  58. };
  59. });
  60. /**
  61. * The scores to display for selecting. The score is the index in the array and
  62. * the value of the index is a translation key used for display in the dialog.
  63. */
  64. const SCORES = [
  65. 'feedback.veryBad',
  66. 'feedback.bad',
  67. 'feedback.average',
  68. 'feedback.good',
  69. 'feedback.veryGood'
  70. ];
  71. const ICON_SIZE = 32;
  72. /**
  73. * The type of the React {@code Component} props of {@link FeedbackDialog}.
  74. */
  75. interface IProps {
  76. /**
  77. * The JitsiConference that is being rated. The conference is passed in
  78. * because feedback can occur after a conference has been left, so
  79. * references to it may no longer exist in redux.
  80. */
  81. conference: IJitsiConference;
  82. /**
  83. * Callback invoked when {@code FeedbackDialog} is unmounted.
  84. */
  85. onClose: Function;
  86. }
  87. /**
  88. * A React {@code Component} for displaying a dialog to rate the current
  89. * conference quality, write a message describing the experience, and submit
  90. * the feedback.
  91. *
  92. * @param {IProps} props - Component's props.
  93. * @returns {JSX}
  94. */
  95. const FeedbackDialog = ({ conference, onClose }: IProps) => {
  96. const { classes } = useStyles();
  97. const dispatch = useDispatch();
  98. const { t } = useTranslation();
  99. const _message = useSelector((state: IReduxState) => state['features/feedback'].message);
  100. const _score = useSelector((state: IReduxState) => state['features/feedback'].score);
  101. /**
  102. * The currently entered feedback message.
  103. */
  104. const [ message, setMessage ] = useState(_message);
  105. /**
  106. * The score selection index which is currently being hovered. The
  107. * value -1 is used as a sentinel value to match store behavior of
  108. * using -1 for no score having been selected.
  109. */
  110. const [ mousedOverScore, setMousedOverScore ] = useState(-1);
  111. /**
  112. * The currently selected score selection index. The score will not
  113. * be 0 indexed so subtract one to map with SCORES.
  114. */
  115. const [ score, setScore ] = useState(_score > -1 ? _score - 1 : _score);
  116. /**
  117. * An array of objects with click handlers for each of the scores listed in
  118. * the constant SCORES. This pattern is used for binding event handlers only
  119. * once for each score selection icon.
  120. */
  121. const scoreClickConfigurations = useRef(SCORES.map((textKey, index) => {
  122. return {
  123. _onClick: () => onScoreSelect(index),
  124. _onKeyDown: (e: React.KeyboardEvent) => {
  125. if (e.key === ' ' || e.key === 'Enter') {
  126. e.stopPropagation();
  127. e.preventDefault();
  128. onScoreSelect(index);
  129. }
  130. },
  131. _onMouseOver: () => onScoreMouseOver(index)
  132. };
  133. }));
  134. useEffect(() => {
  135. sendAnalytics(createFeedbackOpenEvent());
  136. if (typeof APP !== 'undefined') {
  137. APP.API.notifyFeedbackPromptDisplayed();
  138. }
  139. return () => {
  140. onClose?.();
  141. };
  142. }, []);
  143. /**
  144. * Dispatches an action notifying feedback was not submitted. The submitted
  145. * score will have one added as the rest of the app does not expect 0
  146. * indexing.
  147. *
  148. * @private
  149. * @returns {boolean} Returns true to close the dialog.
  150. */
  151. const onCancel = useCallback(() => {
  152. const scoreToSubmit = score > -1 ? score + 1 : score;
  153. dispatch(cancelFeedback(scoreToSubmit, message));
  154. return true;
  155. }, [ score, message ]);
  156. /**
  157. * Updates the known entered feedback message.
  158. *
  159. * @param {string} newValue - The new value from updating the textfield for the
  160. * feedback message.
  161. * @private
  162. * @returns {void}
  163. */
  164. const onMessageChange = useCallback((newValue: string) => {
  165. setMessage(newValue);
  166. }, []);
  167. /**
  168. * Updates the currently selected score.
  169. *
  170. * @param {number} newScore - The index of the selected score in SCORES.
  171. * @private
  172. * @returns {void}
  173. */
  174. function onScoreSelect(newScore: number) {
  175. setScore(newScore);
  176. }
  177. /**
  178. * Sets the currently hovered score to null to indicate no hover is
  179. * occurring.
  180. *
  181. * @private
  182. * @returns {void}
  183. */
  184. const onScoreContainerMouseLeave = useCallback(() => {
  185. setMousedOverScore(-1);
  186. }, []);
  187. /**
  188. * Updates the known state of the score icon currently behind hovered over.
  189. *
  190. * @param {number} newMousedOverScore - The index of the SCORES value currently
  191. * being moused over.
  192. * @private
  193. * @returns {void}
  194. */
  195. function onScoreMouseOver(newMousedOverScore: number) {
  196. setMousedOverScore(newMousedOverScore);
  197. }
  198. /**
  199. * Dispatches the entered feedback for submission. The submitted score will
  200. * have one added as the rest of the app does not expect 0 indexing.
  201. *
  202. * @private
  203. * @returns {boolean} Returns true to close the dialog.
  204. */
  205. const _onSubmit = useCallback(() => {
  206. const scoreToSubmit = score > -1 ? score + 1 : score;
  207. dispatch(submitFeedback(scoreToSubmit, message, conference));
  208. return true;
  209. }, [ score, message, conference ]);
  210. const scoreToDisplayAsSelected
  211. = mousedOverScore > -1 ? mousedOverScore : score;
  212. const scoreIcons = scoreClickConfigurations.current.map(
  213. (config, index) => {
  214. const isFilled = index <= scoreToDisplayAsSelected;
  215. const activeClass = isFilled ? 'active' : '';
  216. const className
  217. = `${classes.starBtn} ${activeClass}`;
  218. return (
  219. <span
  220. aria-label = { t(SCORES[index]) }
  221. className = { className }
  222. key = { index }
  223. onClick = { config._onClick }
  224. onKeyDown = { config._onKeyDown }
  225. role = 'button'
  226. tabIndex = { 0 }
  227. { ...(isMobileBrowser() ? {} : {
  228. onMouseOver: config._onMouseOver
  229. }) }>
  230. {isFilled
  231. ? <Icon
  232. size = { ICON_SIZE }
  233. src = { IconFavoriteSolid } />
  234. : <Icon
  235. size = { ICON_SIZE }
  236. src = { IconFavorite } />}
  237. </span>
  238. );
  239. });
  240. return (
  241. <Dialog
  242. ok = {{
  243. translationKey: 'dialog.Submit'
  244. }}
  245. onCancel = { onCancel }
  246. onSubmit = { _onSubmit }
  247. size = 'large'
  248. titleKey = 'feedback.rateExperience'>
  249. <div className = { classes.dialog }>
  250. <div className = { classes.rating }>
  251. <div
  252. className = { classes.stars }
  253. onMouseLeave = { onScoreContainerMouseLeave }>
  254. {scoreIcons}
  255. </div>
  256. <div
  257. className = { classes.ratingLabel } >
  258. <p className = 'sr-only'>
  259. {t('feedback.accessibilityLabel.yourChoice', {
  260. rating: t(SCORES[scoreToDisplayAsSelected])
  261. })}
  262. </p>
  263. <p
  264. aria-hidden = { true }
  265. id = 'starLabel'>
  266. {t(SCORES[scoreToDisplayAsSelected])}
  267. </p>
  268. </div>
  269. </div>
  270. <div className = { classes.details }>
  271. <Input
  272. id = 'feedbackTextArea'
  273. label = { t('feedback.detailsLabel') }
  274. onChange = { onMessageChange }
  275. textarea = { true }
  276. value = { message } />
  277. </div>
  278. </div>
  279. </Dialog>
  280. );
  281. };
  282. export default FeedbackDialog;