--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -109,17 +109,17 @@ loop.Client = (function($) {
*/
_requestCallUrlInternal: function(nickname, cb) {
var sessionType;
if (this.mozLoop.userProfile) {
sessionType = this.mozLoop.LOOP_SESSION_TYPE.FXA;
} else {
sessionType = this.mozLoop.LOOP_SESSION_TYPE.GUEST;
}
-
+
this.mozLoop.hawkRequest(sessionType, "/call-url/", "POST",
{callerId: nickname},
function (error, responseText) {
if (error) {
this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
this._failureHandler(cb, error);
return;
}
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
return;
}
var callType = this._conversation.get("selectedCallType");
var videoStream = callType === "audio" ? false : true;
/*jshint newcap:false*/
this.loadReactComponent(sharedViews.ConversationView({
+ initiate: true,
sdk: OT,
model: this._conversation,
video: {enabled: videoStream}
}));
},
/**
* Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopCharPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
this.loadReactComponent(sharedViews.FeedbackView({
- feedbackApiClient: feedbackClient
+ feedbackApiClient: feedbackClient,
+ onAfterFeedbackReceived: window.close.bind(window)
}));
}
});
/**
* Panel initialisation.
*/
function init() {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
return;
}
var callType = this._conversation.get("selectedCallType");
var videoStream = callType === "audio" ? false : true;
/*jshint newcap:false*/
this.loadReactComponent(sharedViews.ConversationView({
+ initiate: true,
sdk: OT,
model: this._conversation,
video: {enabled: videoStream}
}));
},
/**
* Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopCharPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
this.loadReactComponent(sharedViews.FeedbackView({
- feedbackApiClient: feedbackClient
+ feedbackApiClient: feedbackClient,
+ onAfterFeedbackReceived: window.close.bind(window)
}));
}
});
/**
* Panel initialisation.
*/
function init() {
--- a/browser/components/loop/content/shared/js/feedbackApiClient.js
+++ b/browser/components/loop/content/shared/js/feedbackApiClient.js
@@ -46,17 +46,18 @@ loop.FeedbackAPIClient = (function($, _)
*/
_supportedFields: ["happy",
"category",
"description",
"product",
"platform",
"version",
"channel",
- "user_agent"],
+ "user_agent",
+ "url"],
/**
* Creates a formatted payload object compliant with the Feedback API spec
* against validated field data.
*
* @param {Object} fields Feedback initial values.
* @return {Object} Formatted payload object.
* @throws {Error} If provided values are invalid
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
* - {Function} action Function to be executed on click.
* - {Enabled} enabled Stream activation status (default: true).
*/
var MediaControlButton = React.createClass({displayName: 'MediaControlButton',
propTypes: {
scope: React.PropTypes.string.isRequired,
type: React.PropTypes.string.isRequired,
action: React.PropTypes.func.isRequired,
- enabled: React.PropTypes.bool.isRequired
+ enabled: React.PropTypes.bool.isRequired,
+ visible: React.PropTypes.bool.isRequired
},
getDefaultProps: function() {
- return {enabled: true};
+ return {enabled: true, visible: true};
},
handleClick: function() {
this.props.action();
},
_getClasses: function() {
var cx = React.addons.classSet;
// classes
var classesObj = {
"btn": true,
"media-control": true,
"local-media": this.props.scope === "local",
- "muted": !this.props.enabled
+ "muted": !this.props.enabled,
+ "hide": !this.props.visible
};
classesObj["btn-mute-" + this.props.type] = true;
return cx(classesObj);
},
_getTitle: function(enabled) {
var prefix = this.props.enabled ? "mute" : "unmute";
var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
});
/**
* Conversation controls.
*/
var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar',
getDefaultProps: function() {
return {
- video: {enabled: true},
- audio: {enabled: true}
+ video: {enabled: true, visible: true},
+ audio: {enabled: true, visible: true}
};
},
propTypes: {
video: React.PropTypes.object.isRequired,
audio: React.PropTypes.object.isRequired,
hangup: React.PropTypes.func.isRequired,
publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
this.props.publishStream("video", !this.props.video.enabled);
},
handleToggleAudio: function() {
this.props.publishStream("audio", !this.props.audio.enabled);
},
render: function() {
- /* jshint ignore:start */
+ var cx = React.addons.classSet;
return (
React.DOM.ul({className: "conversation-toolbar"},
React.DOM.li({className: "conversation-toolbar-btn-box"},
React.DOM.button({className: "btn btn-hangup", onClick: this.handleClickHangup,
title: l10n.get("hangup_button_title")},
l10n.get("hangup_button_caption2")
)
),
React.DOM.li({className: "conversation-toolbar-btn-box"},
MediaControlButton({action: this.handleToggleVideo,
enabled: this.props.video.enabled,
+ visible: this.props.video.visible,
scope: "local", type: "video"})
),
React.DOM.li({className: "conversation-toolbar-btn-box"},
MediaControlButton({action: this.handleToggleAudio,
enabled: this.props.audio.enabled,
+ visible: this.props.audio.visible,
scope: "local", type: "audio"})
)
)
);
- /* jshint ignore:end */
}
});
+ /**
+ * Conversation view.
+ */
var ConversationView = React.createClass({displayName: 'ConversationView',
mixins: [Backbone.Events],
propTypes: {
sdk: React.PropTypes.object.isRequired,
- model: React.PropTypes.object.isRequired
+ video: React.PropTypes.object,
+ audio: React.PropTypes.object,
+ initiate: React.PropTypes.bool
},
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
publisherConfig: {
insertMode: "append",
width: "100%",
height: "100%",
style: {
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off"
}
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {
- video: {enabled: true},
- audio: {enabled: true}
+ initiate: true,
+ video: {enabled: true, visible: true},
+ audio: {enabled: true, visible: true}
};
},
getInitialState: function() {
return {
video: this.props.video,
audio: this.props.audio
};
},
componentWillMount: function() {
- this.publisherConfig.publishVideo = this.props.video.enabled;
+ if (this.props.initiate) {
+ this.publisherConfig.publishVideo = this.props.video.enabled;
+ }
},
componentDidMount: function() {
- this.listenTo(this.props.model, "session:connected",
- this.startPublishing);
- this.listenTo(this.props.model, "session:stream-created",
- this._streamCreated);
- this.listenTo(this.props.model, ["session:peer-hungup",
- "session:network-disconnected",
- "session:ended"].join(" "),
- this.stopPublishing);
-
- this.props.model.startSession();
+ if (this.props.initiate) {
+ this.listenTo(this.props.model, "session:connected",
+ this.startPublishing);
+ this.listenTo(this.props.model, "session:stream-created",
+ this._streamCreated);
+ this.listenTo(this.props.model, ["session:peer-hungup",
+ "session:network-disconnected",
+ "session:ended"].join(" "),
+ this.stopPublishing);
+ this.props.model.startSession();
+ }
/**
* OT inserts inline styles into the markup. Using a listener for
* resize events helps us trigger a full width/height on the element
* so that they update to the correct dimensions.
- * */
+ * XXX: this should be factored as a mixin.
+ */
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
},
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
this.setState({video: {enabled: enabled}});
}
},
/**
* Unpublishes local stream.
*/
stopPublishing: function() {
- // Unregister listeners for publisher events
- this.stopListening(this.publisher);
+ if (this.publisher) {
+ // Unregister listeners for publisher events
+ this.stopListening(this.publisher);
- this.props.model.session.unpublish(this.publisher);
+ this.props.model.session.unpublish(this.publisher);
+ }
},
render: function() {
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": !this.state.video.enabled
});
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {pending: false};
},
_getCategories: function() {
return {
audio_quality: l10n.get("feedback_category_audio_quality"),
video_quality: l10n.get("feedback_category_video_quality"),
disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
)
)
);
}
});
/**
* Feedback received view.
+ *
+ * Props:
+ * - {Function} onAfterFeedbackReceived Function to execute after the
+ * WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
*/
var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
+ propTypes: {
+ onAfterFeedbackReceived: React.PropTypes.func
+ },
+
getInitialState: function() {
return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
},
componentDidMount: function() {
this._timer = setInterval(function() {
this.setState({countdown: this.state.countdown - 1});
}.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
- window.close();
+ if (this.props.onAfterFeedbackReceived) {
+ this.props.onAfterFeedbackReceived();
+ }
}
return (
FeedbackLayout({title: l10n.get("feedback_thank_you_heading")},
React.DOM.p({className: "info thank-you"},
l10n.get("feedback_window_will_close_in2", {
countdown: this.state.countdown,
num: this.state.countdown
}))
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
/**
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
+ onAfterFeedbackReceived: React.PropTypes.func,
// The current feedback submission flow step name
step: React.PropTypes.oneOf(["start", "form", "finished"])
},
getInitialState: function() {
return {pending: false, step: this.props.step || "start"};
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
console.error("Unable to send user feedback", err);
}
this.setState({pending: false, step: "finished"});
},
render: function() {
switch(this.state.step) {
case "finished":
- return FeedbackReceived(null);
+ return (
+ FeedbackReceived({
+ onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
+ );
case "form":
return FeedbackForm({feedbackApiClient: this.props.feedbackApiClient,
sendFeedback: this.sendFeedback,
reset: this.reset,
pending: this.state.pending});
default:
return (
FeedbackLayout({title:
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
* - {Function} action Function to be executed on click.
* - {Enabled} enabled Stream activation status (default: true).
*/
var MediaControlButton = React.createClass({
propTypes: {
scope: React.PropTypes.string.isRequired,
type: React.PropTypes.string.isRequired,
action: React.PropTypes.func.isRequired,
- enabled: React.PropTypes.bool.isRequired
+ enabled: React.PropTypes.bool.isRequired,
+ visible: React.PropTypes.bool.isRequired
},
getDefaultProps: function() {
- return {enabled: true};
+ return {enabled: true, visible: true};
},
handleClick: function() {
this.props.action();
},
_getClasses: function() {
var cx = React.addons.classSet;
// classes
var classesObj = {
"btn": true,
"media-control": true,
"local-media": this.props.scope === "local",
- "muted": !this.props.enabled
+ "muted": !this.props.enabled,
+ "hide": !this.props.visible
};
classesObj["btn-mute-" + this.props.type] = true;
return cx(classesObj);
},
_getTitle: function(enabled) {
var prefix = this.props.enabled ? "mute" : "unmute";
var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
});
/**
* Conversation controls.
*/
var ConversationToolbar = React.createClass({
getDefaultProps: function() {
return {
- video: {enabled: true},
- audio: {enabled: true}
+ video: {enabled: true, visible: true},
+ audio: {enabled: true, visible: true}
};
},
propTypes: {
video: React.PropTypes.object.isRequired,
audio: React.PropTypes.object.isRequired,
hangup: React.PropTypes.func.isRequired,
publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
this.props.publishStream("video", !this.props.video.enabled);
},
handleToggleAudio: function() {
this.props.publishStream("audio", !this.props.audio.enabled);
},
render: function() {
- /* jshint ignore:start */
+ var cx = React.addons.classSet;
return (
<ul className="conversation-toolbar">
<li className="conversation-toolbar-btn-box">
<button className="btn btn-hangup" onClick={this.handleClickHangup}
title={l10n.get("hangup_button_title")}>
{l10n.get("hangup_button_caption2")}
</button>
</li>
<li className="conversation-toolbar-btn-box">
<MediaControlButton action={this.handleToggleVideo}
enabled={this.props.video.enabled}
+ visible={this.props.video.visible}
scope="local" type="video" />
</li>
<li className="conversation-toolbar-btn-box">
<MediaControlButton action={this.handleToggleAudio}
enabled={this.props.audio.enabled}
+ visible={this.props.audio.visible}
scope="local" type="audio" />
</li>
</ul>
);
- /* jshint ignore:end */
}
});
+ /**
+ * Conversation view.
+ */
var ConversationView = React.createClass({
mixins: [Backbone.Events],
propTypes: {
sdk: React.PropTypes.object.isRequired,
- model: React.PropTypes.object.isRequired
+ video: React.PropTypes.object,
+ audio: React.PropTypes.object,
+ initiate: React.PropTypes.bool
},
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
publisherConfig: {
insertMode: "append",
width: "100%",
height: "100%",
style: {
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off"
}
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {
- video: {enabled: true},
- audio: {enabled: true}
+ initiate: true,
+ video: {enabled: true, visible: true},
+ audio: {enabled: true, visible: true}
};
},
getInitialState: function() {
return {
video: this.props.video,
audio: this.props.audio
};
},
componentWillMount: function() {
- this.publisherConfig.publishVideo = this.props.video.enabled;
+ if (this.props.initiate) {
+ this.publisherConfig.publishVideo = this.props.video.enabled;
+ }
},
componentDidMount: function() {
- this.listenTo(this.props.model, "session:connected",
- this.startPublishing);
- this.listenTo(this.props.model, "session:stream-created",
- this._streamCreated);
- this.listenTo(this.props.model, ["session:peer-hungup",
- "session:network-disconnected",
- "session:ended"].join(" "),
- this.stopPublishing);
-
- this.props.model.startSession();
+ if (this.props.initiate) {
+ this.listenTo(this.props.model, "session:connected",
+ this.startPublishing);
+ this.listenTo(this.props.model, "session:stream-created",
+ this._streamCreated);
+ this.listenTo(this.props.model, ["session:peer-hungup",
+ "session:network-disconnected",
+ "session:ended"].join(" "),
+ this.stopPublishing);
+ this.props.model.startSession();
+ }
/**
* OT inserts inline styles into the markup. Using a listener for
* resize events helps us trigger a full width/height on the element
* so that they update to the correct dimensions.
- * */
+ * XXX: this should be factored as a mixin.
+ */
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
},
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
this.setState({video: {enabled: enabled}});
}
},
/**
* Unpublishes local stream.
*/
stopPublishing: function() {
- // Unregister listeners for publisher events
- this.stopListening(this.publisher);
+ if (this.publisher) {
+ // Unregister listeners for publisher events
+ this.stopListening(this.publisher);
- this.props.model.session.unpublish(this.publisher);
+ this.props.model.session.unpublish(this.publisher);
+ }
},
render: function() {
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": !this.state.video.enabled
});
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {pending: false};
},
_getCategories: function() {
return {
audio_quality: l10n.get("feedback_category_audio_quality"),
video_quality: l10n.get("feedback_category_video_quality"),
disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
</form>
</FeedbackLayout>
);
}
});
/**
* Feedback received view.
+ *
+ * Props:
+ * - {Function} onAfterFeedbackReceived Function to execute after the
+ * WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
*/
var FeedbackReceived = React.createClass({
+ propTypes: {
+ onAfterFeedbackReceived: React.PropTypes.func
+ },
+
getInitialState: function() {
return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
},
componentDidMount: function() {
this._timer = setInterval(function() {
this.setState({countdown: this.state.countdown - 1});
}.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
- window.close();
+ if (this.props.onAfterFeedbackReceived) {
+ this.props.onAfterFeedbackReceived();
+ }
}
return (
<FeedbackLayout title={l10n.get("feedback_thank_you_heading")}>
<p className="info thank-you">{
l10n.get("feedback_window_will_close_in2", {
countdown: this.state.countdown,
num: this.state.countdown
})}</p>
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
/**
* Feedback view.
*/
var FeedbackView = React.createClass({
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
+ onAfterFeedbackReceived: React.PropTypes.func,
// The current feedback submission flow step name
step: React.PropTypes.oneOf(["start", "form", "finished"])
},
getInitialState: function() {
return {pending: false, step: this.props.step || "start"};
},
- getInitialProps: function() {
+ getDefaultProps: function() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
console.error("Unable to send user feedback", err);
}
this.setState({pending: false, step: "finished"});
},
render: function() {
switch(this.state.step) {
case "finished":
- return <FeedbackReceived />;
+ return (
+ <FeedbackReceived
+ onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
+ );
case "form":
return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
sendFeedback={this.sendFeedback}
reset={this.reset}
pending={this.state.pending} />;
default:
return (
<FeedbackLayout title={
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -8,16 +8,19 @@
# XXX In the interest of making the build logic simpler and
# more maintainable, we should be trying to implement new
# functionality in Gruntfile.js rather than here.
# Bug 1066176 tracks moving all functionality currently here
# to the Gruntfile and getting rid of this Makefile entirely.
LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000})
+LOOP_FEEDBACK_API_URL := $(shell echo $${LOOP_FEEDBACK_API_URL-"https://input.allizom.org/api/v1/feedback"})
+LOOP_FEEDBACK_PRODUCT_NAME := $(shell echo $${LOOP_FEEDBACK_PRODUCT_NAME-Loop})
+
NODE_LOCAL_BIN=./node_modules/.bin
install: npm_install tos
npm_install:
@npm install
test:
@@ -62,9 +65,11 @@ remove_old_config:
# The services development deployment, however, still wants a static config
# file, and needs an easy way to generate one. This target is for folks
# working with that deployment.
.PHONY: config
config:
@echo "var loop = loop || {};" > content/config.js
@echo "loop.config = loop.config || {};" >> content/config.js
- @echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+ @echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+ @echo "loop.config.feedbackApiUrl = '`echo $(LOOP_FEEDBACK_API_URL)`';" >> content/config.js
+ @echo "loop.config.feedbackProductName = '`echo $(LOOP_FEEDBACK_PRODUCT_NAME)`';" >> content/config.js
--- a/browser/components/loop/standalone/README.md
+++ b/browser/components/loop/standalone/README.md
@@ -24,18 +24,22 @@ folks deploying the development server w
$ make config
It will read the configuration from the following env variables and generate the
appropriate configuration file:
- `LOOP_SERVER_URL` defines the root url of the loop server, without trailing
slash (default: `http://localhost:5000`).
-- `LOOP_PENDING_CALL_TIMEOUT` defines the amount of time a pending outgoing call
- should be considered timed out, in milliseconds (default: `20000`).
+- `LOOP_FEEDBACK_API_URL` sets the root URL for the
+ [input API](https://input.mozilla.org/); defaults to the input stage server
+ (https://input.allizom.org/api/v1/feedback). **Don't forget to set this
+ value to the production server URL when deploying to production.**
+- `LOOP_FEEDBACK_PRODUCT_NAME` defines the product name to be sent to the input
+ API (defaults: Loop).
Usage
-----
For development, run a local static file server:
$ make runserver
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -202,8 +202,46 @@ body,
* Left / Right padding elements
* used to center components
* */
.flex-padding-1 {
display: flex;
flex: 1;
}
+/**
+ * Feedback form overlay (standalone only)
+ */
+.standalone .ended-conversation {
+ position: relative;
+ height: 100%;
+ background-color: #444;
+ text-align: left; /* as backup */
+ text-align: start;
+}
+
+.standalone .ended-conversation .feedback {
+ position: absolute;
+ width: 50%;
+ max-width: 400px;
+ margin: 10px auto;
+ top: 20px;
+ left: 10%;
+ right: 10%;
+ background: #FFF;
+ box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.4);
+ border-radius: 3px;
+ z-index: 1002; /* ensures the form is always on top of the control bar */
+}
+
+.standalone .ended-conversation .local-stream {
+ /* Hide local media stream when feedback form is shown. */
+ display: none;
+}
+
+@media screen and (max-width:640px) {
+ .standalone .ended-conversation .feedback {
+ width: 92%;
+ top: 10%;
+ left: 5px;
+ right: 5px;
+ }
+}
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -36,16 +36,17 @@
<script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>
<!-- app scripts -->
<script type="text/javascript" src="config.js"></script>
<script type="text/javascript" src="shared/js/utils.js"></script>
<script type="text/javascript" src="shared/js/models.js"></script>
<script type="text/javascript" src="shared/js/mixins.js"></script>
<script type="text/javascript" src="shared/js/views.js"></script>
+ <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
<script type="text/javascript" src="shared/js/websocket.js"></script>
<script type="text/javascript" src="js/standaloneClient.js"></script>
<script type="text/javascript" src="js/webapp.js"></script>
<script>
// Wait for all the localization notes to load
window.addEventListener('localized', function() {
loop.webapp.init();
--- a/browser/components/loop/standalone/content/js/standaloneClient.js
+++ b/browser/components/loop/standalone/content/js/standaloneClient.js
@@ -117,17 +117,17 @@ loop.StandaloneClient = (function($) {
dataType: "json",
data: JSON.stringify({callType: callType})
});
req.done(function(sessionData) {
try {
cb(null, this._validate(sessionData, expectedCallsProperties));
} catch (err) {
- console.log("Error requesting call info", err);
+ console.error("Error requesting call info", err.message);
cb(err);
}
}.bind(this));
req.fail(this._failureHandler.bind(this, cb));
},
};
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1,41 +1,35 @@
/** @jsx React.DOM */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.webapp = (function($, _, OT, mozL10n) {
"use strict";
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views;
/**
- * App router.
- * @type {loop.webapp.WebappRouter}
- */
- var router;
-
- /**
* Homepage view.
*/
var HomeView = React.createClass({displayName: 'HomeView',
render: function() {
return (
React.DOM.p(null, mozL10n.get("welcome"))
- )
+ );
}
});
/**
* Unsupported Browsers view.
*/
var UnsupportedBrowserView = React.createClass({displayName: 'UnsupportedBrowserView',
render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
* Expired call URL view.
*/
var CallUrlExpiredView = React.createClass({displayName: 'CallUrlExpiredView',
propTypes: {
helper: React.PropTypes.object.isRequired
},
render: function() {
- /* jshint ignore:start */
return (
React.DOM.div({className: "expired-url-info"},
React.DOM.div({className: "info-panel"},
React.DOM.div({className: "firefox-logo"}),
React.DOM.h1(null, mozL10n.get("call_url_unavailable_notification_heading")),
React.DOM.h4(null, mozL10n.get("call_url_unavailable_notification_message2"))
),
PromoteFirefoxView({helper: this.props.helper})
)
);
- /* jshint ignore:end */
}
});
var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
render: function() {
return (
React.DOM.h1({className: "standalone-header-title"},
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
"hide": !this.props.urlCreationDateString.length
});
var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
"call_url_creation_date": this.props.urlCreationDateString
});
return (
- /* jshint ignore:start */
React.DOM.header({className: "standalone-header header-box container-box"},
ConversationBranding(null),
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
React.DOM.h3({className: "call-url"},
conversationUrl
),
React.DOM.h4({className: urlCreationDateClasses},
callUrlCreationDateString
)
)
- /* jshint ignore:end */
);
}
});
var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
render: function() {
return (
React.DOM.div({className: "standalone-footer container-box"},
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
);
}
});
var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
getInitialState: function() {
return {
callState: this.props.callState || "connecting"
- }
+ };
},
propTypes: {
websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
.isRequired
},
componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
_cancelOutgoingCall: function() {
this.props.websocket.cancel();
},
render: function() {
var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
return (
- /* jshint ignore:start */
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
React.DOM.header({className: "pending-header header-box"},
ConversationBranding(null)
),
React.DOM.div({id: "cameraPreview"}),
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
)
),
React.DOM.div({className: "flex-padding-1"})
)
),
ConversationFooter(null)
)
- /* jshint ignore:end */
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
+ *
+ * Required properties:
+ * - {loop.shared.models.ConversationModel} model Conversation model.
+ * - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({displayName: 'StartConversationView',
- /**
- * Constructor.
- *
- * Required options:
- * - {loop.shared.models.ConversationModel} model Conversation model.
- * - {loop.shared.models.NotificationCollection} notifications
- *
- */
+ propTypes: {
+ model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+ .isRequired,
+ // XXX Check more tightly here when we start injecting window.loop.*
+ notifications: React.PropTypes.object.isRequired,
+ client: React.PropTypes.object.isRequired
+ },
- getInitialProps: function() {
+ getDefaultProps: function() {
return {showCallOptionsMenu: false};
},
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false,
showCallOptionsMenu: this.props.showCallOptionsMenu
};
},
- propTypes: {
- model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
- // XXX Check more tightly here when we start injecting window.loop.*
- notifications: React.PropTypes.object.isRequired,
- client: React.PropTypes.object.isRequired
- },
-
componentDidMount: function() {
// Listen for events & hide dropdown menu if user clicks away
window.addEventListener("click", this.clickHandler);
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
},
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
return (
- /* jshint ignore:start */
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}),
React.DOM.p({className: "standalone-btn-label"},
mozL10n.get("initiate_call_button_label2")
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
),
React.DOM.p({className: tosClasses,
dangerouslySetInnerHTML: {__html: tosHTML}})
),
ConversationFooter(null)
)
- /* jshint ignore:end */
+ );
+ }
+ });
+
+ /**
+ * Ended conversation view.
+ */
+ var EndedConversationView = React.createClass({displayName: 'EndedConversationView',
+ propTypes: {
+ conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+ .isRequired,
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired,
+ onAfterFeedbackReceived: React.PropTypes.func.isRequired
+ },
+
+ render: function() {
+ return (
+ React.DOM.div({className: "ended-conversation"},
+ sharedViews.FeedbackView({
+ feedbackApiClient: this.props.feedbackApiClient,
+ onAfterFeedbackReceived: this.props.onAfterFeedbackReceived}
+ ),
+ sharedViews.ConversationView({
+ initiate: false,
+ sdk: this.props.sdk,
+ model: this.props.conversation,
+ audio: {enabled: false, visible: false},
+ video: {enabled: false, visible: false}}
+ )
+ )
);
}
});
/**
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
*
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
- sdk: React.PropTypes.object.isRequired
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {
callStatus: "start"
};
},
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
this.props.conversation.on("session:connection-error", this._notifyError, this);
},
componentDidUnmount: function() {
this.props.conversation.off(null, null, this);
},
+ shouldComponentUpdate: function(nextProps, nextState) {
+ // Only rerender if current state has actually changed
+ return nextState.callStatus !== this.state.callStatus;
+ },
+
+ callStatusSwitcher: function(status) {
+ return function() {
+ this.setState({callStatus: status});
+ }.bind(this);
+ },
+
/**
* Renders the conversation views.
*/
render: function() {
switch (this.state.callStatus) {
case "failure":
- case "end":
case "start": {
return (
StartConversationView({
model: this.props.conversation,
notifications: this.props.notifications,
client: this.props.client}
)
);
}
case "pending": {
return PendingConversationView({websocket: this._websocket});
}
case "connected": {
return (
sharedViews.ConversationView({
+ initiate: true,
sdk: this.props.sdk,
model: this.props.conversation,
video: {enabled: this.props.conversation.hasVideoStream("outgoing")}}
)
);
}
+ case "end": {
+ return (
+ EndedConversationView({
+ sdk: this.props.sdk,
+ conversation: this.props.conversation,
+ feedbackApiClient: this.props.feedbackApiClient,
+ onAfterFeedbackReceived: this.callStatusSwitcher("start")}
+ )
+ );
+ }
case "expired": {
return (
CallUrlExpiredView({helper: this.props.helper})
);
}
default: {
- return HomeView(null)
+ return HomeView(null);
}
}
},
/**
* Notify the user that the connection was not possible
* @param {{code: number, message: string}} error
*/
_notifyError: function(error) {
- console.log(error);
+ console.error(error);
this.props.notifications.errorL10n("connection_error_see_console_notification");
this.setState({callStatus: "end"});
},
/**
* Peer hung up. Notifies the user and ends the call.
*
* Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
},
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
*/
_handleCallTerminated: function(reason) {
- this.setState({callStatus: "end"});
- // For reasons other than cancel, display some notification text.
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this.props.notifications.errorL10n("call_timeout_notification_text");
}
+ // redirects the user to the call start view
+ // XXX should switch callStatus to failed for specific reasons when we
+ // get the call failed view; for now, switch back to start.
+ this.setState({callStatus: "start"});
},
/**
* Handles ending a call by resetting the view to the start state.
*/
_endCall: function() {
this.setState({callStatus: "end"});
},
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
var WebappRootView = React.createClass({displayName: 'WebappRootView',
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
- sdk: React.PropTypes.object.isRequired
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {
unsupportedDevice: this.props.helper.isIOS(navigator.platform),
unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
};
},
@@ -674,17 +711,18 @@ loop.webapp = (function($, _, OT, mozL10
return UnsupportedBrowserView(null);
} else if (this.props.conversation.get("loopToken")) {
return (
OutgoingConversationView({
client: this.props.client,
conversation: this.props.conversation,
helper: this.props.helper,
notifications: this.props.notifications,
- sdk: this.props.sdk}
+ sdk: this.props.sdk,
+ feedbackApiClient: this.props.feedbackApiClient}
)
);
} else {
return HomeView(null);
}
}
});
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
var helper = new WebappHelper();
var client = new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
});
var notifications = new sharedModels.NotificationCollection();
var conversation = new sharedModels.ConversationModel({}, {
sdk: OT
});
+ var feedbackApiClient = new loop.FeedbackAPIClient(
+ loop.config.feedbackApiUrl, {
+ product: loop.config.feedbackProductName,
+ user_agent: navigator.userAgent,
+ url: document.location.origin
+ });
// Obtain the loopToken and pass it to the conversation
var locationHash = helper.locationHash();
if (locationHash) {
conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
}
React.renderComponent(WebappRootView({
client: client,
conversation: conversation,
helper: helper,
notifications: notifications,
- sdk: OT}
+ sdk: OT,
+ feedbackApiClient: feedbackApiClient}
), document.querySelector("#main"));
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
document.documentElement.lang = mozL10n.language.code;
document.documentElement.dir = mozL10n.language.direction;
}
return {
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
OutgoingConversationView: OutgoingConversationView,
+ EndedConversationView: EndedConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,
UnsupportedDeviceView: UnsupportedDeviceView,
init: init,
PromoteFirefoxView: PromoteFirefoxView,
WebappHelper: WebappHelper,
WebappRootView: WebappRootView
};
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1,41 +1,35 @@
/** @jsx React.DOM */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.webapp = (function($, _, OT, mozL10n) {
"use strict";
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views;
/**
- * App router.
- * @type {loop.webapp.WebappRouter}
- */
- var router;
-
- /**
* Homepage view.
*/
var HomeView = React.createClass({
render: function() {
return (
<p>{mozL10n.get("welcome")}</p>
- )
+ );
}
});
/**
* Unsupported Browsers view.
*/
var UnsupportedBrowserView = React.createClass({
render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
* Expired call URL view.
*/
var CallUrlExpiredView = React.createClass({
propTypes: {
helper: React.PropTypes.object.isRequired
},
render: function() {
- /* jshint ignore:start */
return (
<div className="expired-url-info">
<div className="info-panel">
<div className="firefox-logo" />
<h1>{mozL10n.get("call_url_unavailable_notification_heading")}</h1>
<h4>{mozL10n.get("call_url_unavailable_notification_message2")}</h4>
</div>
<PromoteFirefoxView helper={this.props.helper} />
</div>
);
- /* jshint ignore:end */
}
});
var ConversationBranding = React.createClass({
render: function() {
return (
<h1 className="standalone-header-title">
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
"hide": !this.props.urlCreationDateString.length
});
var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
"call_url_creation_date": this.props.urlCreationDateString
});
return (
- /* jshint ignore:start */
<header className="standalone-header header-box container-box">
<ConversationBranding />
<div className="loop-logo" title="Firefox WebRTC! logo"></div>
<h3 className="call-url">
{conversationUrl}
</h3>
<h4 className={urlCreationDateClasses} >
{callUrlCreationDateString}
</h4>
</header>
- /* jshint ignore:end */
);
}
});
var ConversationFooter = React.createClass({
render: function() {
return (
<div className="standalone-footer container-box">
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
);
}
});
var PendingConversationView = React.createClass({
getInitialState: function() {
return {
callState: this.props.callState || "connecting"
- }
+ };
},
propTypes: {
websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
.isRequired
},
componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
_cancelOutgoingCall: function() {
this.props.websocket.cancel();
},
render: function() {
var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
return (
- /* jshint ignore:start */
<div className="container">
<div className="container-box">
<header className="pending-header header-box">
<ConversationBranding />
</header>
<div id="cameraPreview"></div>
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
</span>
</button>
<div className="flex-padding-1"></div>
</div>
</div>
<ConversationFooter />
</div>
- /* jshint ignore:end */
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
+ *
+ * Required properties:
+ * - {loop.shared.models.ConversationModel} model Conversation model.
+ * - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({
- /**
- * Constructor.
- *
- * Required options:
- * - {loop.shared.models.ConversationModel} model Conversation model.
- * - {loop.shared.models.NotificationCollection} notifications
- *
- */
+ propTypes: {
+ model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+ .isRequired,
+ // XXX Check more tightly here when we start injecting window.loop.*
+ notifications: React.PropTypes.object.isRequired,
+ client: React.PropTypes.object.isRequired
+ },
- getInitialProps: function() {
+ getDefaultProps: function() {
return {showCallOptionsMenu: false};
},
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false,
showCallOptionsMenu: this.props.showCallOptionsMenu
};
},
- propTypes: {
- model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
- // XXX Check more tightly here when we start injecting window.loop.*
- notifications: React.PropTypes.object.isRequired,
- client: React.PropTypes.object.isRequired
- },
-
componentDidMount: function() {
// Listen for events & hide dropdown menu if user clicks away
window.addEventListener("click", this.clickHandler);
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
},
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
return (
- /* jshint ignore:start */
<div className="container">
<div className="container-box">
<ConversationHeader
urlCreationDateString={this.state.urlCreationDateString} />
<p className="standalone-btn-label">
{mozL10n.get("initiate_call_button_label2")}
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
</div>
<p className={tosClasses}
dangerouslySetInnerHTML={{__html: tosHTML}}></p>
</div>
<ConversationFooter />
</div>
- /* jshint ignore:end */
+ );
+ }
+ });
+
+ /**
+ * Ended conversation view.
+ */
+ var EndedConversationView = React.createClass({
+ propTypes: {
+ conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+ .isRequired,
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired,
+ onAfterFeedbackReceived: React.PropTypes.func.isRequired
+ },
+
+ render: function() {
+ return (
+ <div className="ended-conversation">
+ <sharedViews.FeedbackView
+ feedbackApiClient={this.props.feedbackApiClient}
+ onAfterFeedbackReceived={this.props.onAfterFeedbackReceived}
+ />
+ <sharedViews.ConversationView
+ initiate={false}
+ sdk={this.props.sdk}
+ model={this.props.conversation}
+ audio={{enabled: false, visible: false}}
+ video={{enabled: false, visible: false}}
+ />
+ </div>
);
}
});
/**
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
*
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
var OutgoingConversationView = React.createClass({
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
- sdk: React.PropTypes.object.isRequired
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {
callStatus: "start"
};
},
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
this.props.conversation.on("session:connection-error", this._notifyError, this);
},
componentDidUnmount: function() {
this.props.conversation.off(null, null, this);
},
+ shouldComponentUpdate: function(nextProps, nextState) {
+ // Only rerender if current state has actually changed
+ return nextState.callStatus !== this.state.callStatus;
+ },
+
+ callStatusSwitcher: function(status) {
+ return function() {
+ this.setState({callStatus: status});
+ }.bind(this);
+ },
+
/**
* Renders the conversation views.
*/
render: function() {
switch (this.state.callStatus) {
case "failure":
- case "end":
case "start": {
return (
<StartConversationView
model={this.props.conversation}
notifications={this.props.notifications}
client={this.props.client}
/>
);
}
case "pending": {
return <PendingConversationView websocket={this._websocket} />;
}
case "connected": {
return (
<sharedViews.ConversationView
+ initiate={true}
sdk={this.props.sdk}
model={this.props.conversation}
video={{enabled: this.props.conversation.hasVideoStream("outgoing")}}
/>
);
}
+ case "end": {
+ return (
+ <EndedConversationView
+ sdk={this.props.sdk}
+ conversation={this.props.conversation}
+ feedbackApiClient={this.props.feedbackApiClient}
+ onAfterFeedbackReceived={this.callStatusSwitcher("start")}
+ />
+ );
+ }
case "expired": {
return (
<CallUrlExpiredView helper={this.props.helper} />
);
}
default: {
- return <HomeView />
+ return <HomeView />;
}
}
},
/**
* Notify the user that the connection was not possible
* @param {{code: number, message: string}} error
*/
_notifyError: function(error) {
- console.log(error);
+ console.error(error);
this.props.notifications.errorL10n("connection_error_see_console_notification");
this.setState({callStatus: "end"});
},
/**
* Peer hung up. Notifies the user and ends the call.
*
* Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
},
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
*/
_handleCallTerminated: function(reason) {
- this.setState({callStatus: "end"});
- // For reasons other than cancel, display some notification text.
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this.props.notifications.errorL10n("call_timeout_notification_text");
}
+ // redirects the user to the call start view
+ // XXX should switch callStatus to failed for specific reasons when we
+ // get the call failed view; for now, switch back to start.
+ this.setState({callStatus: "start"});
},
/**
* Handles ending a call by resetting the view to the start state.
*/
_endCall: function() {
this.setState({callStatus: "end"});
},
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
var WebappRootView = React.createClass({
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
- sdk: React.PropTypes.object.isRequired
+ sdk: React.PropTypes.object.isRequired,
+ feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {
unsupportedDevice: this.props.helper.isIOS(navigator.platform),
unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
};
},
@@ -675,16 +712,17 @@ loop.webapp = (function($, _, OT, mozL10
} else if (this.props.conversation.get("loopToken")) {
return (
<OutgoingConversationView
client={this.props.client}
conversation={this.props.conversation}
helper={this.props.helper}
notifications={this.props.notifications}
sdk={this.props.sdk}
+ feedbackApiClient={this.props.feedbackApiClient}
/>
);
} else {
return <HomeView />;
}
}
});
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
var helper = new WebappHelper();
var client = new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
});
var notifications = new sharedModels.NotificationCollection();
var conversation = new sharedModels.ConversationModel({}, {
sdk: OT
});
+ var feedbackApiClient = new loop.FeedbackAPIClient(
+ loop.config.feedbackApiUrl, {
+ product: loop.config.feedbackProductName,
+ user_agent: navigator.userAgent,
+ url: document.location.origin
+ });
// Obtain the loopToken and pass it to the conversation
var locationHash = helper.locationHash();
if (locationHash) {
conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
}
React.renderComponent(<WebappRootView
client={client}
conversation={conversation}
helper={helper}
notifications={notifications}
sdk={OT}
+ feedbackApiClient={feedbackApiClient}
/>, document.querySelector("#main"));
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
document.documentElement.lang = mozL10n.language.code;
document.documentElement.dir = mozL10n.language.direction;
}
return {
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
OutgoingConversationView: OutgoingConversationView,
+ EndedConversationView: EndedConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,
UnsupportedDeviceView: UnsupportedDeviceView,
init: init,
PromoteFirefoxView: PromoteFirefoxView,
WebappHelper: WebappHelper,
WebappRootView: WebappRootView
};
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -41,8 +41,37 @@ legal_text_and_links=By using this produ
terms_of_use_link_text=Terms of use
privacy_notice_link_text=Privacy notice
brandShortname=Firefox
clientShortname=WebRTC!
## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
call_url_creation_date_label=(from {{call_url_creation_date}})
call_progress_connecting_description=Connecting…
call_progress_ringing_description=Ringing…
+
+feedback_call_experience_heading2=How was your conversation?
+feedback_what_makes_you_sad=What makes you sad?
+feedback_thank_you_heading=Thank you for your feedback!
+feedback_category_audio_quality=Audio quality
+feedback_category_video_quality=Video quality
+feedback_category_was_disconnected=Was disconnected
+feedback_category_confusing=Confusing
+feedback_category_other=Other:
+feedback_custom_category_text_placeholder=What went wrong?
+feedback_submit_button=Submit
+feedback_back_button=Back
+## LOCALIZATION NOTE (feedback_window_will_close_in2):
+## Gaia l10n format; see https://github.com/mozilla-b2g/gaia/blob/f108c706fae43cd61628babdd9463e7695b2496e/apps/email/locales/email.en-US.properties#L387
+## In this item, don't translate the part between {{..}}
+feedback_window_will_close_in2={[ plural(countdown) ]}
+feedback_window_will_close_in2[one] = This window will close in {{countdown}} second
+feedback_window_will_close_in2[two] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[few] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[many] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[other] = This window will close in {{countdown}} seconds
+
+## LOCALIZATION_NOTE (feedback_rejoin_button): Displayed on the feedback form after
+## a signed-in to signed-in user call.
+## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#feedback
+feedback_rejoin_button=Rejoin
+## LOCALIZATION NOTE (feedback_report_user_button): Used to report a user in the case of
+## an abusive user.
+feedback_report_user_button=Report User
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -2,27 +2,31 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
var express = require('express');
var app = express();
var port = process.env.PORT || 3000;
var loopServerPort = process.env.LOOP_SERVER_PORT || 5000;
+var feedbackApiUrl = process.env.LOOP_FEEDBACK_API_URL ||
+ "https://input.allizom.org/api/v1/feedback";
+var feedbackProductName = process.env.LOOP_FEEDBACK_PRODUCT_NAME || "Loop";
function getConfigFile(req, res) {
"use strict";
res.set('Content-Type', 'text/javascript');
- res.send(
- "var loop = loop || {};" +
- "loop.config = loop.config || {};" +
- "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';" +
- "loop.config.pendingCallTimeout = 20000;"
- );
+ res.send([
+ "var loop = loop || {};",
+ "loop.config = loop.config || {};",
+ "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';",
+ "loop.config.feedbackApiUrl = '" + feedbackApiUrl + "';",
+ "loop.config.feedbackProductName = '" + feedbackProductName + "';",
+ ].join("\n"));
}
app.get('/content/config.js', getConfigFile);
// This lets /test/ be mapped to the right place for running tests
app.use('/', express.static(__dirname + '/../'));
// Magic so that the legal content works both in the standalone server
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -103,18 +103,17 @@ describe("loop.conversation", function()
});
describe("ConversationRouter", function() {
var conversation, client;
beforeEach(function() {
client = new loop.Client();
conversation = new loop.shared.models.ConversationModel({}, {
- sdk: {},
- pendingCallTimeout: 1000,
+ sdk: {}
});
sandbox.spy(conversation, "setIncomingSessionData");
sandbox.stub(conversation, "setOutgoingSessionData");
});
describe("Routes", function() {
var router;
--- a/browser/components/loop/test/shared/feedbackApiClient_test.js
+++ b/browser/components/loop/test/shared/feedbackApiClient_test.js
@@ -133,16 +133,23 @@ describe("loop.FeedbackAPIClient", funct
it("should send user_agent information when provided", function() {
client.send({user_agent: "MOZAGENT"}, function(){});
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.user_agent).eql("MOZAGENT");
});
+ it("should send url information when provided", function() {
+ client.send({url: "http://fake.invalid"}, function(){});
+
+ var parsed = JSON.parse(requests[0].requestBody);
+ expect(parsed.url).eql("http://fake.invalid");
+ });
+
it("should throw on invalid feedback data", function() {
expect(function() {
client.send("invalid data", function(){});
}).to.Throw(/Invalid/);
});
it("should throw on unsupported field name", function() {
expect(function() {
--- a/browser/components/loop/test/shared/router_test.js
+++ b/browser/components/loop/test/shared/router_test.js
@@ -55,18 +55,17 @@ describe("loop.shared.router", function(
beforeEach(function() {
TestRouter = loop.shared.router.BaseConversationRouter.extend({
endCall: sandbox.spy()
});
conversation = new loop.shared.models.ConversationModel({
loopToken: "fakeToken"
}, {
- sdk: {},
- pendingCallTimeout: 1000
+ sdk: {}
});
});
describe("#constructor", function() {
it("should require a ConversationModel instance", function() {
expect(function() {
new TestRouter({ client: {} });
}).to.Throw(Error, /missing required conversation/);
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -182,34 +182,46 @@ describe("loop.shared.views", function()
publishAudio: sandbox.spy(),
publishVideo: sandbox.spy()
}, Backbone.Events);
fakeSDK = {
initPublisher: sandbox.stub().returns(fakePublisher),
initSession: sandbox.stub().returns(fakeSession)
};
model = new sharedModels.ConversationModel(fakeSessionData, {
- sdk: fakeSDK,
- pendingCallTimeout: 1000
+ sdk: fakeSDK
});
});
describe("#componentDidMount", function() {
- it("should start a session", function() {
+ it("should start a session by default", function() {
sandbox.stub(model, "startSession");
mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: true}
});
sinon.assert.calledOnce(model.startSession);
});
+ it("shouldn't start a session if initiate is false", function() {
+ sandbox.stub(model, "startSession");
+
+ mountTestComponent({
+ initiate: false,
+ sdk: fakeSDK,
+ model: model,
+ video: {enabled: true}
+ });
+
+ sinon.assert.notCalled(model.startSession);
+ });
+
it("should set the correct stream publish options", function() {
var component = mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: false}
});
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -31,16 +31,17 @@
mocha.setup('bdd');
</script>
<!-- App scripts -->
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/mixins.js"></script>
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/shared/js/websocket.js"></script>
+ <script src="../../content/shared/js/feedbackApiClient.js"></script>
<script src="../../standalone/content/js/standaloneClient.js"></script>
<script src="../../standalone/content/js/webapp.js"></script>
<!-- Test scripts -->
<script src="standalone_client_test.js"></script>
<script src="webapp_test.js"></script>
<script>
mocha.run(function () {
$("#mocha").append("<p id='complete'>Complete.</p>");
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -8,34 +8,39 @@ var expect = chai.expect;
var TestUtils = React.addons.TestUtils;
describe("loop.webapp", function() {
"use strict";
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views,
sandbox,
- notifications;
+ notifications,
+ feedbackApiClient;
beforeEach(function() {
sandbox = sinon.sandbox.create();
notifications = new sharedModels.NotificationCollection();
+ feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
+ product: "Loop"
+ });
});
afterEach(function() {
sandbox.restore();
});
describe("#init", function() {
var conversationSetStub;
beforeEach(function() {
sandbox.stub(React, "renderComponent");
sandbox.stub(loop.webapp.WebappHelper.prototype,
"locationHash").returns("#call/fake-Token");
+ loop.config.feedbackApiUrl = "http://fake.invalid";
conversationSetStub =
sandbox.stub(sharedModels.ConversationModel.prototype, "set");
});
it("should create the WebappRootView", function() {
loop.webapp.init();
sinon.assert.calledOnce(React.renderComponent);
@@ -72,17 +77,18 @@ describe("loop.webapp", function() {
sdk: {}
});
conversation.set("loopToken", "fakeToken");
ocView = mountTestComponent({
helper: new loop.webapp.WebappHelper(),
client: client,
conversation: conversation,
notifications: notifications,
- sdk: {}
+ sdk: {},
+ feedbackApiClient: feedbackApiClient
});
});
describe("start", function() {
it("should display the StartConversationView", function() {
TestUtils.findRenderedComponentWithType(ocView,
loop.webapp.StartConversationView);
});
@@ -300,26 +306,26 @@ describe("loop.webapp", function() {
});
});
describe("session:ended", function() {
it("should set display the StartConversationView", function() {
conversation.trigger("session:ended");
TestUtils.findRenderedComponentWithType(ocView,
- loop.webapp.StartConversationView);
+ loop.webapp.EndedConversationView);
});
});
describe("session:peer-hungup", function() {
it("should set display the StartConversationView", function() {
conversation.trigger("session:peer-hungup");
TestUtils.findRenderedComponentWithType(ocView,
- loop.webapp.StartConversationView);
+ loop.webapp.EndedConversationView);
});
it("should notify the user", function() {
conversation.trigger("session:peer-hungup");
sinon.assert.calledOnce(notifications.warnL10n);
sinon.assert.calledWithExactly(notifications.warnL10n,
"peer_ended_conversation2");
@@ -328,17 +334,17 @@ describe("loop.webapp", function() {
});
describe("session:network-disconnected", function() {
it("should display the StartConversationView",
function() {
conversation.trigger("session:network-disconnected");
TestUtils.findRenderedComponentWithType(ocView,
- loop.webapp.StartConversationView);
+ loop.webapp.EndedConversationView);
});
it("should notify the user", function() {
conversation.trigger("session:network-disconnected");
sinon.assert.calledOnce(notifications.warnL10n);
sinon.assert.calledWithExactly(notifications.warnL10n,
"network_disconnected");
@@ -469,18 +475,20 @@ describe("loop.webapp", function() {
describe("WebappRootView", function() {
var webappHelper, sdk, conversationModel, client, props;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
loop.webapp.WebappRootView({
client: client,
helper: webappHelper,
+ notifications: notifications,
sdk: sdk,
- conversation: conversationModel
+ conversation: conversationModel,
+ feedbackApiClient: feedbackApiClient
}));
}
beforeEach(function() {
webappHelper = new loop.webapp.WebappHelper();
sdk = {
checkSystemRequirements: function() { return true; }
};
@@ -767,16 +775,42 @@ describe("loop.webapp", function() {
);
tos = view.getDOMNode().querySelector(".terms-service");
expect(tos.classList.contains("hide")).to.equal(true);
});
});
});
+ describe("EndedConversationView", function() {
+ var view, conversation;
+
+ beforeEach(function() {
+ conversation = new sharedModels.ConversationModel({}, {
+ sdk: {}
+ });
+ view = React.addons.TestUtils.renderIntoDocument(
+ loop.webapp.EndedConversationView({
+ conversation: conversation,
+ sdk: {},
+ feedbackApiClient: feedbackApiClient,
+ onAfterFeedbackReceived: function(){}
+ })
+ );
+ });
+
+ it("should render a ConversationView", function() {
+ TestUtils.findRenderedComponentWithType(view, sharedViews.ConversationView);
+ });
+
+ it("should render a FeedbackView", function() {
+ TestUtils.findRenderedComponentWithType(view, sharedViews.FeedbackView);
+ });
+ });
+
describe("PromoteFirefoxView", function() {
describe("#render", function() {
it("should not render when using Firefox", function() {
var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
helper: {isFirefox: function() { return true; }}
}));
expect(comp.getDOMNode().querySelectorAll("h3").length).eql(0);
--- a/browser/components/loop/ui/fake-l10n.js
+++ b/browser/components/loop/ui/fake-l10n.js
@@ -4,16 +4,20 @@
/**
* /!\ FIXME: THIS IS A HORRID HACK which fakes both the mozL10n and webL10n
* objects and makes them returning the string id and serialized vars if any,
* for any requested string id.
* @type {Object}
*/
navigator.mozL10n = document.mozL10n = {
+ initialize: function(){},
+
+ getDirection: function(){},
+
get: function(stringId, vars) {
// upcase the first letter
var readableStringId = stringId.replace(/^./, function(match) {
"use strict";
return match.toUpperCase();
}).replace(/_/g, " "); // and convert _ chars to spaces
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -2,11 +2,12 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Faking the mozLoop object which doesn't exist in regular web pages.
* @type {Object}
*/
navigator.mozLoop = {
+ ensureRegistered: function() {},
getLoopCharPref: function() {},
getLoopBoolPref: function() {}
};
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -32,17 +32,17 @@
.showcase-menu > a {
margin-right: .5em;
padding: .4rem;
margin-top: .2rem;
}
.showcase > section {
position: relative;
- padding-top: 12em;
+ padding-top: 14em;
clear: both;
}
.showcase > section > h1 {
margin: 1em 0;
border-bottom: 1px solid #aaa;
}
@@ -144,8 +144,14 @@
max-width: 120px;
}
.conversation .media.nested .remote {
/* Height of obsolute box covers media control buttons. UI showcase only.
* When tokbox inserts the markup into the page the problem goes away */
bottom: auto;
}
+
+.standalone .ended-conversation .remote_wrapper,
+.standalone .video-layout-wrapper {
+ /* Removes the fake video image for ended conversations */
+ background: none;
+}
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -18,16 +18,17 @@
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
+ var EndedConversationView = loop.webapp.EndedConversationView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Local helpers
function returnTrue() {
@@ -333,16 +334,29 @@
Example({summary: "Firefox User"},
CallUrlExpiredView({helper: {isFirefox: returnTrue}})
),
Example({summary: "Non-Firefox User"},
CallUrlExpiredView({helper: {isFirefox: returnFalse}})
)
),
+ Section({name: "EndedConversationView"},
+ Example({summary: "Displays the feedback form"},
+ React.DOM.div({className: "standalone"},
+ EndedConversationView({sdk: mockSDK,
+ video: {enabled: true},
+ audio: {enabled: true},
+ conversation: mockConversationModel,
+ feedbackApiClient: stageFeedbackApiClient,
+ onAfterFeedbackReceived: noop})
+ )
+ )
+ ),
+
Section({name: "AlertMessages"},
Example({summary: "Various alerts"},
React.DOM.div({className: "alert alert-warning"},
React.DOM.button({className: "close"}),
React.DOM.p({className: "message"},
"The person you were calling has ended the conversation."
)
),
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -18,16 +18,17 @@
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
+ var EndedConversationView = loop.webapp.EndedConversationView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Local helpers
function returnTrue() {
@@ -333,16 +334,29 @@
<Example summary="Firefox User">
<CallUrlExpiredView helper={{isFirefox: returnTrue}} />
</Example>
<Example summary="Non-Firefox User">
<CallUrlExpiredView helper={{isFirefox: returnFalse}} />
</Example>
</Section>
+ <Section name="EndedConversationView">
+ <Example summary="Displays the feedback form">
+ <div className="standalone">
+ <EndedConversationView sdk={mockSDK}
+ video={{enabled: true}}
+ audio={{enabled: true}}
+ conversation={mockConversationModel}
+ feedbackApiClient={stageFeedbackApiClient}
+ onAfterFeedbackReceived={noop} />
+ </div>
+ </Example>
+ </Section>
+
<Section name="AlertMessages">
<Example summary="Various alerts">
<div className="alert alert-warning">
<button className="close"></button>
<p className="message">
The person you were calling has ended the conversation.
</p>
</div>