選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

FeedbackDialog.web.tsx 10KB

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