From ff442853a24c1eb3ac85a9cefaa02311363c113d Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Mon, 7 Aug 2017 09:20:44 -0700 Subject: [PATCH] feat(feedback): convert to react and redux (#1833) * feat(feedback): convert to react and redux - For styles, remove "aui-dialog2" nesting so existing styles can be reused. - Remove Feedback.js and replace with calls to redux for state storing and accessing. - Add dispatching to FeedbackButton instead of relying on jquery clicking handling so the button can be hooked into redux. * address feedback * remove calling to not show feedback for recorder and filmstrip --- conference.js | 18 +- css/modals/feedback/_feedback.scss | 130 +++---- lang/main.json | 9 +- modules/UI/UI.js | 43 --- modules/UI/feedback/Feedback.js | 95 ----- modules/UI/feedback/FeedbackWindow.js | 184 ---------- modules/UI/recording/Recording.js | 2 - react/features/feedback/actionTypes.js | 21 ++ react/features/feedback/actions.js | 122 +++++++ .../feedback/components/FeedbackButton.web.js | 64 +++- .../feedback/components/FeedbackDialog.web.js | 343 ++++++++++++++++++ react/features/feedback/components/index.js | 3 +- react/features/feedback/index.js | 4 + react/features/feedback/reducer.js | 45 +++ .../components/SecondaryToolbar.web.js | 17 +- 15 files changed, 676 insertions(+), 424 deletions(-) delete mode 100644 modules/UI/feedback/Feedback.js delete mode 100644 modules/UI/feedback/FeedbackWindow.js create mode 100644 react/features/feedback/actionTypes.js create mode 100644 react/features/feedback/actions.js create mode 100644 react/features/feedback/components/FeedbackDialog.web.js create mode 100644 react/features/feedback/reducer.js diff --git a/conference.js b/conference.js index ef47664ec..4c6ebccf8 100644 --- a/conference.js +++ b/conference.js @@ -57,6 +57,7 @@ import { import { getLocationContextRoot } from './react/features/base/util'; import { statsEmitter } from './react/features/connection-indicator'; import { showDesktopPicker } from './react/features/desktop-picker'; +import { maybeOpenFeedbackDialog } from './react/features/feedback'; import { mediaPermissionPromptVisibilityChanged, suspendDetected @@ -2398,12 +2399,17 @@ export default { hangup(requestFeedback = false) { eventEmitter.emit(JitsiMeetConferenceEvents.BEFORE_HANGUP); - let requestFeedbackPromise = requestFeedback - ? APP.UI.requestFeedbackOnHangup() - // false - because the thank you dialog shouldn't be displayed - .catch(() => Promise.resolve(false)) - : Promise.resolve(true);// true - because the thank you dialog - //should be displayed + let requestFeedbackPromise; + + if (requestFeedback) { + requestFeedbackPromise + = APP.store.dispatch(maybeOpenFeedbackDialog(room)) + // false because the thank you dialog shouldn't be displayed + .catch(() => Promise.resolve(false)); + } else { + requestFeedbackPromise = Promise.resolve(true); + } + // All promises are returning Promise.resolve to make Promise.all to // be resolved when both Promises are finished. Otherwise Promise.all // will reject on first rejected Promise and we can redirect the page diff --git a/css/modals/feedback/_feedback.scss b/css/modals/feedback/_feedback.scss index 7ce14fb4e..08d2bc5d3 100644 --- a/css/modals/feedback/_feedback.scss +++ b/css/modals/feedback/_feedback.scss @@ -45,83 +45,59 @@ animation-timing-function: ease-in-out } -.feedback.aui-dialog2{ - .aui-dialog2{ - &-header { - background-color: $feedbackContentBg; - border-bottom-color: transparent; - padding-top: 30px; - h2 { - color: $feedbackTextColor; - text-align: center; - } - } +.feedback-dialog { + .details { + margin-top: 20px; + padding-left: 60px; + padding-right: 60px; - &-content { - background-color: $feedbackContentBg; - text-align: center; - padding: 10px 40px 20px 40px; - - .input-control { - background-color: $feedbackInputBg; - color: $feedbackInputTextColor; - - &::-webkit-input-placeholder { - color: $feedbackInputPlaceholderColor; - } - &::-moz-placeholder { /* Firefox 19+ */ - color: $feedbackInputPlaceholderColor; - } - &:-ms-input-placeholder { - color: $feedbackInputPlaceholderColor; - } - } - - .rating { - line-height: 1.2; - text-align: center; - margin-top: 10px; - - .star-label { - height: 16px; - font-size: 14px; - color: $rateStarLabelColor; - } - .star-btn { - display: inline-block; - color: $rateStarDefault; - font-size: $rateStarSize; - position: relative; - cursor: pointer; - outline: none; - text-decoration: none; - @include transition(all .2s ease); - - &.starHover, - &.active, - &:hover { - color: $rateStarActivity; - }; - - } - } - - .details { - padding-left: 60px; - padding-right: 60px; - margin-top: 20px; - textarea { - min-height: 100px; - } - } - } - &-footer { - background-color: $feedbackContentBg; - border-top-color: transparent; - - .button-control { - color: $feedbackCancelFontColor; - } + textarea { + min-height: 100px; } } -} \ No newline at end of file + + .input-control { + background-color: $feedbackInputBg; + color: $feedbackInputTextColor; + + &::-webkit-input-placeholder { + color: $feedbackInputPlaceholderColor; + } + &::-moz-placeholder { /* Firefox 19+ */ + color: $feedbackInputPlaceholderColor; + } + &:-ms-input-placeholder { + color: $feedbackInputPlaceholderColor; + } + } + + .rating { + line-height: 1.2; + margin-top: 10px; + text-align: center; + + .star-label { + color: $rateStarLabelColor; + font-size: 14px; + height: 16px; + } + + .star-btn { + color: $rateStarDefault; + cursor: pointer; + display: inline-block; + font-size: $rateStarSize; + outline: none; + position: relative; + text-decoration: none; + @include transition(all .2s ease); + + &.active, + &:hover, + &.starHover { + color: $rateStarActivity; + }; + + } + } +} diff --git a/lang/main.json b/lang/main.json index c59901a53..2dcb9682d 100644 --- a/lang/main.json +++ b/lang/main.json @@ -301,7 +301,6 @@ "enterDisplayName": "Please enter your display name", "extensionRequired": "Extension required:", "firefoxExtensionPrompt": "You need to install a Firefox extension in order to use screen sharing. Please try again after you get it from here!", - "rateExperience": "Please rate your meeting experience.", "feedbackHelp": "Your feedback will help us to improve our video experience.", "feedbackQuestion": "Tell us about your call!", "thankYou": "Thank you for using __appName__!", @@ -478,5 +477,13 @@ "deviceError": { "cameraPermission": "Error obtaining camera permission", "microphonePermission": "Error obtaining microphone permission" + }, + "feedback": { + "average": "Average", + "bad": "Bad", + "good": "Good", + "rateExperience": "Please rate your meeting experience.", + "veryBad": "Very Bad", + "veryGood": "Very Good" } } diff --git a/modules/UI/UI.js b/modules/UI/UI.js index f0a4deaa6..0cd5d86d8 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -21,7 +21,6 @@ import Filmstrip from "./videolayout/Filmstrip"; import SettingsMenu from "./side_pannels/settings/SettingsMenu"; import Profile from "./side_pannels/profile/Profile"; import Settings from "./../settings/Settings"; -import { FEEDBACK_REQUEST_IN_PROGRESS } from './UIErrors'; import { debounce } from "../util/helpers"; import { updateDeviceList } from '../../react/features/base/devices'; @@ -47,7 +46,6 @@ import { var EventEmitter = require("events"); UI.messageHandler = messageHandler; -import Feedback from "./feedback/Feedback"; import FollowMe from "../FollowMe"; var eventEmitter = new EventEmitter(); @@ -228,10 +226,6 @@ UI.initConference = function () { APP.store.dispatch(checkAutoEnableDesktopSharing()); - if(!interfaceConfig.filmStripOnly) { - Feedback.init(eventEmitter); - } - // FollowMe attempts to copy certain aspects of the moderator's UI into the // other participants' UI. Consequently, it needs (1) read and write access // to the UI (depending on the moderator role of the local participant) and @@ -922,43 +916,6 @@ UI.addMessage = function (from, displayName, message, stamp) { UI.updateDTMFSupport = isDTMFSupported => APP.store.dispatch(showDialPadButton(isDTMFSupported)); -/** - * Show user feedback dialog if its required and enabled after pressing the - * hangup button. - * @returns {Promise} Resolved with value - false if the dialog is enabled and - * resolved with true if the dialog is disabled or the feedback was already - * submitted. Rejected if another dialog is already displayed. This values are - * used to display or not display the thank you dialog from - * conference.maybeRedirectToWelcomePage method. - */ -UI.requestFeedbackOnHangup = function () { - if (Feedback.isVisible()) - return Promise.reject(FEEDBACK_REQUEST_IN_PROGRESS); - // Feedback has been submitted already. - else if (Feedback.isEnabled() && Feedback.isSubmitted()) { - return Promise.resolve({ - thankYouDialogVisible : true, - feedbackSubmitted: true - }); - } - else - return new Promise(function (resolve) { - if (Feedback.isEnabled()) { - Feedback.openFeedbackWindow( - (options) => { - options.thankYouDialogVisible = false; - resolve(options); - }); - } else { - // If the feedback functionality isn't enabled we show a thank - // you dialog. Signaling it (true), so the caller - // of requestFeedback can act on it - resolve( - {thankYouDialogVisible : true, feedbackSubmitted: false}); - } - }); -}; - UI.updateRecordingState = function (state) { Recording.updateRecordingState(state); }; diff --git a/modules/UI/feedback/Feedback.js b/modules/UI/feedback/Feedback.js deleted file mode 100644 index f45b85ca4..000000000 --- a/modules/UI/feedback/Feedback.js +++ /dev/null @@ -1,95 +0,0 @@ -/* global $, APP, JitsiMeetJS */ -import FeedbackWindow from "./FeedbackWindow"; - -/** - * Defines all methods in connection to the Feedback window. - * - * @type {{openFeedbackWindow: Function}} - */ -const Feedback = { - - /** - * Initialise the Feedback functionality. - * @param emitter the EventEmitter to associate with the Feedback. - */ - init: function (emitter) { - // CallStats is the way we send feedback, so we don't have to initialise - // if callstats isn't enabled. - if (!APP.conference.isCallstatsEnabled()) - return; - - // If enabled property is still undefined, i.e. it hasn't been set from - // some other module already, we set it to true by default. - if (typeof this.enabled == "undefined") - this.enabled = true; - - this.window = new FeedbackWindow(); - this.emitter = emitter; - - $("#feedbackButton").click(Feedback.openFeedbackWindow); - }, - /** - * Enables/ disabled the feedback feature. - */ - enableFeedback: function (enable) { - this.enabled = enable; - }, - - /** - * Indicates if the feedback functionality is enabled. - * - * @return true if the feedback functionality is enabled, false otherwise. - */ - isEnabled: function() { - return this.enabled && APP.conference.isCallstatsEnabled(); - }, - - /** - * Returns true if the feedback window is currently visible and false - * otherwise. - * @return {boolean} true if the feedback window is visible, false - * otherwise - */ - isVisible: function() { - return $(".feedback").is(":visible"); - }, - - /** - * Indicates if the feedback is submitted. - * - * @return {boolean} {true} to indicate if the feedback is submitted, - * {false} - otherwise - */ - isSubmitted: function() { - return Feedback.window.submitted; - }, - - /** - * Opens the feedback window. - */ - openFeedbackWindow: function (callback) { - Feedback.window.show(callback); - - JitsiMeetJS.analytics.sendEvent('feedback.open'); - }, - - /** - * Returns the feedback score. - * - * @returns {*} - */ - getFeedbackScore: function() { - return Feedback.window.feedbackScore; - }, - - /** - * Returns the feedback free text. - * - * @returns {null|*|message} - */ - getFeedbackText: function() { - return Feedback.window.feedbackText; - } -}; - -export default Feedback; diff --git a/modules/UI/feedback/FeedbackWindow.js b/modules/UI/feedback/FeedbackWindow.js deleted file mode 100644 index 35a444028..000000000 --- a/modules/UI/feedback/FeedbackWindow.js +++ /dev/null @@ -1,184 +0,0 @@ -/* global $, APP, interfaceConfig */ - -const labels = { - 1: 'Very Bad', - 2: 'Bad', - 3: 'Average', - 4: 'Good', - 5: 'Very Good' -}; - -/** - * Toggles the appropriate css class for the given number of stars, to - * indicate that those stars have been clicked/selected. - * - * @param starCount the number of stars, for which to toggle the css class - */ -function toggleStars(starCount) { - let labelEl = $('#starLabel'); - let label = starCount >= 0 ? - labels[starCount + 1] : - ''; - - $('#stars > a').each(function(index, el) { - if (index <= starCount) { - el.classList.add("starHover"); - } else - el.classList.remove("starHover"); - }); - labelEl.text(label); -} - -/** - * Constructs the html for the rated feedback window. - * - * @returns {string} the contructed html string - */ -function createRateFeedbackHTML() { - - let starClassName = (interfaceConfig.ENABLE_FEEDBACK_ANIMATION) - ? "icon-star-full shake-rotate" - : "icon-star-full"; - - return ` -
-
-
-

 

-
- -
-
- -
-
`; -} - -/** - * Feedback is loaded callback - * Calls when Modal window is in DOM - * - * @param Feedback - */ -let onLoadFunction = function (Feedback) { - $('#stars > a').each((index, el) => { - el.onmouseover = function(){ - toggleStars(index); - }; - el.onmouseleave = function(){ - toggleStars(Feedback.feedbackScore - 1); - }; - el.onclick = function(){ - Feedback.feedbackScore = index + 1; - Feedback.setFeedbackMessage(); - }; - }); - - // Init stars to correspond to previously entered feedback. - if (Feedback.feedbackScore > 0) { - toggleStars(Feedback.feedbackScore - 1); - } - - if (Feedback.feedbackMessage && Feedback.feedbackMessage.length > 0) - $('#feedbackTextArea').text(Feedback.feedbackMessage); - - $('#feedbackTextArea').focus(); -}; - -/** - * On Feedback Submitted callback - * - * @param Feedback - */ -function onFeedbackSubmitted(Feedback) { - let form = $('#feedbackForm'); - let message = form.find('textarea').val(); - - APP.conference.sendFeedback( - Feedback.feedbackScore, - message); - - // TODO: make sendFeedback return true or false. - Feedback.submitted = true; - - //Remove history is submitted - Feedback.feedbackScore = -1; - Feedback.feedbackMessage = ''; - Feedback.onHide(); -} - -/** - * On Feedback Closed callback - * - * @param Feedback - */ -function onFeedbackClosed(Feedback) { - Feedback.onHide(); -} - -/** - * @class Dialog - * - */ -export default class Dialog { - - constructor() { - this.feedbackScore = -1; - this.feedbackMessage = ''; - this.submitted = false; - this.onCloseCallback = function() {}; - - this.setDefaultOptions(); - } - - setDefaultOptions() { - var self = this; - - this.options = { - titleKey: 'dialog.rateExperience', - msgString: createRateFeedbackHTML(), - loadedFunction: function() {onLoadFunction(self);}, - submitFunction: function() {onFeedbackSubmitted(self);}, - closeFunction: function() {onFeedbackClosed(self);}, - wrapperClass: 'feedback', - size: 'medium' - }; - } - - setFeedbackMessage() { - this.feedbackMessage = $('#feedbackTextArea').val(); - } - - show(cb) { - const options = this.options; - if (typeof cb === 'function') { - this.onCloseCallback = cb; - } - - this.window = APP.UI.messageHandler.openTwoButtonDialog(options); - } - - onHide() { - this.onCloseCallback({ - feedbackSubmitted: this.submitted - }); - } -} diff --git a/modules/UI/recording/Recording.js b/modules/UI/recording/Recording.js index 78f99d216..ca5b4b061 100644 --- a/modules/UI/recording/Recording.js +++ b/modules/UI/recording/Recording.js @@ -19,7 +19,6 @@ const logger = require("jitsi-meet-logger").getLogger(__filename); import UIEvents from "../../../service/UI/UIEvents"; import UIUtil from '../util/UIUtil'; import VideoLayout from '../videolayout/VideoLayout'; -import Feedback from '../feedback/Feedback.js'; import { setToolboxEnabled } from '../../../react/features/toolbox'; import { setNotificationsEnabled } from '../../../react/features/notifications'; @@ -308,7 +307,6 @@ var Recording = { VideoLayout.enableDeviceAvailabilityIcons( APP.conference.getMyUserId(), false); VideoLayout.setLocalVideoVisible(false); - Feedback.enableFeedback(false); APP.store.dispatch(setToolboxEnabled(false)); APP.store.dispatch(setNotificationsEnabled(false)); APP.UI.messageHandler.enablePopups(false); diff --git a/react/features/feedback/actionTypes.js b/react/features/feedback/actionTypes.js new file mode 100644 index 000000000..a161ac3f8 --- /dev/null +++ b/react/features/feedback/actionTypes.js @@ -0,0 +1,21 @@ +/** + * The type of the action which signals feedback was closed without submitting. + * + * { + * type: CANCEL_FEEDBACK, + * message: string, + * score: number + * } + */ +export const CANCEL_FEEDBACK = Symbol('CANCEL_FEEDBACK'); + +/** + * The type of the action which signals feedback was submitted for recording. + * + * { + * type: SUBMIT_FEEDBACK, + * message: string, + * score: number + * } + */ +export const SUBMIT_FEEDBACK = Symbol('SUBMIT_FEEDBACK'); diff --git a/react/features/feedback/actions.js b/react/features/feedback/actions.js new file mode 100644 index 000000000..4e9a17163 --- /dev/null +++ b/react/features/feedback/actions.js @@ -0,0 +1,122 @@ +import { FEEDBACK_REQUEST_IN_PROGRESS } from '../../../modules/UI/UIErrors'; + +import { openDialog } from '../../features/base/dialog'; + +import { + CANCEL_FEEDBACK, + SUBMIT_FEEDBACK +} from './actionTypes'; +import { FeedbackDialog } from './components'; + +declare var config: Object; +declare var interfaceConfig: Object; + +/** + * Caches the passed in feedback in the redux store. + * + * @param {number} score - The quality score given to the conference. + * @param {string} message - A description entered by the participant that + * explains the rating. + * @returns {{ + * type: CANCEL_FEEDBACK, + * message: string, + * score: number + * }} + */ +export function cancelFeedback(score, message) { + return { + type: CANCEL_FEEDBACK, + message, + score + }; +} + +/** + * Potentially open the {@code FeedbackDialog}. It will not be opened if it is + * already open or feedback has already been submitted. + * + * @param {JistiConference} conference - The conference for which the feedback + * would be about. 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. + * @returns {Promise} Resolved with value - false if the dialog is enabled and + * resolved with true if the dialog is disabled or the feedback was already + * submitted. Rejected if another dialog is already displayed. + */ +export function maybeOpenFeedbackDialog(conference) { + return (dispatch, getState) => { + const state = getState(); + + if (interfaceConfig.filmStripOnly || config.iAmRecorder) { + // Intentionally fall through the if chain to prevent further action + // from being taken with regards to showing feedback. + } else if (state['features/base/dialog'].component === FeedbackDialog) { + // Feedback is currently being displayed. + + return Promise.reject(FEEDBACK_REQUEST_IN_PROGRESS); + } else if (state['features/feedback'].submitted) { + // Feedback has been submitted already. + + return Promise.resolve({ + thankYouDialogVisible: true, + feedbackSubmitted: true + }); + } else if (conference.isCallstatsEnabled()) { + return new Promise(resolve => { + dispatch(openFeedbackDialog(conference, () => { + const { submitted } = getState()['features/feedback']; + + resolve({ + feedbackSubmitted: submitted, + thankYouDialogVisible: false + }); + })); + }); + } + + // If the feedback functionality isn't enabled we show a thank + // you dialog. Signaling it (true), so the caller + // of requestFeedback can act on it + return Promise.resolve({ + thankYouDialogVisible: true, + feedbackSubmitted: false + }); + }; +} + +/** + * Opens {@code FeedbackDialog}. + * + * @param {JitsiConference} conference - 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. + * @param {Function} [onClose] - An optional callback to invoke when the dialog + * is closed. + * @returns {Object} + */ +export function openFeedbackDialog(conference, onClose) { + return openDialog(FeedbackDialog, { + conference, + onClose + }); +} + +/** + * Send the passed in feedback. + * + * @param {number} score - An integer between 1 and 5 indicating the user + * feedback. The negative integer -1 is used to denote no score was selected. + * @param {string} message - Detailed feedback from the user to explain the + * rating. + * @param {JitsiConference} conference - The JitsiConference for which the + * feedback is being left. + * @returns {{ + * type: SUBMIT_FEEDBACK + * }} + */ +export function submitFeedback(score, message, conference) { + conference.sendFeedback(score, message); + + return { + type: SUBMIT_FEEDBACK + }; +} diff --git a/react/features/feedback/components/FeedbackButton.web.js b/react/features/feedback/components/FeedbackButton.web.js index 14b4ac12d..0137cc23f 100644 --- a/react/features/feedback/components/FeedbackButton.web.js +++ b/react/features/feedback/components/FeedbackButton.web.js @@ -1,15 +1,23 @@ /* @flow */ import React, { Component } from 'react'; +import { connect } from 'react-redux'; -declare var config: Object; +import { openFeedbackDialog } from '../actions'; /** * Implements a Web/React Component which renders a feedback button. */ -export class FeedbackButton extends Component { - state = { - callStatsID: String +class FeedbackButton extends Component { + _onClick: Function; + + static propTypes = { + /** + * The JitsiConference for which the feedback will be about. + * + * @type {JitsiConference} + */ + _conference: React.PropTypes.object }; /** @@ -21,9 +29,8 @@ export class FeedbackButton extends Component { constructor(props: Object) { super(props); - this.state = { - callStatsID: config.callStatsID - }; + // Bind event handlers so they are only bound once for every instance. + this._onClick = this._onClick.bind(this); } /** @@ -33,15 +40,46 @@ export class FeedbackButton extends Component { * @returns {ReactElement} */ render() { - // If callstats.io-support is not configured, skip rendering. - if (!this.state.callStatsID) { - return null; - } - return ( + id = 'feedbackButton' + onClick = { this._onClick } /> ); } + + /** + * Dispatches an action to open a dialog requesting call feedback. + * + * @private + * @returns {void} + */ + _onClick() { + const { _conference, dispatch } = this.props; + + dispatch(openFeedbackDialog(_conference)); + } } + +/** + * Maps (parts of) the Redux state to the associated Conference's props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _toolboxVisible: boolean + * }} + */ +function _mapStateToProps(state) { + return { + /** + * The JitsiConference for which the feedback will be about. + * + * @private + * @type {JitsiConference} + */ + _conference: state['features/base/conference'].conference + }; +} + +export default connect(_mapStateToProps)(FeedbackButton); diff --git a/react/features/feedback/components/FeedbackDialog.web.js b/react/features/feedback/components/FeedbackDialog.web.js new file mode 100644 index 000000000..98927e957 --- /dev/null +++ b/react/features/feedback/components/FeedbackDialog.web.js @@ -0,0 +1,343 @@ +import StarIcon from '@atlaskit/icon/glyph/star'; +import StarFilledIcon from '@atlaskit/icon/glyph/star-filled'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { Dialog } from '../../base/dialog'; +import { translate } from '../../base/i18n'; +import JitsiMeetJS from '../../base/lib-jitsi-meet'; + +import { cancelFeedback, submitFeedback } from '../actions'; + +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. + * + * @types {string[]} + */ +const SCORES = [ + 'feedback.veryBad', + 'feedback.bad', + 'feedback.average', + 'feedback.good', + 'feedback.veryGood' +]; + +/** + * A React {@code Component} for displaying a dialog to rate the current + * conference quality, write a message describing the experience, and submit + * the feedback. + * + * @extends Component + */ +class FeedbackDialog extends Component { + /** + * {@code FeedbackDialog} component's property types. + * + * @static + */ + static propTypes = { + /** + * The cached feedback message, if any, that was set when closing a + * previous instance of {@code FeedbackDialog}. + */ + _message: React.PropTypes.string, + + /** + * The cached feedback score, if any, that was set when closing a + * previous instance of {@code FeedbackDialog}. + */ + _score: React.PropTypes.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. + * + * @type {JitsiConference} + */ + conference: React.PropTypes.object, + + /** + * Invoked to signal feedback submission or canceling. + */ + dispatch: React.PropTypes.func, + + /** + * Callback invoked when {@code FeedbackDialog} is unmounted. + */ + onClose: React.PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * 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) { + 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 + }; + + /** + * An array of objects with click handlers for each of the scores listed + * in SCORES. This pattern is used for binding event handlers only once + * for each score selection icon. + * + * @type {Object[]} + */ + this._scoreClickConfigurations = SCORES.map((textKey, index) => { + return { + _onClick: () => 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); + } + + /** + * Emits an analytics event to notify feedback has been opened. + * + * @inheritdoc + */ + componentDidMount() { + JitsiMeetJS.analytics.sendEvent('feedback.open'); + } + + /** + * 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 scoreIcons = this._scoreClickConfigurations.map( + (config, index) => { + const isFilled = index <= scoreToDisplayAsSelected; + const activeClass = isFilled ? 'active' : ''; + const className + = `star-btn ${scoreAnimationClass} ${activeClass}`; + + return ( + + { isFilled + ? + : } + + ); + }); + + const { t } = this.props; + + return ( + +
+
+
+

+ { t(SCORES[scoreToDisplayAsSelected]) } +

+
+
+ { scoreIcons } +
+
+
+