Merge fx-team to m-c. a=merge

This commit is contained in:
Ryan VanderMeulen 2014-08-04 16:03:44 -04:00
commit cd1b84f5fd
47 changed files with 5254 additions and 2698 deletions

View File

@ -1575,10 +1575,14 @@ pref("loop.enabled", false);
pref("loop.server", "https://loop.services.mozilla.com");
pref("loop.seenToS", "unseen");
pref("loop.legal.ToS_url", "https://accounts.firefox.com/legal/terms");
pref("loop.legal.privacy_url", "https://www.mozilla.org/privacy/");
pref("loop.do_not_disturb", false);
pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
pref("loop.retry_delay.start", 60000);
pref("loop.retry_delay.limit", 300000);
pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
pref("loop.feedback.product", "Loop");
// serverURL to be assigned by services team
pref("services.push.serverURL", "wss://push.services.mozilla.com/");

View File

@ -2,7 +2,7 @@
* 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/. */
const Cu = Components.utils;
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/webrtcUI.jsm");
@ -129,7 +129,12 @@ let PositionHandler = {
adjustPosition: function() {
if (!this.positionCustomized) {
// Center the window horizontally on the screen (not the available area).
window.moveTo((screen.width - document.documentElement.clientWidth) / 2, 0);
// Until we have moved the window to y=0, 'screen.width' may give a value
// for a secondary screen, so use values from the screen manager instead.
let width = {};
Cc["@mozilla.org/gfx/screenmanager;1"].getService(Ci.nsIScreenManager)
.primaryScreen.GetRectDisplayPix({}, {}, width, {});
window.moveTo((width.value - document.documentElement.clientWidth) / 2, 0);
} else {
// This will ensure we're at y=0.
this.setXPosition(window.screenX);

View File

@ -26,7 +26,7 @@
window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
</script>
<script type="text/javascript" src="loop/libs/sdk.js"></script>
<script type="text/javascript" src="loop/shared/libs/react-0.10.0.js"></script>
<script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
<script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
<script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
<script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
@ -35,6 +35,7 @@
<script type="text/javascript" src="loop/shared/js/models.js"></script>
<script type="text/javascript" src="loop/shared/js/router.js"></script>
<script type="text/javascript" src="loop/shared/js/views.js"></script>
<script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
<script type="text/javascript" src="loop/js/client.js"></script>
<script type="text/javascript" src="loop/js/desktopRouter.js"></script>
<script type="text/javascript" src="loop/js/conversation.js"></script>

View File

@ -116,30 +116,6 @@ loop.conversation = (function(OT, mozL10n) {
}
});
/**
* Call ended view.
* @type {loop.shared.views.BaseView}
*/
var EndedCallView = sharedViews.BaseView.extend({
template: _.template([
'<p>',
' <button class="btn btn-info" data-l10n-id="close_window"></button>',
'</p>'
].join("")),
className: "call-ended",
events: {
"click button": "closeWindow"
},
closeWindow: function(event) {
event.preventDefault();
// XXX For now, we just close the window.
window.close();
}
});
/**
* Conversation router.
*
@ -155,8 +131,8 @@ loop.conversation = (function(OT, mozL10n) {
"call/accept": "accept",
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock"
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
/**
@ -170,7 +146,7 @@ loop.conversation = (function(OT, mozL10n) {
* @override {loop.shared.router.BaseConversationRouter.endCall}
*/
endCall: function() {
this.navigate("call/ended", {trigger: true});
this.navigate("call/feedback", {trigger: true});
},
/**
@ -180,7 +156,7 @@ loop.conversation = (function(OT, mozL10n) {
* by the router from the URL.
*/
incoming: function(loopVersion) {
window.navigator.mozLoop.startAlerting();
navigator.mozLoop.startAlerting();
this._conversation.set({loopVersion: loopVersion});
this._conversation.once("accept", function() {
this.navigate("call/accept", {trigger: true});
@ -200,7 +176,7 @@ loop.conversation = (function(OT, mozL10n) {
* Accepts an incoming call.
*/
accept: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
this._conversation.initiate({
client: new loop.Client(),
outgoing: false
@ -211,7 +187,7 @@ loop.conversation = (function(OT, mozL10n) {
* Declines an incoming call.
*/
decline: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
// XXX For now, we just close the window
window.close();
},
@ -223,7 +199,7 @@ loop.conversation = (function(OT, mozL10n) {
* after a callUrl is received
*/
declineAndBlock: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var client = new loop.Client();
client.deleteCallUrl(token, function(error) {
@ -254,10 +230,17 @@ loop.conversation = (function(OT, mozL10n) {
},
/**
* XXX: load a view with a close button for now?
* Call has ended, display a feedback form.
*/
ended: function() {
this.loadView(new EndedCallView());
feedback: function() {
document.title = mozL10n.get("call_has_ended");
this.loadReactComponent(sharedViews.FeedbackView({
feedbackApiClient: new loop.FeedbackAPIClient({
baseUrl: navigator.mozLoop.getLoopCharPref("feedback.baseUrl"),
product: navigator.mozLoop.getLoopCharPref("feedback.product")
})
}));
}
});
@ -267,7 +250,7 @@ loop.conversation = (function(OT, mozL10n) {
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(window.navigator.mozLoop);
mozL10n.initialize(navigator.mozLoop);
document.title = mozL10n.get("incoming_call_title");
@ -280,7 +263,6 @@ loop.conversation = (function(OT, mozL10n) {
return {
ConversationRouter: ConversationRouter,
EndedCallView: EndedCallView,
IncomingCallView: IncomingCallView,
init: init
};

View File

@ -116,30 +116,6 @@ loop.conversation = (function(OT, mozL10n) {
}
});
/**
* Call ended view.
* @type {loop.shared.views.BaseView}
*/
var EndedCallView = sharedViews.BaseView.extend({
template: _.template([
'<p>',
' <button class="btn btn-info" data-l10n-id="close_window"></button>',
'</p>'
].join("")),
className: "call-ended",
events: {
"click button": "closeWindow"
},
closeWindow: function(event) {
event.preventDefault();
// XXX For now, we just close the window.
window.close();
}
});
/**
* Conversation router.
*
@ -155,8 +131,8 @@ loop.conversation = (function(OT, mozL10n) {
"call/accept": "accept",
"call/decline": "decline",
"call/ongoing": "conversation",
"call/ended": "ended",
"call/declineAndBlock": "declineAndBlock"
"call/declineAndBlock": "declineAndBlock",
"call/feedback": "feedback"
},
/**
@ -170,7 +146,7 @@ loop.conversation = (function(OT, mozL10n) {
* @override {loop.shared.router.BaseConversationRouter.endCall}
*/
endCall: function() {
this.navigate("call/ended", {trigger: true});
this.navigate("call/feedback", {trigger: true});
},
/**
@ -180,7 +156,7 @@ loop.conversation = (function(OT, mozL10n) {
* by the router from the URL.
*/
incoming: function(loopVersion) {
window.navigator.mozLoop.startAlerting();
navigator.mozLoop.startAlerting();
this._conversation.set({loopVersion: loopVersion});
this._conversation.once("accept", function() {
this.navigate("call/accept", {trigger: true});
@ -200,7 +176,7 @@ loop.conversation = (function(OT, mozL10n) {
* Accepts an incoming call.
*/
accept: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
this._conversation.initiate({
client: new loop.Client(),
outgoing: false
@ -211,7 +187,7 @@ loop.conversation = (function(OT, mozL10n) {
* Declines an incoming call.
*/
decline: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
// XXX For now, we just close the window
window.close();
},
@ -223,7 +199,7 @@ loop.conversation = (function(OT, mozL10n) {
* after a callUrl is received
*/
declineAndBlock: function() {
window.navigator.mozLoop.stopAlerting();
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var client = new loop.Client();
client.deleteCallUrl(token, function(error) {
@ -254,10 +230,17 @@ loop.conversation = (function(OT, mozL10n) {
},
/**
* XXX: load a view with a close button for now?
* Call has ended, display a feedback form.
*/
ended: function() {
this.loadView(new EndedCallView());
feedback: function() {
document.title = mozL10n.get("call_has_ended");
this.loadReactComponent(sharedViews.FeedbackView({
feedbackApiClient: new loop.FeedbackAPIClient({
baseUrl: navigator.mozLoop.getLoopCharPref("feedback.baseUrl"),
product: navigator.mozLoop.getLoopCharPref("feedback.product")
})
}));
}
});
@ -267,7 +250,7 @@ loop.conversation = (function(OT, mozL10n) {
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(window.navigator.mozLoop);
mozL10n.initialize(navigator.mozLoop);
document.title = mozL10n.get("incoming_call_title");
@ -280,7 +263,6 @@ loop.conversation = (function(OT, mozL10n) {
return {
ConversationRouter: ConversationRouter,
EndedCallView: EndedCallView,
IncomingCallView: IncomingCallView,
init: init
};

View File

@ -108,12 +108,21 @@ loop.panel = (function(_, mozL10n) {
},
render: function() {
var tosHTML = __("legal_text_and_links", {
"terms_of_use_url": "https://accounts.firefox.com/legal/terms",
"privacy_notice_url": "www.mozilla.org/privacy/"
});
if (this.state.seenToS == "unseen") {
var terms_of_use_url = navigator.mozLoop.getLoopCharPref('legal.ToS_url');
var privacy_notice_url = navigator.mozLoop.getLoopCharPref('legal.privacy_url');
var tosHTML = __("legal_text_and_links2", {
"terms_of_use": React.renderComponentToStaticMarkup(
React.DOM.a({href: terms_of_use_url, target: "_blank"},
__("legal_text_tos")
)
),
"privacy_notice": React.renderComponentToStaticMarkup(
React.DOM.a({href: privacy_notice_url, target: "_blank"},
__("legal_text_privacy")
)
),
});
navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
return React.DOM.p({className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML}});

View File

@ -108,12 +108,21 @@ loop.panel = (function(_, mozL10n) {
},
render: function() {
var tosHTML = __("legal_text_and_links", {
"terms_of_use_url": "https://accounts.firefox.com/legal/terms",
"privacy_notice_url": "www.mozilla.org/privacy/"
});
if (this.state.seenToS == "unseen") {
var terms_of_use_url = navigator.mozLoop.getLoopCharPref('legal.ToS_url');
var privacy_notice_url = navigator.mozLoop.getLoopCharPref('legal.privacy_url');
var tosHTML = __("legal_text_and_links2", {
"terms_of_use": React.renderComponentToStaticMarkup(
<a href={terms_of_use_url} target="_blank">
{__("legal_text_tos")}
</a>
),
"privacy_notice": React.renderComponentToStaticMarkup(
<a href={privacy_notice_url} target="_blank">
{__("legal_text_privacy")}
</a>
),
});
navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
return <p className="terms-service"
dangerouslySetInnerHTML={{__html: tosHTML}}></p>;

View File

@ -15,7 +15,7 @@
<div id="main"></div>
<script type="text/javascript" src="loop/shared/libs/react-0.10.0.js"></script>
<script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
<script type="text/javascript" src="loop/libs/l10n.js"></script>
<script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
<script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>

View File

@ -238,3 +238,105 @@
color: #FFF;
background: #111;
}
/* Expired call url page */
.expired-url-info {
width: 400px;
margin: 0 auto;
}
.promote-firefox {
text-align: center;
font-size: 18px;
line-height: 24px;
margin: 2em 0;
}
.promote-firefox h3 {
font-weight: 300;
}
/* Feedback form */
.feedback {
padding: 1em;
}
.feedback h3 {
color: #666;
font-size: 12px;
font-weight: 700;
text-align: center;
margin-bottom: 10px;
margin-top: 15px;
}
.feedback .faces {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.feedback .face {
border: 1px solid transparent;
box-shadow: 0px 1px 2px #CCC;
cursor: pointer;
border-radius: 4px;
margin: 0px 10px;
width: 80px;
height: 80px;
background-color: #fbfbfb;
background-size: 60px auto;
background-position: center center;
background-repeat: no-repeat;
}
.feedback .face:hover {
border: 1px solid #DDD;
background-color: #FEFEFE;
}
.feedback .face.face-happy {
background-image: url("../img/happy.png");
}
.feedback .face.face-sad {
background-image: url("../img/sad.png");
}
.feedback button.back {
border-radius: 2px;
border: 1px solid #CCC;
color: #CCC;
font-size: 11px;
cursor: pointer;
padding: 3px 10px;
display: inline;
margin-bottom: 1em;
}
.feedback label {
display: block;
}
.feedback form input[type="radio"] {
margin-right: .5em;
}
.feedback form button[type="submit"] {
width: 100%;
}
.feedback form input[type="text"] {
width: 100%;
}
.feedback .info {
display: block;
font-size: 10px;
color: #CCC;
text-align: center;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,92 @@
/* 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 */
var loop = loop || {};
loop.FeedbackAPIClient = (function($) {
"use strict";
/**
* Feedback API client. Sends feedback data to an input.mozilla.com compatible
* API.
*
* Available settings:
* - {String} baseUrl Base API url (required)
* - {String} product Product name (required)
*
* @param {Object} settings Settings.
* @link http://fjord.readthedocs.org/en/latest/api.html
*/
function FeedbackAPIClient(settings) {
settings = settings || {};
if (!settings.hasOwnProperty("baseUrl")) {
throw new Error("Missing required baseUrl setting.");
}
this._baseUrl = settings.baseUrl;
if (!settings.hasOwnProperty("product")) {
throw new Error("Missing required product setting.");
}
this._product = settings.product;
}
FeedbackAPIClient.prototype = {
/**
* Formats Feedback data to match the API spec.
*
* @param {Object} fields Feedback form data.
* @return {Object} Formatted data.
*/
_formatData: function(fields) {
var formatted = {};
if (typeof fields !== "object") {
throw new Error("Invalid feedback data provided.");
}
formatted.product = this._product;
formatted.happy = fields.happy;
formatted.category = fields.category;
// Default description field value
if (!fields.description) {
formatted.description = (fields.happy ? "Happy" : "Sad") + " User";
} else {
formatted.description = fields.description;
}
return formatted;
},
/**
* Sends feedback data.
*
* @param {Object} fields Feedback form data.
* @param {Function} cb Callback(err, result)
*/
send: function(fields, cb) {
var req = $.ajax({
url: this._baseUrl,
method: "POST",
contentType: "application/json",
dataType: "json",
data: JSON.stringify(this._formatData(fields))
});
req.done(function(result) {
console.info("User feedback data have been submitted", result);
cb(null, result);
});
req.fail(function(jqXHR, textStatus, errorThrown) {
var message = "Error posting user feedback data";
var httpError = jqXHR.status + " " + errorThrown;
console.error(message, httpError, JSON.stringify(jqXHR.responseJSON));
cb(new Error(message + ": " + httpError));
});
}
};
return FeedbackAPIClient;
})(jQuery);

View File

@ -166,7 +166,6 @@ loop.shared.router = (function(l10n) {
* Session has ended. Notifies the user and ends the call.
*/
_onSessionEnded: function() {
this._notifier.warnL10n("call_has_ended");
this.endCall();
},

View File

@ -13,6 +13,7 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var __ = l10n.get;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* L10n view. Translates resulting view DOM fragment once rendered.
@ -352,6 +353,239 @@ loop.shared.views = (function(_, OT, l10n) {
}
});
/**
* Feedback outer layout.
*
* Props:
* -
*/
var FeedbackLayout = React.createClass({displayName: 'FeedbackLayout',
propTypes: {
children: React.PropTypes.component.isRequired,
title: React.PropTypes.string.isRequired,
reset: React.PropTypes.func // if not specified, no Back btn is shown
},
render: function() {
var backButton = React.DOM.div(null);
if (this.props.reset) {
backButton = (
React.DOM.button({className: "back", type: "button", onClick: this.props.reset},
"« ", __("feedback_back_button")
)
);
}
return (
React.DOM.div({className: "feedback"},
backButton,
React.DOM.h3(null, this.props.title),
this.props.children
)
);
}
});
/**
* Detailed feedback form.
*/
var FeedbackForm = React.createClass({displayName: 'FeedbackForm',
propTypes: {
pending: React.PropTypes.bool,
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
getInitialProps: function() {
return {pending: false};
},
_getCategories: function() {
return {
audio_quality: __("feedback_category_audio_quality"),
video_quality: __("feedback_category_video_quality"),
disconnected : __("feedback_category_was_disconnected"),
confusing: __("feedback_category_confusing"),
other: __("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
React.DOM.label({key: key},
React.DOM.input({type: "radio", ref: "category", name: "category",
value: category,
onChange: this.handleCategoryChange}),
categories[category]
)
);
}, this);
},
/**
* Checks if the form is ready for submission:
* - a category (reason) must be chosen
* - no feedback submission should be pending
*
* @return {Boolean}
*/
_isFormReady: function() {
return this.state.category !== "" && !this.props.pending;
},
handleCategoryChange: function(event) {
var category = event.target.value;
if (category !== "other") {
// resets description text field
this.setState({description: ""});
}
this.setState({category: category});
},
handleCustomTextChange: function(event) {
this.setState({description: event.target.value});
},
handleFormSubmit: function(event) {
event.preventDefault();
this.props.sendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
});
},
render: function() {
return (
FeedbackLayout({title: __("feedback_what_makes_you_sad"),
reset: this.props.reset},
React.DOM.form({onSubmit: this.handleFormSubmit},
this._getCategoryFields(),
React.DOM.p(null, React.DOM.input({type: "text", ref: "description", name: "description",
disabled: this.state.category !== "other",
onChange: this.handleCustomTextChange,
value: this.state.description})),
React.DOM.button({type: "submit", className: "btn btn-success",
disabled: !this._isFormReady()},
__("feedback_submit_button")
)
)
)
);
}
});
/**
* Feedback received view.
*/
var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
window.close();
}
return (
FeedbackLayout({title: __("feedback_thank_you_heading")},
React.DOM.p({className: "info thank-you"}, __("feedback_window_will_close_in", {
countdown: this.state.countdown
}))
)
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
// 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() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
this.sendFeedback({happy: true}, this._onFeedbackSent);
},
handleSadClick: function() {
this.setState({step: "form"});
},
sendFeedback: function(fields) {
// Setting state.pending to true will disable the submit button to avoid
// multiple submissions
this.setState({pending: true});
// Sends feedback data
this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
},
_onFeedbackSent: function(err) {
if (err) {
// XXX better end user error reporting, see bug 1046738
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);
case "form":
return FeedbackForm({feedbackApiClient: this.props.feedbackApiClient,
sendFeedback: this.sendFeedback,
reset: this.reset,
pending: this.state.pending});
default:
return (
FeedbackLayout({title: __("feedback_call_experience_heading")},
React.DOM.div({className: "faces"},
React.DOM.button({className: "face face-happy",
onClick: this.handleHappyClick}),
React.DOM.button({className: "face face-sad",
onClick: this.handleSadClick})
)
)
);
}
}
});
/**
* Notification view.
*/
@ -518,6 +752,7 @@ loop.shared.views = (function(_, OT, l10n) {
BaseView: BaseView,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView,
NotificationView: NotificationView,

View File

@ -13,6 +13,7 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var __ = l10n.get;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* L10n view. Translates resulting view DOM fragment once rendered.
@ -352,6 +353,239 @@ loop.shared.views = (function(_, OT, l10n) {
}
});
/**
* Feedback outer layout.
*
* Props:
* -
*/
var FeedbackLayout = React.createClass({
propTypes: {
children: React.PropTypes.component.isRequired,
title: React.PropTypes.string.isRequired,
reset: React.PropTypes.func // if not specified, no Back btn is shown
},
render: function() {
var backButton = <div />;
if (this.props.reset) {
backButton = (
<button className="back" type="button" onClick={this.props.reset}>
&laquo;&nbsp;{__("feedback_back_button")}
</button>
);
}
return (
<div className="feedback">
{backButton}
<h3>{this.props.title}</h3>
{this.props.children}
</div>
);
}
});
/**
* Detailed feedback form.
*/
var FeedbackForm = React.createClass({
propTypes: {
pending: React.PropTypes.bool,
sendFeedback: React.PropTypes.func,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
getInitialProps: function() {
return {pending: false};
},
_getCategories: function() {
return {
audio_quality: __("feedback_category_audio_quality"),
video_quality: __("feedback_category_video_quality"),
disconnected : __("feedback_category_was_disconnected"),
confusing: __("feedback_category_confusing"),
other: __("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
<label key={key}>
<input type="radio" ref="category" name="category"
value={category}
onChange={this.handleCategoryChange} />
{categories[category]}
</label>
);
}, this);
},
/**
* Checks if the form is ready for submission:
* - a category (reason) must be chosen
* - no feedback submission should be pending
*
* @return {Boolean}
*/
_isFormReady: function() {
return this.state.category !== "" && !this.props.pending;
},
handleCategoryChange: function(event) {
var category = event.target.value;
if (category !== "other") {
// resets description text field
this.setState({description: ""});
}
this.setState({category: category});
},
handleCustomTextChange: function(event) {
this.setState({description: event.target.value});
},
handleFormSubmit: function(event) {
event.preventDefault();
this.props.sendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
});
},
render: function() {
return (
<FeedbackLayout title={__("feedback_what_makes_you_sad")}
reset={this.props.reset}>
<form onSubmit={this.handleFormSubmit}>
{this._getCategoryFields()}
<p><input type="text" ref="description" name="description"
disabled={this.state.category !== "other"}
onChange={this.handleCustomTextChange}
value={this.state.description} /></p>
<button type="submit" className="btn btn-success"
disabled={!this._isFormReady()}>
{__("feedback_submit_button")}
</button>
</form>
</FeedbackLayout>
);
}
});
/**
* Feedback received view.
*/
var FeedbackReceived = React.createClass({
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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
window.close();
}
return (
<FeedbackLayout title={__("feedback_thank_you_heading")}>
<p className="info thank-you">{__("feedback_window_will_close_in", {
countdown: this.state.countdown
})}</p>
</FeedbackLayout>
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({
propTypes: {
// A loop.FeedbackAPIClient instance
feedbackApiClient: React.PropTypes.object.isRequired,
// 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() {
return {step: "start"};
},
reset: function() {
this.setState(this.getInitialState());
},
handleHappyClick: function() {
this.sendFeedback({happy: true}, this._onFeedbackSent);
},
handleSadClick: function() {
this.setState({step: "form"});
},
sendFeedback: function(fields) {
// Setting state.pending to true will disable the submit button to avoid
// multiple submissions
this.setState({pending: true});
// Sends feedback data
this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
},
_onFeedbackSent: function(err) {
if (err) {
// XXX better end user error reporting, see bug 1046738
console.error("Unable to send user feedback", err);
}
this.setState({pending: false, step: "finished"});
},
render: function() {
switch(this.state.step) {
case "finished":
return <FeedbackReceived />;
case "form":
return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
sendFeedback={this.sendFeedback}
reset={this.reset}
pending={this.state.pending} />;
default:
return (
<FeedbackLayout title={__("feedback_call_experience_heading")}>
<div className="faces">
<button className="face face-happy"
onClick={this.handleHappyClick}></button>
<button className="face face-sad"
onClick={this.handleSadClick}></button>
</div>
</FeedbackLayout>
);
}
}
});
/**
* Notification view.
*/
@ -518,6 +752,7 @@ loop.shared.views = (function(_, OT, l10n) {
BaseView: BaseView,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView,
NotificationView: NotificationView,

View File

@ -22,6 +22,8 @@ browser.jar:
content/browser/loop/shared/css/conversation.css (content/shared/css/conversation.css)
# Shared images
content/browser/loop/shared/img/happy.png (content/shared/img/happy.png)
content/browser/loop/shared/img/sad.png (content/shared/img/sad.png)
content/browser/loop/shared/img/icon_32.png (content/shared/img/icon_32.png)
content/browser/loop/shared/img/icon_64.png (content/shared/img/icon_64.png)
content/browser/loop/shared/img/loading-icon.gif (content/shared/img/loading-icon.gif)
@ -39,13 +41,14 @@ browser.jar:
content/browser/loop/shared/img/dropdown-inverse@2x.png (content/shared/img/dropdown-inverse@2x.png)
# Shared scripts
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
# Shared libs
content/browser/loop/shared/libs/react-0.10.0.js (content/shared/libs/react-0.10.0.js)
content/browser/loop/shared/libs/react-0.11.1.js (content/shared/libs/react-0.11.1.js)
content/browser/loop/shared/libs/lodash-2.4.1.js (content/shared/libs/lodash-2.4.1.js)
content/browser/loop/shared/libs/jquery-2.1.0.js (content/shared/libs/jquery-2.1.0.js)
content/browser/loop/shared/libs/backbone-1.1.2.js (content/shared/libs/backbone-1.1.2.js)

View File

@ -26,7 +26,7 @@
</script>
<script type="text/javascript" src="shared/libs/sdk.js"></script>
<script type="text/javascript" src="libs/webl10n-20130617.js"></script>
<script type="text/javascript" src="shared/libs/react-0.10.0.js"></script>
<script type="text/javascript" src="shared/libs/react-0.11.1.js"></script>
<script type="text/javascript" src="shared/libs/jquery-2.1.0.js"></script>
<script type="text/javascript" src="shared/libs/lodash-2.4.1.js"></script>
<script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>

View File

@ -47,7 +47,7 @@ describe("loop.conversation", function() {
});
afterEach(function() {
delete window.navigator.mozLoop;
delete navigator.mozLoop;
sandbox.restore();
});
@ -79,7 +79,7 @@ describe("loop.conversation", function() {
sinon.assert.calledOnce(document.mozL10n.initialize);
sinon.assert.calledWithExactly(document.mozL10n.initialize,
window.navigator.mozLoop);
navigator.mozLoop);
});
it("should set the document title", function() {
@ -159,16 +159,16 @@ describe("loop.conversation", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return TestUtils.isComponentOfType(value,
return TestUtils.isDescriptorOfType(value,
loop.conversation.IncomingCallView);
}));
});
it("should start alerting", function() {
sandbox.stub(window.navigator.mozLoop, "startAlerting");
sandbox.stub(navigator.mozLoop, "startAlerting");
router.incoming("fakeVersion");
sinon.assert.calledOnce(window.navigator.mozLoop.startAlerting);
sinon.assert.calledOnce(navigator.mozLoop.startAlerting);
});
});
@ -187,10 +187,10 @@ describe("loop.conversation", function() {
});
it("should stop alerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.accept();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
});
@ -207,7 +207,7 @@ describe("loop.conversation", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return TestUtils.isComponentOfType(value,
return TestUtils.isDescriptorOfType(value,
loop.shared.views.ConversationView);
}));
});
@ -241,25 +241,56 @@ describe("loop.conversation", function() {
});
it("should stop alerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.decline();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
});
describe("#ended", function() {
describe("#feedback", function() {
var oldTitle;
beforeEach(function() {
oldTitle = document.title;
sandbox.stub(document.mozL10n, "get").returns("Call ended");
});
beforeEach(function() {
sandbox.stub(loop, "FeedbackAPIClient");
sandbox.stub(router, "loadReactComponent");
});
afterEach(function() {
document.title = oldTitle;
});
// XXX When the call is ended gracefully, we should check that we
// close connections nicely
it("should close the window");
// close connections nicely (see bug 1046744)
it("should display a feedback form view", function() {
router.feedback();
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return TestUtils.isDescriptorOfType(value,
loop.shared.views.FeedbackView);
}));
});
it("should update the conversation window title", function() {
router.feedback();
expect(document.title).eql("Call ended");
});
});
describe("#blocked", function() {
it("should call mozLoop.stopAlerting", function() {
sandbox.stub(window.navigator.mozLoop, "stopAlerting");
sandbox.stub(navigator.mozLoop, "stopAlerting");
router.declineAndBlock();
sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
});
it("should call delete call", function() {
@ -319,44 +350,31 @@ describe("loop.conversation", function() {
sinon.assert.calledWith(router.navigate, "call/ongoing");
});
it("should navigate to call/ended when the call session ends",
it("should navigate to call/feedback when the call session ends",
function() {
conversation.trigger("session:ended");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
it("should navigate to call/ended when peer hangs up", function() {
it("should navigate to call/feedback when peer hangs up", function() {
conversation.trigger("session:peer-hungup");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
it("should navigate to call/{token} when network disconnects",
it("should navigate to call/feedback when network disconnects",
function() {
conversation.trigger("session:network-disconnected");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWith(router.navigate, "call/ended");
sinon.assert.calledWith(router.navigate, "call/feedback");
});
});
});
describe("EndedCallView", function() {
describe("#closeWindow", function() {
it("should close the conversation window", function() {
sandbox.stub(window, "close");
var view = new loop.conversation.EndedCallView();
view.closeWindow({preventDefault: sandbox.spy()});
sinon.assert.calledOnce(window.close);
});
});
});
describe("IncomingCallView", function() {
var view, model;

View File

@ -16,7 +16,7 @@
<div id="fixtures"></div>
<!-- libs -->
<script src="../../content/libs/l10n.js"></script>
<script src="../../content/shared/libs/react-0.10.0.js"></script>
<script src="../../content/shared/libs/react-0.11.1.js"></script>
<script src="../../content/shared/libs/jquery-2.1.0.js"></script>
<script src="../../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../../content/shared/libs/backbone-1.1.2.js"></script>
@ -33,6 +33,7 @@
<!-- App scripts -->
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/feedbackApiClient.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/views.js"></script>

View File

@ -110,7 +110,7 @@ describe("loop.panel", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWithExactly(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isComponentOfType(
return React.addons.TestUtils.isDescriptorOfType(
value, loop.panel.PanelView);
}));
});

View File

@ -0,0 +1,139 @@
/* 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, sinon, it, beforeEach, afterEach, describe */
var expect = chai.expect;
describe("loop.FeedbackAPIClient", function() {
"use strict";
var sandbox,
fakeXHR,
requests = [];
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeXHR = sandbox.useFakeXMLHttpRequest();
requests = [];
// https://github.com/cjohansen/Sinon.JS/issues/393
fakeXHR.xhr.onCreate = function (xhr) {
requests.push(xhr);
};
});
afterEach(function() {
sandbox.restore();
});
describe("#constructor", function() {
it("should require a baseUrl setting", function() {
expect(function() {
return new loop.FeedbackAPIClient();
}).to.Throw(/required baseUrl/);
});
it("should require a product setting", function() {
expect(function() {
return new loop.FeedbackAPIClient({baseUrl: "http://fake"});
}).to.Throw(/required product/);
});
});
describe("constructed", function() {
var client;
beforeEach(function() {
client = new loop.FeedbackAPIClient({
baseUrl: "http://fake/feedback",
product: "Hello"
});
});
describe("#send", function() {
it("should send happy feedback data", function() {
var feedbackData = {
happy: true,
description: "Happy User"
};
client.send(feedbackData, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).to.be.equal("http://fake/feedback");
expect(requests[0].method).to.be.equal("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(true);
expect(parsed.description).eql("Happy User");
});
it("should send sad feedback data", function() {
var feedbackData = {
happy: false,
category: "confusing"
};
client.send(feedbackData, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).to.be.equal("http://fake/feedback");
expect(requests[0].method).to.be.equal("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(false);
expect(parsed.product).eql("Hello");
expect(parsed.category).eql("confusing");
expect(parsed.description).eql("Sad User");
});
it("should send formatted feedback data", function() {
client.send({
happy: false,
category: "other",
description: "it's far too awesome!"
}, function(){});
expect(requests).to.have.length.of(1);
expect(requests[0].url).eql("http://fake/feedback");
expect(requests[0].method).eql("POST");
var parsed = JSON.parse(requests[0].requestBody);
expect(parsed.happy).eql(false);
expect(parsed.product).eql("Hello");
expect(parsed.category).eql("other");
expect(parsed.description).eql("it's far too awesome!");
});
it("should throw on invalid feedback data", function() {
expect(function() {
client.send("invalid data", function(){});
}).to.Throw(/Invalid/);
});
it("should call passed callback on success", function() {
var cb = sandbox.spy();
var fakeResponseData = {description: "confusing"};
client.send({reason: "confusing"}, cb);
requests[0].respond(200, {"Content-Type": "application/json"},
JSON.stringify(fakeResponseData));
sinon.assert.calledOnce(cb);
sinon.assert.calledWithExactly(cb, null, fakeResponseData);
});
it("should call passed callback on error", function() {
var cb = sandbox.spy();
var fakeErrorData = {error: true};
client.send({reason: "confusing"}, cb);
requests[0].respond(400, {"Content-Type": "application/json"},
JSON.stringify(fakeErrorData));
sinon.assert.calledOnce(cb);
sinon.assert.calledWithExactly(cb, sinon.match(function(err) {
return /Bad Request/.test(err);
}));
});
});
});
});

View File

@ -16,7 +16,7 @@
<div id="fixtures"></div>
<!-- libs -->
<script src="../../content/shared/libs/react-0.10.0.js"></script>
<script src="../../content/shared/libs/react-0.11.1.js"></script>
<script src="../../content/shared/libs/jquery-2.1.0.js"></script>
<script src="../../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../../content/shared/libs/backbone-1.1.2.js"></script>
@ -36,11 +36,13 @@
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/feedbackApiClient.js"></script>
<!-- Test scripts -->
<script src="models_test.js"></script>
<script src="views_test.js"></script>
<script src="router_test.js"></script>
<script src="feedbackApiClient_test.js"></script>
<script>
mocha.run(function () {
$("#mocha").append("<p id='complete'>Complete.</p>");

View File

@ -161,14 +161,6 @@ describe("loop.shared.router", function() {
sinon.assert.calledOnce(router.endCall);
});
it("should warn the user that the session has ended", function() {
conversation.trigger("session:ended");
sinon.assert.calledOnce(notifier.warnL10n);
sinon.assert.calledWithExactly(notifier.warnL10n,
"call_has_ended");
});
it("should warn the user when peer hangs up", function() {
conversation.trigger("session:peer-hungup");

View File

@ -401,6 +401,144 @@ describe("loop.shared.views", function() {
});
});
describe("FeedbackView", function() {
var comp, fakeFeedbackApiClient;
beforeEach(function() {
fakeFeedbackApiClient = {send: sandbox.stub()};
comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
feedbackApiClient: fakeFeedbackApiClient
}));
});
// local test helpers
function clickHappyFace(comp) {
var happyFace = comp.getDOMNode().querySelector(".face-happy");
TestUtils.Simulate.click(happyFace);
}
function clickSadFace(comp) {
var sadFace = comp.getDOMNode().querySelector(".face-sad");
TestUtils.Simulate.click(sadFace);
}
function fillSadFeedbackForm(comp, category, text) {
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[value='" + category + "']"));
if (text) {
TestUtils.Simulate.change(
comp.getDOMNode().querySelector("[name='description']"), {
target: {value: "fake reason"}
});
}
}
function submitSadFeedbackForm(comp, category, text) {
TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
}
describe("Happy feedback", function() {
it("should send feedback data when clicking on the happy face",
function() {
clickHappyFace(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWith(fakeFeedbackApiClient.send, {happy: true});
});
it("should thank the user once happy feedback data is sent", function() {
fakeFeedbackApiClient.send = function(data, cb) {
cb();
};
clickHappyFace(comp);
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
expect(comp.getDOMNode().querySelector("button.back")).to.be.a("null");
});
});
describe("Sad feedback", function() {
it("should bring the user to feedback form when clicking on the sad face",
function() {
clickSadFace(comp);
expect(comp.getDOMNode().querySelectorAll("form").length).eql(1);
});
it("should disable the form submit button when no category is chosen",
function() {
clickSadFace(comp);
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(true);
});
it("should enable the form submit button once a choice is made",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(false);
});
it("should disable the form submit button once the form is submitted",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(true);
});
it("should send feedback data when the form is submitted", function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWithMatch(fakeFeedbackApiClient.send, {
happy: false,
category: "confusing"
});
});
it("should send feedback data when user has entered a custom description",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other", "fake reason");
submitSadFeedbackForm(comp);
sinon.assert.calledOnce(fakeFeedbackApiClient.send);
sinon.assert.calledWith(fakeFeedbackApiClient.send, {
happy: false,
category: "other",
description: "fake reason"
});
});
it("should thank the user when feedback data has been sent", function() {
fakeFeedbackApiClient.send = function(data, cb) {
cb();
};
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
expect(comp.getDOMNode()
.querySelectorAll(".feedback .thank-you").length).eql(1);
});
});
});
describe("NotificationView", function() {
var collection, model, view;

View File

@ -16,7 +16,7 @@
<div id="messages"></div>
<div id="fixtures"></div>
<!-- libs -->
<script src="../../content/shared/libs/react-0.10.0.js"></script>
<script src="../../content/shared/libs/react-0.11.1.js"></script>
<script src="../../content/shared/libs/jquery-2.1.0.js"></script>
<script src="../../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../../content/shared/libs/backbone-1.1.2.js"></script>

View File

@ -149,7 +149,7 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isComponentOfType(
return React.addons.TestUtils.isDescriptorOfType(
value, loop.webapp.CallUrlExpiredView);
}));
});
@ -168,7 +168,7 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWithExactly(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isComponentOfType(
return React.addons.TestUtils.isDescriptorOfType(
value, loop.webapp.ConversationFormView);
}));
});
@ -193,7 +193,7 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isComponentOfType(
return React.addons.TestUtils.isDescriptorOfType(
value, loop.shared.views.ConversationView);
}));
});

View File

@ -20,6 +20,8 @@
<script src="../content/shared/libs/jquery-2.1.0.js"></script>
<script src="../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../content/shared/libs/backbone-1.1.2.js"></script>
<script src="../content/shared/js/feedbackApiClient.js"></script>
<script src="../content/shared/js/utils.js"></script>
<script src="../content/shared/js/models.js"></script>
<script src="../content/shared/js/router.js"></script>
<script src="../content/shared/js/views.js"></script>

View File

@ -45,3 +45,10 @@
.showcase > section .example > h3 {
border-bottom: 1px dashed #aaa;
}
.showcase p.note {
margin: 0;
padding: 0;
color: #666;
font-style: italic;
}

View File

@ -22,6 +22,7 @@
// 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() {
@ -32,6 +33,13 @@
return false;
}
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient({
baseUrl: "https://input.allizom.org/api/v1/feedback",
product: "Loop"
});
var Example = React.createClass({displayName: 'Example',
render: function() {
var cx = React.addons.classSet;
@ -114,6 +122,22 @@
)
),
Section({name: "FeedbackView"},
React.DOM.p({className: "note"},
React.DOM.strong(null, "Note:"), " For the useable demo, you can access submitted data at ",
React.DOM.a({href: "https://input.allizom.org/"}, "input.allizom.org"), "."
),
Example({summary: "Default (useable demo)", dashed: "true", style: {width: "280px"}},
FeedbackView({feedbackApiClient: stageFeedbackApiClient})
),
Example({summary: "Detailed form", dashed: "true", style: {width: "280px"}},
FeedbackView({step: "form"})
),
Example({summary: "Thank you!", dashed: "true", style: {width: "280px"}},
FeedbackView({step: "finished"})
)
),
Section({name: "CallUrlExpiredView"},
Example({summary: "Firefox User"},
CallUrlExpiredView({helper: {isFirefox: returnTrue}})

View File

@ -22,6 +22,7 @@
// 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() {
@ -32,6 +33,13 @@
return false;
}
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient({
baseUrl: "https://input.allizom.org/api/v1/feedback",
product: "Loop"
});
var Example = React.createClass({
render: function() {
var cx = React.addons.classSet;
@ -114,6 +122,22 @@
</Example>
</Section>
<Section name="FeedbackView">
<p className="note">
<strong>Note:</strong> For the useable demo, you can access submitted data at&nbsp;
<a href="https://input.allizom.org/">input.allizom.org</a>.
</p>
<Example summary="Default (useable demo)" dashed="true" style={{width: "280px"}}>
<FeedbackView feedbackApiClient={stageFeedbackApiClient} />
</Example>
<Example summary="Detailed form" dashed="true" style={{width: "280px"}}>
<FeedbackView step="form" />
</Example>
<Example summary="Thank you!" dashed="true" style={{width: "280px"}}>
<FeedbackView step="finished" />
</Example>
</Section>
<Section name="CallUrlExpiredView">
<Example summary="Firefox User">
<CallUrlExpiredView helper={{isFirefox: returnTrue}} />

View File

@ -53,6 +53,14 @@
<popupset>
<menupopup id="context-menu-popup">
</menupopup>
<menupopup id="texteditor-context-popup">
<menuitem id="cMenu_cut"/>
<menuitem id="cMenu_copy"/>
<menuitem id="cMenu_paste"/>
<menuitem id="cMenu_delete"/>
<menuseparator/>
<menuitem id="cMenu_selectAll"/>
</menupopup>
</popupset>
<deck id="main-deck" flex="1">

View File

@ -51,6 +51,7 @@ var ItchEditor = Class({
* ItchEditor.prototype.initialize.apply(this, arguments)
*/
initialize: function(host) {
this.host = host;
this.doc = host.document;
this.label = "";
this.elt = this.doc.createElement("vbox");
@ -165,7 +166,8 @@ var TextEditor = Class({
lineNumbers: true,
extraKeys: this.extraKeys,
themeSwitching: false,
autocomplete: true
autocomplete: true,
contextMenu: this.host.textEditorContextMenuPopup
});
// Trigger a few editor specific events on `this`.

View File

@ -175,6 +175,12 @@ var ProjectEditor = Class({
_buildMenubar: function() {
this.contextMenuPopup = this.document.getElementById("context-menu-popup");
this.contextMenuPopup.addEventListener("popupshowing", this._updateContextMenuItems);
this.textEditorContextMenuPopup = this.document.getElementById("texteditor-context-popup");
this.textEditorContextMenuPopup.addEventListener("popupshowing", this._updateMenuItems);
this.editMenu = this.document.getElementById("edit-menu");
this.fileMenu = this.document.getElementById("file-menu");
@ -191,6 +197,7 @@ var ProjectEditor = Class({
body.appendChild(this.editorCommandset);
body.appendChild(this.editorKeyset);
body.appendChild(this.contextMenuPopup);
body.appendChild(this.textEditorContextMenuPopup);
let index = this.menuindex || 0;
this.menubar.insertBefore(this.editMenu, this.menubar.children[index]);
@ -232,9 +239,6 @@ var ProjectEditor = Class({
this.editorCommandset = this.document.getElementById("editMenuCommands");
this.editorKeyset = this.document.getElementById("editMenuKeys");
this.contextMenuPopup = this.document.getElementById("context-menu-popup");
this.contextMenuPopup.addEventListener("popupshowing", this._updateContextMenuItems);
this.projectEditorCommandset.addEventListener("command", (evt) => {
evt.stopPropagation();
evt.preventDefault();
@ -313,6 +317,7 @@ var ProjectEditor = Class({
this.editorCommandset.remove();
this.editorKeyset.remove();
this.contextMenuPopup.remove();
this.textEditorContextMenuPopup.remove();
this.editMenu.remove();
this.fileMenu.remove();

View File

@ -7,6 +7,8 @@ support-files =
[browser_projecteditor_app_options.js]
skip-if = buildapp == 'mulet'
[browser_projecteditor_contextmenu_01.js]
[browser_projecteditor_contextmenu_02.js]
[browser_projecteditor_delete_file.js]
skip-if = buildapp == 'mulet'
[browser_projecteditor_editing_01.js]

View File

@ -0,0 +1,27 @@
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Test that context menus append to the correct document.
let test = asyncTest(function*() {
let projecteditor = yield addProjectEditorTabForTempDirectory({
menubar: false
});
ok(projecteditor, "ProjectEditor has loaded");
let contextMenuPopup = projecteditor.document.querySelector("#context-menu-popup");
let textEditorContextMenuPopup = projecteditor.document.querySelector("#texteditor-context-popup");
ok (contextMenuPopup, "The menu has loaded in the projecteditor document");
ok (textEditorContextMenuPopup, "The menu has loaded in the projecteditor document");
let projecteditor2 = yield addProjectEditorTabForTempDirectory();
let contextMenuPopup = projecteditor2.document.getElementById("context-menu-popup");
let textEditorContextMenuPopup = projecteditor2.document.getElementById("texteditor-context-popup");
ok (!contextMenuPopup, "The menu has NOT loaded in the projecteditor document");
ok (!textEditorContextMenuPopup, "The menu has NOT loaded in the projecteditor document");
ok (content.document.querySelector("#context-menu-popup"), "The menu has loaded in the specified element");
ok (content.document.querySelector("#texteditor-context-popup"), "The menu has loaded in the specified element");
});

View File

@ -0,0 +1,62 @@
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
loadHelperScript("helper_edits.js");
// Test context menu enabled / disabled state in editor
let test = asyncTest(function*() {
let projecteditor = yield addProjectEditorTabForTempDirectory();
ok (projecteditor, "ProjectEditor has loaded");
let {textEditorContextMenuPopup} = projecteditor;
let cmdDelete = textEditorContextMenuPopup.querySelector("[command=cmd_delete]");
let cmdSelectAll = textEditorContextMenuPopup.querySelector("[command=cmd_selectAll]");
let cmdCut = textEditorContextMenuPopup.querySelector("[command=cmd_cut]");
let cmdCopy = textEditorContextMenuPopup.querySelector("[command=cmd_copy]");
let cmdPaste = textEditorContextMenuPopup.querySelector("[command=cmd_paste]");
info ("Opening resource");
let resource = projecteditor.project.allResources()[2];
yield selectFile(projecteditor, resource);
let editor = projecteditor.currentEditor;
editor.editor.focus();
info ("Opening context menu on resource");
yield openContextMenuForEditor(editor, textEditorContextMenuPopup);
is (cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled");
is (cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled");
is (cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled");
is (cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled");
is (cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled");
info ("Setting a selection and repening context menu on resource");
yield closeContextMenuForEditor(editor, textEditorContextMenuPopup);
editor.editor.setSelection({line: 0, ch: 0}, {line: 0, ch: 2});
yield openContextMenuForEditor(editor, textEditorContextMenuPopup);
is (cmdDelete.getAttribute("disabled"), "", "cmdDelete is enabled");
is (cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled");
is (cmdCut.getAttribute("disabled"), "", "cmdCut is enabled");
is (cmdCopy.getAttribute("disabled"), "", "cmdCopy is enabled");
is (cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled");
});
function openContextMenuForEditor(editor, contextMenu) {
let editorDoc = editor.editor.container.contentDocument;
let shown = onPopupShow(contextMenu);
EventUtils.synthesizeMouse(editorDoc.body, 2, 2,
{type: "contextmenu", button: 2}, editorDoc.defaultView);
yield shown;
}
function closeContextMenuForEditor(editor, contextMenu) {
let editorDoc = editor.editor.container.contentDocument;
let hidden = onPopupHidden(contextMenu);
contextMenu.hidePopup();
yield hidden;
}

View File

@ -82,7 +82,7 @@ let test = asyncTest(function*() {
let editor = projecteditor.currentEditor;
editor.editor.focus();
EventUtils.synthesizeKey("foo", { }, projecteditor.window);
EventUtils.synthesizeKey("f", { }, projecteditor.window);
yield openAndCloseMenu(fileMenu);
yield openAndCloseMenu(editMenu);
@ -99,10 +99,10 @@ let test = asyncTest(function*() {
});
function openAndCloseMenu(menu) {
let shown = onPopupShow(menu)
let shown = onPopupShow(menu);
EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView);
yield shown;
let hidden = onPopupHidden(menu)
let hidden = onPopupHidden(menu);
EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView);
yield hidden;
}

View File

@ -131,8 +131,8 @@ Editor.modes = {
* properties go to CodeMirror's documentation (see below).
*
* Other than that, it accepts one additional and optional
* property contextMenu. This property should be an ID of
* an element we can use as a context menu.
* property contextMenu. This property should be an element, or
* an ID of an element that we can use as a context menu.
*
* This object is also an event emitter.
*
@ -286,7 +286,9 @@ Editor.prototype = {
cm.getWrapperElement().addEventListener("contextmenu", (ev) => {
ev.preventDefault();
if (!this.config.contextMenu) return;
let popup = el.ownerDocument.getElementById(this.config.contextMenu);
let popup = this.config.contextMenu;
if (typeof popup == "string")
popup = el.ownerDocument.getElementById(this.config.contextMenu);
popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
}, false);

View File

@ -67,3 +67,4 @@ skip-if = os == "linux" || "mac" # bug 949355
[browser_styleeditor_selectstylesheet.js]
[browser_styleeditor_sourcemaps.js]
[browser_styleeditor_sourcemap_watching.js]
[browser_styleeditor_transition_rule.js]

View File

@ -0,0 +1,48 @@
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html";
waitForExplicitFinish();
const NEW_RULE = "body { background-color: purple; }";
let test = asyncTest(function*() {
let {UI} = yield addTabAndOpenStyleEditors(2, null, TESTCASE_URI);
is(UI.editors.length, 2, "correct number of editors");
let editor = UI.editors[0];
yield openEditor(editor);
// Set text twice in a row
let styleChanges = listenForStyleChange(editor.styleSheet);
editor.sourceEditor.setText(NEW_RULE);
editor.sourceEditor.setText(NEW_RULE + " ");
yield styleChanges;
let sheet = content.document.styleSheets[0];
// Test that we removed the transition rule, but kept the rule we added
is(sheet.cssRules.length, 1, "only one rule in stylesheet");
is(sheet.cssRules[0].cssText, NEW_RULE,
"stylesheet only contains rule we added");
});
/* Helpers */
function openEditor(editor) {
let link = editor.summary.querySelector(".stylesheet-name");
link.click();
return editor.getSourceEditor();
}
function listenForStyleChange(sheet) {
let deferred = promise.defer();
sheet.on("style-applied", deferred.resolve);
return deferred.promise;
}

View File

@ -28,14 +28,29 @@ unmute_local_video_button_title=Unmute your video
peer_ended_conversation=Your peer ended the conversation.
call_has_ended=Your call has ended.
close_window=Close this window
cannot_start_call_session_not_ready=Can't start call, session is not ready.
network_disconnected=The network connection terminated abruptly.
connection_error_see_console_notification=Call failed; see console for details.
## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the
## part between {{..}}
legal_text_and_links=By using this product you agree to the <a \
target="_blank" href="{{terms_of_use_url}}">Terms of Use</a> and <a \
href="{{privacy_notice_url}}">Privacy Notice</a>.
## LOCALIZATION NOTE (legal_text_and_links2): In this item, don't translate the
## parts between {{..}} because these will be replaced with links with the labels
## from legal_text_tos and legal_text_privacy.
legal_text_and_links2=By using this product you agree to the {{terms_of_use}} \
and {{privacy_notice}}.
legal_text_tos = Terms of Use
legal_text_privacy = Privacy Notice
feedback_call_experience_heading=How was your call experience?
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_submit_button=Submit
feedback_back_button=Back
## LOCALIZATION NOTE (feedback_window_will_close_in): In this item, don't
## translate the part between {{..}}
feedback_window_will_close_in=This window will close in {{countdown}} seconds

View File

@ -981,21 +981,24 @@ class Mochitest(MochitestUtilsMixin):
vmwareHelper = None
DEFAULT_TIMEOUT = 60.0
mediaDevices = None
structured_logger = None
# XXX use automation.py for test name to avoid breaking legacy
# TODO: replace this with 'runtests.py' or 'mochitest' or the like
test_name = 'automation.py'
def __init__(self):
# Structured logger
if self.structured_logger is None:
self.structured_logger = StructuredLogger('mochitest')
stream_handler = StreamHandler(stream=sys.stdout, formatter=MochitestFormatter())
self.structured_logger.add_handler(stream_handler)
Mochitest.structured_logger = self.structured_logger
super(Mochitest, self).__init__()
# Structured logger
structured_log = StructuredLogger('mochitest')
stream_handler = StreamHandler(stream=sys.stdout, formatter=MochitestFormatter())
structured_log.add_handler(stream_handler)
# Structured logs parser
self.message_logger = MessageLogger(logger=structured_log)
self.message_logger = MessageLogger(logger=self.structured_logger)
# environment function for browserEnv
self.environment = environment

View File

@ -18,7 +18,9 @@ import argparse
class DMCli(object):
def __init__(self):
self.commands = { 'install': { 'function': self.install,
self.commands = { 'deviceroot': { 'function': self.deviceroot,
'help': 'get device root directory for storing temporary files' },
'install': { 'function': self.install,
'args': [ { 'name': 'file' } ],
'help': 'push this package file to the device and install it' },
'uninstall': { 'function': self.uninstall,
@ -219,6 +221,9 @@ class DMCli(object):
else:
self.parser.error("Unknown device manager type: %s" % type)
def deviceroot(self, args):
return self.dm.deviceRoot
def push(self, args):
(src, dest) = (args.local_file, args.remote_file)
if os.path.isdir(src):

View File

@ -2070,6 +2070,10 @@ Requisition.prototype.exec = function(options) {
}
}
if (data != null && typeof data === 'string') {
data = data.replace(/^Protocol error: /, ''); // Temp fix for bug 1035296
}
data = (data != null && data.isTypedData) ? data : {
isTypedData: true,
data: data,

View File

@ -24,8 +24,9 @@ loader.lazyGetter(this, "CssLogic", () => require("devtools/styleinspector/css-l
let TRANSITION_CLASS = "moz-styleeditor-transitioning";
let TRANSITION_DURATION_MS = 500;
let TRANSITION_BUFFER_MS = 1000;
let TRANSITION_RULE = "\
:root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\
let TRANSITION_RULE_SELECTOR =
".moz-styleeditor-transitioning:root, .moz-styleeditor-transitioning:root *";
let TRANSITION_RULE = TRANSITION_RULE_SELECTOR + " {\
transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \
transition-delay: 0ms !important;\
transition-timing-function: ease-out !important;\
@ -907,20 +908,16 @@ let StyleSheetActor = protocol.ActorClass({
* to remove the rule after a certain time.
*/
_insertTransistionRule: function() {
// Insert the global transition rule
// Use a ref count to make sure we do not add it multiple times.. and remove
// it only when all pending StyleSheets-generated transitions ended.
if (this._transitionRefCount == 0) {
this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length);
this.document.documentElement.classList.add(TRANSITION_CLASS);
}
this.document.documentElement.classList.add(TRANSITION_CLASS);
this._transitionRefCount++;
// We always add the rule since we've just reset all the rules
this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length);
// Set up clean up and commit after transition duration (+buffer)
// @see _onTransitionEnd
this.window.setTimeout(this._onTransitionEnd.bind(this),
TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS);
this.window.clearTimeout(this._transitionTimeout);
this._transitionTimeout = this.window.setTimeout(this._onTransitionEnd.bind(this),
TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS);
},
/**
@ -929,9 +926,12 @@ let StyleSheetActor = protocol.ActorClass({
*/
_onTransitionEnd: function()
{
if (--this._transitionRefCount == 0) {
this.document.documentElement.classList.remove(TRANSITION_CLASS);
this.rawSheet.deleteRule(this.rawSheet.cssRules.length - 1);
this.document.documentElement.classList.remove(TRANSITION_CLASS);
let index = this.rawSheet.cssRules.length - 1;
let rule = this.rawSheet.cssRules[index];
if (rule.selectorText == TRANSITION_RULE_SELECTOR) {
this.rawSheet.deleteRule(index);
}
events.emit(this, "style-applied");

View File

@ -1112,7 +1112,7 @@ let Front = Class({
let message = (packet.error == "unknownError" && packet.message) ?
"Protocol error: " + packet.message :
packet.error;
deferred.reject(packet.error);
deferred.reject(message);
} else {
deferred.resolve(packet);
}