This commit is contained in:
57
react/features/feedback/components/FeedbackButton.web.ts
Normal file
57
react/features/feedback/components/FeedbackButton.web.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { IJitsiConference } from '../../base/conference/reducer';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import { IconFeedback } from '../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
|
||||
import { openFeedbackDialog } from '../actions';
|
||||
import { shouldSendJaaSFeedbackMetadata } from '../functions.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link FeedbackButton}.
|
||||
*/
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a button for opening feedback dialog.
|
||||
*/
|
||||
class FeedbackButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.feedback';
|
||||
override icon = IconFeedback;
|
||||
override label = 'toolbar.feedback';
|
||||
override tooltip = 'toolbar.feedback';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { _conference, dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('feedback'));
|
||||
dispatch(openFeedbackDialog(_conference));
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IReduxState) => {
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
return {
|
||||
_conference: conference,
|
||||
visible: shouldSendJaaSFeedbackMetadata(state)
|
||||
};
|
||||
};
|
||||
|
||||
export default translate(connect(mapStateToProps)(FeedbackButton));
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
export default Component;
|
||||
330
react/features/feedback/components/FeedbackDialog.web.tsx
Normal file
330
react/features/feedback/components/FeedbackDialog.web.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user