jiti-meet/react/features/feedback/components/FeedbackDialog.web.js

410 lines
12 KiB
JavaScript

// @flow
import StarIcon from '@atlaskit/icon/glyph/star';
import StarFilledIcon from '@atlaskit/icon/glyph/star-filled';
import React, { Component } from 'react';
import type { Dispatch } from 'redux';
import {
createFeedbackOpenEvent,
sendAnalytics
} from '../../analytics';
import { Dialog } from '../../base/dialog';
import { isMobileBrowser } from '../../base/environment/utils';
import { translate } from '../../base/i18n';
import { connect } from '../../base/redux';
import Input from '../../base/ui/components/web/Input';
import { cancelFeedback, submitFeedback } from '../actions';
declare var APP: Object;
declare var interfaceConfig: Object;
const scoreAnimationClass
= interfaceConfig.ENABLE_FEEDBACK_ANIMATION ? 'shake-rotate' : '';
/**
* 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'
];
type Scrollable = {
scroll: Function
}
/**
* The type of the React {@code Component} props of {@link FeedbackDialog}.
*/
type Props = {
/**
* The cached feedback message, if any, that was set when closing a previous
* instance of {@code FeedbackDialog}.
*/
_message: string,
/**
* The cached feedback score, if any, that was set when closing a previous
* instance of {@code FeedbackDialog}.
*/
_score: number,
/**
* 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: Object,
/**
* Invoked to signal feedback submission or canceling.
*/
dispatch: Dispatch<any>,
/**
* Callback invoked when {@code FeedbackDialog} is unmounted.
*/
onClose: Function,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* The type of the React {@code Component} state of {@link FeedbackDialog}.
*/
type State = {
/**
* The currently entered feedback message.
*/
message: string,
/**
* 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.
*/
mousedOverScore: number,
/**
* The currently selected score selection index. The score will not be 0
* indexed so subtract one to map with SCORES.
*/
score: number
};
/**
* A React {@code Component} for displaying a dialog to rate the current
* conference quality, write a message describing the experience, and submit
* the feedback.
*
* @augments Component
*/
class FeedbackDialog extends Component<Props, State> {
/**
* 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.
*/
_scoreClickConfigurations: Array<Object>;
/**
* Initializes a new {@code FeedbackDialog} instance.
*
* @param {Object} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props: Props) {
super(props);
const { _message, _score } = this.props;
this.state = {
/**
* The currently entered feedback message.
*
* @type {string}
*/
message: _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.
*
* @type {number}
*/
mousedOverScore: -1,
/**
* The currently selected score selection index. The score will not
* be 0 indexed so subtract one to map with SCORES.
*
* @type {number}
*/
score: _score > -1 ? _score - 1 : _score
};
this._scoreClickConfigurations = SCORES.map((textKey, index) => {
return {
_onClick: () => this._onScoreSelect(index),
_onKeyPres: e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._onScoreSelect(index);
}
},
_onMouseOver: () => this._onScoreMouseOver(index)
};
});
// Bind event handlers so they are only bound once for every instance.
this._onCancel = this._onCancel.bind(this);
this._onMessageChange = this._onMessageChange.bind(this);
this._onScoreContainerMouseLeave
= this._onScoreContainerMouseLeave.bind(this);
this._onSubmit = this._onSubmit.bind(this);
// On some mobile browsers opening Feedback dialog scrolls down the whole content because of the keyboard.
// By scrolling to the top we prevent hiding the feedback stars so the user knows those exist.
this._onScrollTop = (node: ?Scrollable) => {
node && node.scroll && node.scroll(0, 0);
};
}
/**
* Emits an analytics event to notify feedback has been opened.
*
* @inheritdoc
*/
componentDidMount() {
sendAnalytics(createFeedbackOpenEvent());
if (typeof APP !== 'undefined') {
APP.API.notifyFeedbackPromptDisplayed();
}
}
/**
* Invokes the onClose callback, if defined, to notify of the close event.
*
* @inheritdoc
*/
componentWillUnmount() {
if (this.props.onClose) {
this.props.onClose();
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { message, mousedOverScore, score } = this.state;
const scoreToDisplayAsSelected
= mousedOverScore > -1 ? mousedOverScore : score;
const { t } = this.props;
const scoreIcons = this._scoreClickConfigurations.map(
(config, index) => {
const isFilled = index <= scoreToDisplayAsSelected;
const activeClass = isFilled ? 'active' : '';
const className
= `star-btn ${scoreAnimationClass} ${activeClass}`;
return (
<span
aria-label = { t(SCORES[index]) }
className = { className }
key = { index }
onClick = { config._onClick }
onKeyPress = { config._onKeyPres }
role = 'button'
tabIndex = { 0 }
{ ...(isMobileBrowser() ? {} : {
onMouseOver: config._onMouseOver
}) }>
{ isFilled
? <StarFilledIcon
label = 'star-filled'
size = 'xlarge' />
: <StarIcon
label = 'star'
size = 'xlarge' /> }
</span>
);
});
return (
<Dialog
okKey = 'dialog.Submit'
onCancel = { this._onCancel }
onDialogRef = { this._onScrollTop }
onSubmit = { this._onSubmit }
titleKey = 'feedback.rateExperience'>
<div className = 'feedback-dialog'>
<div className = 'rating'>
<div
aria-label = { this.props.t('feedback.star') }
className = 'star-label' >
<p id = 'starLabel'>
{ t(SCORES[scoreToDisplayAsSelected]) }
</p>
</div>
<div
className = 'stars'
onMouseLeave = { this._onScoreContainerMouseLeave }>
{ scoreIcons }
</div>
</div>
<div className = 'details'>
<Input
autoFocus = { true }
id = 'feedbackTextArea'
label = { t('feedback.detailsLabel') }
onChange = { this._onMessageChange }
textarea = { true }
value = { message } />
</div>
</div>
</Dialog>
);
}
_onCancel: () => boolean;
/**
* 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.
*/
_onCancel() {
const { message, score } = this.state;
const scoreToSubmit = score > -1 ? score + 1 : score;
this.props.dispatch(cancelFeedback(scoreToSubmit, message));
return true;
}
_onMessageChange: (Object) => void;
/**
* Updates the known entered feedback message.
*
* @param {string} newValue - The new value from updating the textfield for the
* feedback message.
* @private
* @returns {void}
*/
_onMessageChange(newValue) {
this.setState({ message: newValue });
}
/**
* Updates the currently selected score.
*
* @param {number} score - The index of the selected score in SCORES.
* @private
* @returns {void}
*/
_onScoreSelect(score) {
this.setState({ score });
}
_onScoreContainerMouseLeave: () => void;
/**
* Sets the currently hovered score to null to indicate no hover is
* occurring.
*
* @private
* @returns {void}
*/
_onScoreContainerMouseLeave() {
this.setState({ mousedOverScore: -1 });
}
/**
* Updates the known state of the score icon currently behind hovered over.
*
* @param {number} mousedOverScore - The index of the SCORES value currently
* being moused over.
* @private
* @returns {void}
*/
_onScoreMouseOver(mousedOverScore) {
this.setState({ mousedOverScore });
}
_onSubmit: () => void;
/**
* 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.
*/
_onSubmit() {
const { conference, dispatch } = this.props;
const { message, score } = this.state;
const scoreToSubmit = score > -1 ? score + 1 : score;
dispatch(submitFeedback(scoreToSubmit, message, conference));
return true;
}
_onScrollTop: (node: ?Scrollable) => void;
}
/**
* Maps (parts of) the Redux state to the associated {@code FeedbackDialog}'s
* props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* }}
*/
function _mapStateToProps(state) {
const { message, score } = state['features/feedback'];
return {
/**
* The cached feedback message, if any, that was set when closing a
* previous instance of {@code FeedbackDialog}.
*
* @type {string}
*/
_message: message,
/**
* The currently selected score selection index.
*
* @type {number}
*/
_score: score
};
}
export default translate(connect(_mapStateToProps)(FeedbackDialog));