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
This commit is contained in:
virtuacoplenny 2017-08-07 09:20:44 -07:00 committed by yanas
parent 85a168d51b
commit ff442853a2
15 changed files with 676 additions and 424 deletions

View File

@ -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

View File

@ -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;
}
}
}
.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;
};
}
}
}

View File

@ -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 <a href='__url__'>get it from here</a>!",
"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"
}
}

View File

@ -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);
};

View File

@ -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;

View File

@ -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 `
<form id="feedbackForm"
action="javascript:false;" onsubmit="return false;">
<div class="rating">
<div class="star-label">
<p id="starLabel">&nbsp;</p>
</div>
<div id="stars" class="feedback-stars">
<a class="star-btn">
<i class=${ starClassName }></i>
</a>
<a class="star-btn">
<i class=${ starClassName }></i>
</a>
<a class="star-btn">
<i class=${ starClassName }></i>
</a>
<a class="star-btn">
<i class=${ starClassName }></i>
</a>
<a class="star-btn">
<i class=${ starClassName }></i>
</a>
</div>
</div>
<div class="details">
<textarea id="feedbackTextArea" class="input-control"
data-i18n="[placeholder]dialog.feedbackHelp"></textarea>
</div>
</form>`;
}
/**
* 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
});
}
}

View File

@ -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);

View File

@ -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');

View File

@ -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
};
}

View File

@ -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 (
<a
className = 'button icon-feedback'
id = 'feedbackButton' />
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);

View File

@ -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 (
<a
className = { className }
key = { index }
onClick = { config._onClick }
onMouseOver = { config._onMouseOver }>
{ isFilled
? <StarFilledIcon
label = 'star-filled'
size = 'xlarge' />
: <StarIcon
label = 'star'
size = 'xlarge' /> }
</a>
);
});
const { t } = this.props;
return (
<Dialog
okTitleKey = 'dialog.Submit'
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'feedback.rateExperience'>
<div className = 'feedback-dialog'>
<div className = 'rating'>
<div className = 'star-label'>
<p id = 'starLabel'>
{ t(SCORES[scoreToDisplayAsSelected]) }
</p>
</div>
<div
className = 'stars'
onMouseLeave = { this._onScoreContainerMouseLeave }>
{ scoreIcons }
</div>
</div>
<div className = 'details'>
<textarea
autoFocus = { true }
className = 'input-control'
id = 'feedbackTextArea'
onChange = { this._onMessageChange }
placeholder = { t('dialog.feedbackHelp') }
value = { message } />
</div>
</div>
</Dialog>
);
}
/**
* 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;
}
/**
* Updates the known entered feedback message.
*
* @param {Object} event - The DOM event from updating the textfield for the
* feedback message.
* @private
* @returns {void}
*/
_onMessageChange(event) {
this.setState({ message: event.target.value });
}
/**
* Updates the currently selected score.
*
* @param {number} score - The index of the selected score in SCORES.
* @private
* @returns {void}
*/
_onScoreSelect(score) {
this.setState({ score });
}
/**
* 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 });
}
/**
* 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;
}
}
/**
* 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));

View File

@ -1 +1,2 @@
export * from './FeedbackButton';
export { default as FeedbackButton } from './FeedbackButton';
export { default as FeedbackDialog } from './FeedbackDialog';

View File

@ -1 +1,5 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
import './reducer';

View File

@ -0,0 +1,45 @@
import {
ReducerRegistry
} from '../base/redux';
import {
CANCEL_FEEDBACK,
SUBMIT_FEEDBACK
} from './actionTypes';
const DEFAULT_STATE = {
message: '',
// The sentinel value -1 is used to denote no rating has been set and to
// preserve pre-redux behavior.
score: -1,
submitted: false
};
/**
* Reduces the Redux actions of the feature features/feedback.
*/
ReducerRegistry.register(
'features/feedback',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case CANCEL_FEEDBACK: {
return {
...state,
message: action.message,
score: action.score
};
}
case SUBMIT_FEEDBACK: {
return {
...state,
message: '',
score: -1,
submitted: true
};
}
}
return state;
});

View File

@ -13,6 +13,7 @@ import { getToolbarClassNames } from '../functions';
import Toolbar from './Toolbar';
declare var APP: Object;
declare var config: Object;
/**
* Implementation of secondary toolbar React component.
@ -29,6 +30,12 @@ class SecondaryToolbar extends Component {
* @static
*/
static propTypes = {
/**
* Application ID for callstats.io API. The {@code FeedbackButton} will
* display if defined.
*/
_callStatsID: React.PropTypes.string,
/**
* The indicator which determines whether the local participant is a
* guest in the conference.
@ -79,7 +86,7 @@ class SecondaryToolbar extends Component {
* @returns {ReactElement}
*/
render(): ReactElement<*> | null {
const { _secondaryToolbarButtons } = this.props;
const { _callStatsID, _secondaryToolbarButtons } = this.props;
// The number of buttons to show in the toolbar isn't fixed, it depends
// on the availability of features and configuration parameters. So
@ -95,7 +102,7 @@ class SecondaryToolbar extends Component {
className = { secondaryToolbarClassName }
toolbarButtons = { _secondaryToolbarButtons }
tooltipPosition = { 'right' }>
<FeedbackButton />
{ _callStatsID ? <FeedbackButton /> : null }
</Toolbar>
);
}
@ -140,8 +147,14 @@ function _mapDispatchToProps(dispatch: Function): Object {
function _mapStateToProps(state: Object): Object {
const { isGuest } = state['features/jwt'];
const { secondaryToolbarButtons, visible } = state['features/toolbox'];
const { callStatsID } = state['features/base/config'];
return {
/**
* Application ID for callstats.io API.
*/
_callStatsID: callStatsID,
/**
* The indicator which determines whether the local participant is a
* guest in the conference.