This commit is contained in:
Wes Kocher 2014-11-24 16:28:02 -08:00
Родитель fb8d05cd59 924f4b21d3
Коммит a98bb652c6
72 изменённых файлов: 2032 добавлений и 1159 удалений

Просмотреть файл

@ -1669,6 +1669,7 @@ pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
pref("loop.rooms.enabled", true);
pref("loop.fxa_oauth.tokendata", "");
pref("loop.fxa_oauth.profile", "");
pref("loop.support_url", "https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc");
// serverURL to be assigned by services team
pref("services.push.serverURL", "wss://push.services.mozilla.com/");

Просмотреть файл

@ -12,6 +12,22 @@ function parseQueryString() {
document.title = parseQueryString();
addEventListener("DOMContentLoaded", () => {
let tryAgain = document.getElementById("tryAgain");
let sendCrashReport = document.getElementById("checkSendReport");
tryAgain.addEventListener("click", () => {
let event = new CustomEvent("AboutTabCrashedTryAgain", {
bubbles: true,
detail: {
sendCrashReport: sendCrashReport.checked,
},
});
document.dispatchEvent(event);
});
});
// Error pages are loaded as LOAD_BACKGROUND, so they don't get load events.
var event = new CustomEvent("AboutTabCrashedLoad", {bubbles:true});
document.dispatchEvent(event);

Просмотреть файл

@ -1142,6 +1142,28 @@ var gBrowserInit = {
#endif
}, false, true);
gBrowser.addEventListener("AboutTabCrashedTryAgain", function(event) {
let ownerDoc = event.originalTarget;
if (!ownerDoc.documentURI.startsWith("about:tabcrashed")) {
return;
}
let isTopFrame = (ownerDoc.defaultView.parent === ownerDoc.defaultView);
if (!isTopFrame) {
return;
}
let browser = gBrowser.getBrowserForDocument(ownerDoc);
#ifdef MOZ_CRASHREPORTER
if (event.detail.sendCrashReport) {
TabCrashReporter.submitCrashReport(browser);
}
#endif
let tab = gBrowser.getTabForBrowser(browser);
SessionStore.reviveCrashedTab(tab);
}, false, true);
if (uriToLoad && uriToLoad != "about:blank") {
if (uriToLoad instanceof Ci.nsISupportsArray) {
let count = uriToLoad.Count();
@ -2606,9 +2628,6 @@ let BrowserOnClick = {
ownerDoc.documentURI.toLowerCase() == "about:newtab") {
this.onE10sAboutNewTab(event, ownerDoc);
}
else if (ownerDoc.documentURI.startsWith("about:tabcrashed")) {
this.onAboutTabCrashed(event, ownerDoc);
}
},
receiveMessage: function (msg) {
@ -2869,29 +2888,6 @@ let BrowserOnClick = {
}
},
/**
* The about:tabcrashed can't do window.reload() because that
* would reload the page but not use a remote browser.
*/
onAboutTabCrashed: function(event, ownerDoc) {
let isTopFrame = (ownerDoc.defaultView.parent === ownerDoc.defaultView);
if (!isTopFrame) {
return;
}
let button = event.originalTarget;
if (button.id == "tryAgain") {
let browser = gBrowser.getBrowserForDocument(ownerDoc);
#ifdef MOZ_CRASHREPORTER
if (ownerDoc.getElementById("checkSendReport").checked) {
TabCrashReporter.submitCrashReport(browser);
}
#endif
let tab = gBrowser.getTabForBrowser(browser);
SessionStore.reviveCrashedTab(tab);
}
},
ignoreWarningButton: function (isMalware) {
// Allow users to override and continue through to the site,
// but add a notify bar as a reminder, so that they don't lose

Просмотреть файл

@ -38,6 +38,8 @@
<script type="text/javascript" src="loop/shared/js/roomStore.js"></script>
<script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
<script type="text/javascript" src="loop/shared/js/activeRoomStore.js"></script>
<script type="text/javascript" src="loop/shared/js/feedbackStore.js"></script>
<script type="text/javascript" src="loop/shared/js/feedbackViews.js"></script>
<script type="text/javascript" src="loop/js/conversationViews.js"></script>
<script type="text/javascript" src="loop/shared/js/websocket.js"></script>
<script type="text/javascript" src="loop/js/conversationAppStore.js"></script>

Просмотреть файл

@ -229,7 +229,8 @@ loop.conversation = (function(mozL10n) {
.isRequired,
sdk: React.PropTypes.object.isRequired,
conversationAppStore: React.PropTypes.instanceOf(
loop.store.ConversationAppStore).isRequired
loop.store.ConversationAppStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -301,21 +302,9 @@ loop.conversation = (function(mozL10n) {
document.title = mozL10n.get("conversation_has_ended");
var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
sharedViews.FeedbackView({
feedbackApiClient: feedbackClient,
feedbackStore: this.props.feedbackStore,
onAfterFeedbackReceived: this.closeWindow.bind(this)}
)
);
@ -562,7 +551,8 @@ loop.conversation = (function(mozL10n) {
conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore)
.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -590,26 +580,26 @@ loop.conversation = (function(mozL10n) {
client: this.props.client,
conversation: this.props.conversation,
sdk: this.props.sdk,
conversationAppStore: this.props.conversationAppStore}
conversationAppStore: this.props.conversationAppStore,
feedbackStore: this.props.feedbackStore}
));
}
case "outgoing": {
return (OutgoingConversationView({
store: this.props.conversationStore,
dispatcher: this.props.dispatcher}
dispatcher: this.props.dispatcher,
feedbackStore: this.props.feedbackStore}
));
}
case "room": {
return (DesktopRoomConversationView({
dispatcher: this.props.dispatcher,
roomStore: this.props.roomStore,
dispatcher: this.props.dispatcher}
feedbackStore: this.props.feedbackStore}
));
}
case "failed": {
return (GenericFailureView({
cancelCall: this.closeWindow}
));
return GenericFailureView({cancelCall: this.closeWindow});
}
default: {
// If we don't have a windowType, we don't know what we are yet,
@ -646,6 +636,14 @@ loop.conversation = (function(mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(
navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
// Create the stores.
var conversationAppStore = new loop.store.ConversationAppStore({
@ -665,6 +663,9 @@ loop.conversation = (function(mozL10n) {
mozLoop: navigator.mozLoop,
activeRoomStore: activeRoomStore
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
// XXX Old class creation for the incoming conversation view, whilst
// we transition across (bug 1072323).
@ -697,6 +698,7 @@ loop.conversation = (function(mozL10n) {
React.renderComponent(AppControllerView({
conversationAppStore: conversationAppStore,
roomStore: roomStore,
feedbackStore: feedbackStore,
conversationStore: conversationStore,
client: client,
conversation: conversation,

Просмотреть файл

@ -229,7 +229,8 @@ loop.conversation = (function(mozL10n) {
.isRequired,
sdk: React.PropTypes.object.isRequired,
conversationAppStore: React.PropTypes.instanceOf(
loop.store.ConversationAppStore).isRequired
loop.store.ConversationAppStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -301,21 +302,9 @@ loop.conversation = (function(mozL10n) {
document.title = mozL10n.get("conversation_has_ended");
var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
<sharedViews.FeedbackView
feedbackApiClient={feedbackClient}
feedbackStore={this.props.feedbackStore}
onAfterFeedbackReceived={this.closeWindow.bind(this)}
/>
);
@ -562,7 +551,8 @@ loop.conversation = (function(mozL10n) {
conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore)
.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -591,25 +581,25 @@ loop.conversation = (function(mozL10n) {
conversation={this.props.conversation}
sdk={this.props.sdk}
conversationAppStore={this.props.conversationAppStore}
feedbackStore={this.props.feedbackStore}
/>);
}
case "outgoing": {
return (<OutgoingConversationView
store={this.props.conversationStore}
dispatcher={this.props.dispatcher}
feedbackStore={this.props.feedbackStore}
/>);
}
case "room": {
return (<DesktopRoomConversationView
dispatcher={this.props.dispatcher}
roomStore={this.props.roomStore}
dispatcher={this.props.dispatcher}
feedbackStore={this.props.feedbackStore}
/>);
}
case "failed": {
return (<GenericFailureView
cancelCall={this.closeWindow}
/>);
return <GenericFailureView cancelCall={this.closeWindow} />;
}
default: {
// If we don't have a windowType, we don't know what we are yet,
@ -646,6 +636,14 @@ loop.conversation = (function(mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(
navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
// Create the stores.
var conversationAppStore = new loop.store.ConversationAppStore({
@ -665,6 +663,9 @@ loop.conversation = (function(mozL10n) {
mozLoop: navigator.mozLoop,
activeRoomStore: activeRoomStore
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
// XXX Old class creation for the incoming conversation view, whilst
// we transition across (bug 1072323).
@ -697,6 +698,7 @@ loop.conversation = (function(mozL10n) {
React.renderComponent(<AppControllerView
conversationAppStore={conversationAppStore}
roomStore={roomStore}
feedbackStore={feedbackStore}
conversationStore={conversationStore}
client={client}
conversation={conversation}

Просмотреть файл

@ -356,7 +356,7 @@ loop.conversationViews = (function(mozL10n) {
nameDisplayMode: "off",
videoDisabledDisplayMode: "off"
}
}
};
},
/**
@ -431,7 +431,8 @@ loop.conversationViews = (function(mozL10n) {
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired
loop.store.ConversationStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -462,22 +463,9 @@ loop.conversationViews = (function(mozL10n) {
_renderFeedbackView: function() {
document.title = mozL10n.get("conversation_has_ended");
// XXX Bug 1076754 Feedback view should be redone in the Flux style.
var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
sharedViews.FeedbackView({
feedbackApiClient: feedbackClient,
feedbackStore: this.props.feedbackStore,
onAfterFeedbackReceived: this._closeWindow.bind(this)}
)
);

Просмотреть файл

@ -356,7 +356,7 @@ loop.conversationViews = (function(mozL10n) {
nameDisplayMode: "off",
videoDisabledDisplayMode: "off"
}
}
};
},
/**
@ -431,7 +431,8 @@ loop.conversationViews = (function(mozL10n) {
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired
loop.store.ConversationStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -462,22 +463,9 @@ loop.conversationViews = (function(mozL10n) {
_renderFeedbackView: function() {
document.title = mozL10n.get("conversation_has_ended");
// XXX Bug 1076754 Feedback view should be redone in the Flux style.
var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
<sharedViews.FeedbackView
feedbackApiClient={feedbackClient}
feedbackStore={this.props.feedbackStore}
onAfterFeedbackReceived={this._closeWindow.bind(this)}
/>
);

Просмотреть файл

@ -281,6 +281,13 @@ loop.panel = (function(_, mozL10n) {
}
},
handleHelpEntry: function(event) {
event.preventDefault();
var helloSupportUrl = navigator.mozLoop.getLoopPref('support_url');
window.open(helloSupportUrl);
window.close();
},
_isSignedIn: function() {
return !!navigator.mozLoop.userProfile;
},
@ -318,7 +325,10 @@ loop.panel = (function(_, mozL10n) {
mozL10n.get("settings_menu_item_signin"),
onClick: this.handleClickAuthEntry,
displayed: navigator.mozLoop.fxAEnabled,
icon: this._isSignedIn() ? "signout" : "signin"})
icon: this._isSignedIn() ? "signout" : "signin"}),
SettingsDropdownEntry({label: mozL10n.get("help_label"),
onClick: this.handleHelpEntry,
icon: "help"})
)
)
);

Просмотреть файл

@ -281,6 +281,13 @@ loop.panel = (function(_, mozL10n) {
}
},
handleHelpEntry: function(event) {
event.preventDefault();
var helloSupportUrl = navigator.mozLoop.getLoopPref('support_url');
window.open(helloSupportUrl);
window.close();
},
_isSignedIn: function() {
return !!navigator.mozLoop.userProfile;
},
@ -319,6 +326,9 @@ loop.panel = (function(_, mozL10n) {
onClick={this.handleClickAuthEntry}
displayed={navigator.mozLoop.fxAEnabled}
icon={this._isSignedIn() ? "signout" : "signin"} />
<SettingsDropdownEntry label={mozL10n.get("help_label")}
onClick={this.handleHelpEntry}
icon="help" />
</ul>
</div>
);

Просмотреть файл

@ -706,6 +706,7 @@ html, .fx-embedded, #main,
background: #000;
height: 50px;
text-align: left;
width: 75%;
}
.room-conversation-wrapper header h1 {
@ -717,6 +718,20 @@ html, .fx-embedded, #main,
background-size: 30px;
background-position: 10px;
background-repeat: no-repeat;
display: inline-block;
}
.room-conversation-wrapper header a {
float: right;
}
.room-conversation-wrapper header .icon-help {
display: inline-block;
background-size: contain;
margin-top: 20px;
width: 20px;
height: 20px;
background: transparent url("../img/svg/glyph-help-16x16.svg") no-repeat;
}
.room-conversation-wrapper footer {

Просмотреть файл

@ -659,6 +659,10 @@ body[dir=rtl] .generate-url-spinner {
background: transparent url(../img/svg/glyph-signout-16x16.svg) no-repeat center center;
}
.settings-menu .icon-help {
background: transparent url(../img/svg/glyph-help-16x16.svg) no-repeat center center;
}
/* Footer */
.footer {

Просмотреть файл

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
<circle fill="#5A5A5A" cx="8" cy="8" r="8"/>
<g>
<path fill="#FFFFFF" d="M10.716,5.643c0,1.943-2.158,1.812-2.158,3.154v0.3H6.831V8.726c0-2.075,1.907-1.932,1.907-2.915
c0-0.432-0.312-0.684-0.84-0.684c-0.491,0-0.972,0.24-1.403,0.731L5.284,4.923C5.967,4.121,6.855,3.64,8.09,3.64
C9.841,3.64,10.716,4.576,10.716,5.643z M8.797,11.268c0,0.6-0.479,1.092-1.079,1.092s-1.079-0.492-1.079-1.092
c0-0.588,0.479-1.079,1.079-1.079S8.797,10.68,8.797,11.268z"/>
</g>
</svg>

После

Ширина:  |  Высота:  |  Размер: 1.0 KiB

Просмотреть файл

@ -325,6 +325,28 @@ loop.shared.actions = (function() {
* Used to indicate the user wishes to leave the room.
*/
LeaveRoom: Action.define("leaveRoom", {
}),
/**
* Requires detailed information on sad feedback.
*/
RequireFeedbackDetails: Action.define("requireFeedbackDetails", {
}),
/**
* Send feedback data.
*/
SendFeedback: Action.define("sendFeedback", {
happy: Boolean,
category: String,
description: String
}),
/**
* Reacts on feedback submission error.
*/
SendFeedbackError: Action.define("sendFeedbackError", {
error: Error
})
};
})();

Просмотреть файл

@ -107,8 +107,8 @@ loop.FeedbackAPIClient = (function($, _) {
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));
cb(new Error(message + ": " + httpError + "; " +
(jqXHR.responseJSON && jqXHR.responseJSON.detail || "")));
});
}
};

Просмотреть файл

@ -0,0 +1,98 @@
/* 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.store = loop.store || {};
loop.store.FeedbackStore = (function() {
"use strict";
var sharedActions = loop.shared.actions;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES = {
// Initial state (mood selection)
INIT: "feedback-init",
// User detailed feedback form step
DETAILS: "feedback-details",
// Pending feedback data submission
PENDING: "feedback-pending",
// Feedback has been sent
SENT: "feedback-sent",
// There was an issue with the feedback API
FAILED: "feedback-failed"
};
/**
* Feedback store.
*
* @param {loop.Dispatcher} dispatcher The dispatcher for dispatching actions
* and registering to consume actions.
* @param {Object} options Options object:
* - {mozLoop} mozLoop The MozLoop API object.
* - {feedbackClient} loop.FeedbackAPIClient The feedback API client.
*/
var FeedbackStore = loop.store.createStore({
actions: [
"requireFeedbackDetails",
"sendFeedback",
"sendFeedbackError"
],
initialize: function(options) {
if (!options.feedbackClient) {
throw new Error("Missing option feedbackClient");
}
this._feedbackClient = options.feedbackClient;
},
/**
* Returns initial state data for this active room.
*/
getInitialStoreState: function() {
return {feedbackState: FEEDBACK_STATES.INIT};
},
/**
* Requires user detailed feedback.
*/
requireFeedbackDetails: function() {
this.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
},
/**
* Sends feedback data to the feedback server.
*
* @param {sharedActions.SendFeedback} actionData The action data.
*/
sendFeedback: function(actionData) {
delete actionData.name;
this._feedbackClient.send(actionData, function(err) {
if (err) {
this.dispatchAction(new sharedActions.SendFeedbackError({
error: err
}));
return;
}
this.setStoreState({feedbackState: FEEDBACK_STATES.SENT});
}.bind(this));
this.setStoreState({feedbackState: FEEDBACK_STATES.PENDING});
},
/**
* Notifies a store from any error encountered while sending feedback data.
*
* @param {sharedActions.SendFeedback} actionData The action data.
*/
sendFeedbackError: function(actionData) {
this.setStoreState({
feedbackState: FEEDBACK_STATES.FAILED,
error: actionData.error
});
}
});
return FeedbackStore;
})();

Просмотреть файл

@ -0,0 +1,326 @@
/** @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/. */
/* jshint newcap:false */
/* global loop:true, React */
var loop = loop || {};
loop.shared = loop.shared || {};
loop.shared.views = loop.shared.views || {};
loop.shared.views.FeedbackView = (function(l10n) {
"use strict";
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
/**
* 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: "fx-embedded-btn-back", type: "button",
onClick: this.props.reset},
"« ", l10n.get("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: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
pending: React.PropTypes.bool,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
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"),
confusing: l10n.get("feedback_category_confusing"),
other: l10n.get("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
React.DOM.label({key: key, className: "feedback-category-label"},
React.DOM.input({type: "radio", ref: "category", name: "category",
className: "feedback-category-radio",
value: category,
onChange: this.handleCategoryChange,
checked: this.state.category === category}),
categories[category]
)
);
}, this);
},
/**
* Checks if the form is ready for submission:
*
* - no feedback submission should be pending.
* - a category (reason) must be chosen;
* - if the "other" category is chosen, a custom description must have been
* entered by the end user;
*
* @return {Boolean}
*/
_isFormReady: function() {
if (this.props.pending || !this.state.category) {
return false;
}
if (this.state.category === "other" && !this.state.description) {
return false;
}
return true;
},
handleCategoryChange: function(event) {
var category = event.target.value;
this.setState({
category: category,
description: category == "other" ? "" : this._getCategories()[category]
});
if (category == "other") {
this.refs.description.getDOMNode().focus();
}
},
handleDescriptionFieldChange: function(event) {
this.setState({description: event.target.value});
},
handleDescriptionFieldFocus: function(event) {
this.setState({category: "other", description: ""});
},
handleFormSubmit: function(event) {
event.preventDefault();
// XXX this feels ugly, we really want a feedbackActions object here.
this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
}));
},
render: function() {
var descriptionDisplayValue = this.state.category === "other" ?
this.state.description : "";
return (
FeedbackLayout({title: l10n.get("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",
className: "feedback-description",
onChange: this.handleDescriptionFieldChange,
onFocus: this.handleDescriptionFieldFocus,
value: descriptionDisplayValue,
placeholder:
l10n.get("feedback_custom_category_text_placeholder")})
),
React.DOM.button({type: "submit", className: "btn btn-success",
disabled: !this._isFormReady()},
l10n.get("feedback_submit_button")
)
)
)
);
}
});
/**
* 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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
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
}))
)
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
mixins: [Backbone.Events, sharedMixins.AudioMixin],
propTypes: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
onAfterFeedbackReceived: React.PropTypes.func,
// Used by the UI showcase.
feedbackState: React.PropTypes.string
},
getInitialState: function() {
var storeState = this.props.feedbackStore.getStoreState();
return _.extend({}, storeState, {
feedbackState: this.props.feedbackState || storeState.feedbackState
});
},
componentWillMount: function() {
this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
},
componentDidMount: function() {
this.play("terminated");
},
componentWillUnmount: function() {
this.stopListening(this.props.feedbackStore);
},
_onStoreStateChanged: function() {
this.setState(this.props.feedbackStore.getStoreState());
},
reset: function() {
this.setState(this.props.feedbackStore.getInitialStoreState());
},
handleHappyClick: function() {
// XXX: If the user is happy, we directly send this information to the
// feedback API; this is a behavior we might want to revisit later.
this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
happy: true,
category: "",
description: ""
}));
},
handleSadClick: function() {
this.props.feedbackStore.dispatchAction(
new sharedActions.RequireFeedbackDetails());
},
_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.feedbackState) {
default:
case FEEDBACK_STATES.INIT: {
return (
FeedbackLayout({title:
l10n.get("feedback_call_experience_heading2")},
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})
)
)
);
}
case FEEDBACK_STATES.DETAILS: {
return (
FeedbackForm({
feedbackStore: this.props.feedbackStore,
reset: this.reset,
pending: this.state.feedbackState === FEEDBACK_STATES.PENDING})
);
}
case FEEDBACK_STATES.PENDING:
case FEEDBACK_STATES.SENT:
case FEEDBACK_STATES.FAILED: {
if (this.state.error) {
// XXX better end user error reporting, see bug 1046738
console.error("Error encountered while submitting feedback",
this.state.error);
}
return (
FeedbackReceived({
onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
);
}
}
}
});
return FeedbackView;
})(navigator.mozL10n || document.mozL10n);

Просмотреть файл

@ -0,0 +1,326 @@
/** @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/. */
/* jshint newcap:false */
/* global loop:true, React */
var loop = loop || {};
loop.shared = loop.shared || {};
loop.shared.views = loop.shared.views || {};
loop.shared.views.FeedbackView = (function(l10n) {
"use strict";
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
/**
* 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="fx-embedded-btn-back" type="button"
onClick={this.props.reset}>
&laquo;&nbsp;{l10n.get("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: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
pending: React.PropTypes.bool,
reset: React.PropTypes.func
},
getInitialState: function() {
return {category: "", description: ""};
},
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"),
confusing: l10n.get("feedback_category_confusing"),
other: l10n.get("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
<label key={key} className="feedback-category-label">
<input type="radio" ref="category" name="category"
className="feedback-category-radio"
value={category}
onChange={this.handleCategoryChange}
checked={this.state.category === category} />
{categories[category]}
</label>
);
}, this);
},
/**
* Checks if the form is ready for submission:
*
* - no feedback submission should be pending.
* - a category (reason) must be chosen;
* - if the "other" category is chosen, a custom description must have been
* entered by the end user;
*
* @return {Boolean}
*/
_isFormReady: function() {
if (this.props.pending || !this.state.category) {
return false;
}
if (this.state.category === "other" && !this.state.description) {
return false;
}
return true;
},
handleCategoryChange: function(event) {
var category = event.target.value;
this.setState({
category: category,
description: category == "other" ? "" : this._getCategories()[category]
});
if (category == "other") {
this.refs.description.getDOMNode().focus();
}
},
handleDescriptionFieldChange: function(event) {
this.setState({description: event.target.value});
},
handleDescriptionFieldFocus: function(event) {
this.setState({category: "other", description: ""});
},
handleFormSubmit: function(event) {
event.preventDefault();
// XXX this feels ugly, we really want a feedbackActions object here.
this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
}));
},
render: function() {
var descriptionDisplayValue = this.state.category === "other" ?
this.state.description : "";
return (
<FeedbackLayout title={l10n.get("feedback_what_makes_you_sad")}
reset={this.props.reset}>
<form onSubmit={this.handleFormSubmit}>
{this._getCategoryFields()}
<p>
<input type="text" ref="description" name="description"
className="feedback-description"
onChange={this.handleDescriptionFieldChange}
onFocus={this.handleDescriptionFieldFocus}
value={descriptionDisplayValue}
placeholder={
l10n.get("feedback_custom_category_text_placeholder")} />
</p>
<button type="submit" className="btn btn-success"
disabled={!this._isFormReady()}>
{l10n.get("feedback_submit_button")}
</button>
</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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
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>
</FeedbackLayout>
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({
mixins: [Backbone.Events, sharedMixins.AudioMixin],
propTypes: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
onAfterFeedbackReceived: React.PropTypes.func,
// Used by the UI showcase.
feedbackState: React.PropTypes.string
},
getInitialState: function() {
var storeState = this.props.feedbackStore.getStoreState();
return _.extend({}, storeState, {
feedbackState: this.props.feedbackState || storeState.feedbackState
});
},
componentWillMount: function() {
this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
},
componentDidMount: function() {
this.play("terminated");
},
componentWillUnmount: function() {
this.stopListening(this.props.feedbackStore);
},
_onStoreStateChanged: function() {
this.setState(this.props.feedbackStore.getStoreState());
},
reset: function() {
this.setState(this.props.feedbackStore.getInitialStoreState());
},
handleHappyClick: function() {
// XXX: If the user is happy, we directly send this information to the
// feedback API; this is a behavior we might want to revisit later.
this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
happy: true,
category: "",
description: ""
}));
},
handleSadClick: function() {
this.props.feedbackStore.dispatchAction(
new sharedActions.RequireFeedbackDetails());
},
_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.feedbackState) {
default:
case FEEDBACK_STATES.INIT: {
return (
<FeedbackLayout title={
l10n.get("feedback_call_experience_heading2")}>
<div className="faces">
<button className="face face-happy"
onClick={this.handleHappyClick}></button>
<button className="face face-sad"
onClick={this.handleSadClick}></button>
</div>
</FeedbackLayout>
);
}
case FEEDBACK_STATES.DETAILS: {
return (
<FeedbackForm
feedbackStore={this.props.feedbackStore}
reset={this.reset}
pending={this.state.feedbackState === FEEDBACK_STATES.PENDING} />
);
}
case FEEDBACK_STATES.PENDING:
case FEEDBACK_STATES.SENT:
case FEEDBACK_STATES.FAILED: {
if (this.state.error) {
// XXX better end user error reporting, see bug 1046738
console.error("Error encountered while submitting feedback",
this.state.error);
}
return (
<FeedbackReceived
onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
);
}
}
}
});
return FeedbackView;
})(navigator.mozL10n || document.mozL10n);

Просмотреть файл

@ -14,8 +14,6 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* Media control button.
*
@ -345,287 +343,6 @@ 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: "fx-embedded-btn-back", type: "button",
onClick: this.props.reset},
"« ", l10n.get("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: ""};
},
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"),
confusing: l10n.get("feedback_category_confusing"),
other: l10n.get("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
React.DOM.label({key: key, className: "feedback-category-label"},
React.DOM.input({type: "radio", ref: "category", name: "category",
className: "feedback-category-radio",
value: category,
onChange: this.handleCategoryChange,
checked: this.state.category === category}),
categories[category]
)
);
}, this);
},
/**
* Checks if the form is ready for submission:
*
* - no feedback submission should be pending.
* - a category (reason) must be chosen;
* - if the "other" category is chosen, a custom description must have been
* entered by the end user;
*
* @return {Boolean}
*/
_isFormReady: function() {
if (this.props.pending || !this.state.category) {
return false;
}
if (this.state.category === "other" && !this.state.description) {
return false;
}
return true;
},
handleCategoryChange: function(event) {
var category = event.target.value;
this.setState({
category: category,
description: category == "other" ? "" : this._getCategories()[category]
});
if (category == "other") {
this.refs.description.getDOMNode().focus();
}
},
handleDescriptionFieldChange: function(event) {
this.setState({description: event.target.value});
},
handleDescriptionFieldFocus: function(event) {
this.setState({category: "other", description: ""});
},
handleFormSubmit: function(event) {
event.preventDefault();
this.props.sendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
});
},
render: function() {
var descriptionDisplayValue = this.state.category === "other" ?
this.state.description : "";
return (
FeedbackLayout({title: l10n.get("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",
className: "feedback-description",
onChange: this.handleDescriptionFieldChange,
onFocus: this.handleDescriptionFieldFocus,
value: descriptionDisplayValue,
placeholder:
l10n.get("feedback_custom_category_text_placeholder")})
),
React.DOM.button({type: "submit", className: "btn btn-success",
disabled: !this._isFormReady()},
l10n.get("feedback_submit_button")
)
)
)
);
}
});
/**
* 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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
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
}))
)
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
mixins: [sharedMixins.AudioMixin],
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"};
},
getDefaultProps: function() {
return {step: "start"};
},
componentDidMount: function() {
this.play("terminated");
},
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({
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:
l10n.get("feedback_call_experience_heading2")},
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.
*/
@ -743,7 +460,7 @@ loop.shared.views = (function(_, OT, l10n) {
React.DOM.span({className: "button-caption"}, this.props.caption),
this.props.children
)
)
);
}
});
@ -768,7 +485,7 @@ loop.shared.views = (function(_, OT, l10n) {
React.DOM.div({className: cx(classObject)},
this.props.children
)
)
);
}
});
@ -777,7 +494,6 @@ loop.shared.views = (function(_, OT, l10n) {
ButtonGroup: ButtonGroup,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView
};

Просмотреть файл

@ -14,8 +14,6 @@ loop.shared.views = (function(_, OT, l10n) {
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
/**
* Media control button.
*
@ -345,287 +343,6 @@ 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="fx-embedded-btn-back" type="button"
onClick={this.props.reset}>
&laquo;&nbsp;{l10n.get("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: ""};
},
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"),
confusing: l10n.get("feedback_category_confusing"),
other: l10n.get("feedback_category_other")
};
},
_getCategoryFields: function() {
var categories = this._getCategories();
return Object.keys(categories).map(function(category, key) {
return (
<label key={key} className="feedback-category-label">
<input type="radio" ref="category" name="category"
className="feedback-category-radio"
value={category}
onChange={this.handleCategoryChange}
checked={this.state.category === category} />
{categories[category]}
</label>
);
}, this);
},
/**
* Checks if the form is ready for submission:
*
* - no feedback submission should be pending.
* - a category (reason) must be chosen;
* - if the "other" category is chosen, a custom description must have been
* entered by the end user;
*
* @return {Boolean}
*/
_isFormReady: function() {
if (this.props.pending || !this.state.category) {
return false;
}
if (this.state.category === "other" && !this.state.description) {
return false;
}
return true;
},
handleCategoryChange: function(event) {
var category = event.target.value;
this.setState({
category: category,
description: category == "other" ? "" : this._getCategories()[category]
});
if (category == "other") {
this.refs.description.getDOMNode().focus();
}
},
handleDescriptionFieldChange: function(event) {
this.setState({description: event.target.value});
},
handleDescriptionFieldFocus: function(event) {
this.setState({category: "other", description: ""});
},
handleFormSubmit: function(event) {
event.preventDefault();
this.props.sendFeedback({
happy: false,
category: this.state.category,
description: this.state.description
});
},
render: function() {
var descriptionDisplayValue = this.state.category === "other" ?
this.state.description : "";
return (
<FeedbackLayout title={l10n.get("feedback_what_makes_you_sad")}
reset={this.props.reset}>
<form onSubmit={this.handleFormSubmit}>
{this._getCategoryFields()}
<p>
<input type="text" ref="description" name="description"
className="feedback-description"
onChange={this.handleDescriptionFieldChange}
onFocus={this.handleDescriptionFieldFocus}
value={descriptionDisplayValue}
placeholder={
l10n.get("feedback_custom_category_text_placeholder")} />
</p>
<button type="submit" className="btn btn-success"
disabled={!this._isFormReady()}>
{l10n.get("feedback_submit_button")}
</button>
</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);
},
componentWillUnmount: function() {
if (this._timer) {
clearInterval(this._timer);
}
},
render: function() {
if (this.state.countdown < 1) {
clearInterval(this._timer);
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>
</FeedbackLayout>
);
}
});
/**
* Feedback view.
*/
var FeedbackView = React.createClass({
mixins: [sharedMixins.AudioMixin],
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"};
},
getDefaultProps: function() {
return {step: "start"};
},
componentDidMount: function() {
this.play("terminated");
},
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
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={
l10n.get("feedback_call_experience_heading2")}>
<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.
*/
@ -743,7 +460,7 @@ loop.shared.views = (function(_, OT, l10n) {
<span className="button-caption">{this.props.caption}</span>
{this.props.children}
</button>
)
);
}
});
@ -768,7 +485,7 @@ loop.shared.views = (function(_, OT, l10n) {
<div className={cx(classObject)}>
{this.props.children}
</div>
)
);
}
});
@ -777,7 +494,6 @@ loop.shared.views = (function(_, OT, l10n) {
ButtonGroup: ButtonGroup,
ConversationView: ConversationView,
ConversationToolbar: ConversationToolbar,
FeedbackView: FeedbackView,
MediaControlButton: MediaControlButton,
NotificationListView: NotificationListView
};

Просмотреть файл

@ -50,6 +50,7 @@ browser.jar:
content/browser/loop/shared/img/svg/glyph-account-16x16.svg (content/shared/img/svg/glyph-account-16x16.svg)
content/browser/loop/shared/img/svg/glyph-signin-16x16.svg (content/shared/img/svg/glyph-signin-16x16.svg)
content/browser/loop/shared/img/svg/glyph-signout-16x16.svg (content/shared/img/svg/glyph-signout-16x16.svg)
content/browser/loop/shared/img/svg/glyph-help-16x16.svg (content/shared/img/svg/glyph-help-16x16.svg)
content/browser/loop/shared/img/audio-call-avatar.svg (content/shared/img/audio-call-avatar.svg)
content/browser/loop/shared/img/beta-ribbon.svg (content/shared/img/beta-ribbon.svg)
content/browser/loop/shared/img/icons-10x10.svg (content/shared/img/icons-10x10.svg)
@ -70,12 +71,14 @@ browser.jar:
content/browser/loop/shared/js/store.js (content/shared/js/store.js)
content/browser/loop/shared/js/roomStore.js (content/shared/js/roomStore.js)
content/browser/loop/shared/js/activeRoomStore.js (content/shared/js/activeRoomStore.js)
content/browser/loop/shared/js/feedbackStore.js (content/shared/js/feedbackStore.js)
content/browser/loop/shared/js/dispatcher.js (content/shared/js/dispatcher.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/mixins.js (content/shared/js/mixins.js)
content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/feedbackViews.js (content/shared/js/feedbackViews.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
content/browser/loop/shared/js/validate.js (content/shared/js/validate.js)
content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js)

Просмотреть файл

@ -84,3 +84,5 @@ config:
@echo "loop.config.fxosApp = loop.config.fxosApp || {};" >> content/config.js
@echo "loop.config.fxosApp.name = 'Loop';" >> content/config.js
@echo "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';" >> content/config.js
@echo "loop.config.roomsSupportUrl = 'https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc';" >> content/config.js
@echo "loop.config.guestSupportUrl = 'https://support.mozilla.org/kb/respond-firefox-hello-invitation-guest-mode';" >> content/config.js

Просмотреть файл

@ -87,15 +87,19 @@ body,
color: #777;
}
.footer-external-links a {
.footer-external-links {
padding: .2rem .7rem;
margin: 0 .5rem;
text-decoration: none;
}
.footer-external-links a:hover {
color: #111;
}
.footer-external-links a {
margin: 0 .5rem;
text-decoration: none;
color: #adadad;
}
.footer-external-links a:hover {
color: #777;
}
.footer-logo {
width: 100px;

Просмотреть файл

@ -99,6 +99,8 @@
<script type="text/javascript" src="shared/js/otSdkDriver.js"></script>
<script type="text/javascript" src="shared/js/store.js"></script>
<script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
<script type="text/javascript" src="shared/js/feedbackStore.js"></script>
<script type="text/javascript" src="shared/js/feedbackViews.js"></script>
<script type="text/javascript" src="js/standaloneAppStore.js"></script>
<script type="text/javascript" src="js/standaloneClient.js"></script>
<script type="text/javascript" src="js/standaloneMozLoop.js"></script>

Просмотреть файл

@ -107,7 +107,10 @@ loop.standaloneRoomViews = (function(mozL10n) {
render: function() {
return (
React.DOM.header(null,
React.DOM.h1(null, mozL10n.get("clientShortname2"))
React.DOM.h1(null, mozL10n.get("clientShortname2")),
React.DOM.a({target: "_blank", href: loop.config.roomsSupportUrl},
React.DOM.i({className: "icon icon-help"})
)
)
);
}

Просмотреть файл

@ -108,6 +108,9 @@ loop.standaloneRoomViews = (function(mozL10n) {
return (
<header>
<h1>{mozL10n.get("clientShortname2")}</h1>
<a target="_blank" href={loop.config.roomsSupportUrl}>
<i className="icon icon-help"></i>
</a>
</header>
);
}

Просмотреть файл

@ -259,7 +259,12 @@ loop.webapp = (function($, _, OT, mozL10n) {
React.DOM.div({className: "standalone-footer container-box"},
React.DOM.div({title: mozL10n.get("vendor_alttext",
{vendorShortname: mozL10n.get("vendorShortname")}),
className: "footer-logo"})
className: "footer-logo"}),
React.DOM.div({className: "footer-external-links"},
React.DOM.a({target: "_blank", href: loop.config.guestSupportUrl},
mozL10n.get("support_link")
)
)
)
);
}
@ -538,7 +543,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
onAfterFeedbackReceived: React.PropTypes.func.isRequired
},
@ -549,7 +554,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return (
React.DOM.div({className: "ended-conversation"},
sharedViews.FeedbackView({
feedbackApiClient: this.props.feedbackApiClient,
feedbackStore: this.props.feedbackStore,
onAfterFeedbackReceived: this.props.onAfterFeedbackReceived}
),
sharedViews.ConversationView({
@ -611,7 +616,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -690,7 +695,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
EndedConversationView({
sdk: this.props.sdk,
conversation: this.props.conversation,
feedbackApiClient: this.props.feedbackApiClient,
feedbackStore: this.props.feedbackStore,
onAfterFeedbackReceived: this.callStatusSwitcher("start")}
)
);
@ -887,14 +892,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired,
// XXX New types for flux style
standaloneAppStore: React.PropTypes.instanceOf(
loop.store.StandaloneAppStore).isRequired,
activeRoomStore: React.PropTypes.instanceOf(
loop.store.ActiveRoomStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -931,7 +936,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper: this.props.helper,
notifications: this.props.notifications,
sdk: this.props.sdk,
feedbackApiClient: this.props.feedbackApiClient}
feedbackStore: this.props.feedbackStore}
)
);
}
@ -992,7 +997,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
var feedbackClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
user_agent: navigator.userAgent,
url: document.location.origin
});
// Stores
var standaloneAppStore = new loop.store.StandaloneAppStore({
conversation: conversation,
dispatcher: dispatcher,
@ -1003,6 +1015,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
mozLoop: standaloneMozLoop,
sdkDriver: sdkDriver
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
window.addEventListener("unload", function() {
dispatcher.dispatch(new sharedActions.WindowUnload());
@ -1014,7 +1029,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper: helper,
notifications: notifications,
sdk: OT,
feedbackApiClient: feedbackApiClient,
feedbackStore: feedbackStore,
standaloneAppStore: standaloneAppStore,
activeRoomStore: activeRoomStore,
dispatcher: dispatcher}

Просмотреть файл

@ -260,6 +260,11 @@ loop.webapp = (function($, _, OT, mozL10n) {
<div title={mozL10n.get("vendor_alttext",
{vendorShortname: mozL10n.get("vendorShortname")})}
className="footer-logo"></div>
<div className="footer-external-links">
<a target="_blank" href={loop.config.guestSupportUrl}>
{mozL10n.get("support_link")}
</a>
</div>
</div>
);
}
@ -538,7 +543,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
onAfterFeedbackReceived: React.PropTypes.func.isRequired
},
@ -549,7 +554,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return (
<div className="ended-conversation">
<sharedViews.FeedbackView
feedbackApiClient={this.props.feedbackApiClient}
feedbackStore={this.props.feedbackStore}
onAfterFeedbackReceived={this.props.onAfterFeedbackReceived}
/>
<sharedViews.ConversationView
@ -611,7 +616,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -690,7 +695,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
<EndedConversationView
sdk={this.props.sdk}
conversation={this.props.conversation}
feedbackApiClient={this.props.feedbackApiClient}
feedbackStore={this.props.feedbackStore}
onAfterFeedbackReceived={this.callStatusSwitcher("start")}
/>
);
@ -887,14 +892,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired,
// XXX New types for flux style
standaloneAppStore: React.PropTypes.instanceOf(
loop.store.StandaloneAppStore).isRequired,
activeRoomStore: React.PropTypes.instanceOf(
loop.store.ActiveRoomStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
getInitialState: function() {
@ -931,7 +936,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper={this.props.helper}
notifications={this.props.notifications}
sdk={this.props.sdk}
feedbackApiClient={this.props.feedbackApiClient}
feedbackStore={this.props.feedbackStore}
/>
);
}
@ -992,7 +997,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
var feedbackClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
user_agent: navigator.userAgent,
url: document.location.origin
});
// Stores
var standaloneAppStore = new loop.store.StandaloneAppStore({
conversation: conversation,
dispatcher: dispatcher,
@ -1003,6 +1015,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
mozLoop: standaloneMozLoop,
sdkDriver: sdkDriver
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
window.addEventListener("unload", function() {
dispatcher.dispatch(new sharedActions.WindowUnload());
@ -1014,7 +1029,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper={helper}
notifications={notifications}
sdk={OT}
feedbackApiClient={feedbackApiClient}
feedbackStore={feedbackStore}
standaloneAppStore={standaloneAppStore}
activeRoomStore={activeRoomStore}
dispatcher={dispatcher}

Просмотреть файл

@ -124,4 +124,4 @@ standalone_title_with_status={{clientShortname}} — {{currentStatus}}
status_in_conversation=In conversation
status_conversation_ended=Conversation ended
status_error=Something went wrong
support_link=Get Help

Просмотреть файл

@ -30,7 +30,9 @@ function getConfigFile(req, res) {
"loop.config.legalWebsiteUrl = '/legal/terms';",
"loop.config.fxosApp = loop.config.fxosApp || {};",
"loop.config.fxosApp.name = 'Loop';",
"loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';"
"loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';",
"loop.config.roomsSupportUrl = 'https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc';",
"loop.config.guestSupportUrl = 'https://support.mozilla.org/kb/respond-firefox-hello-invitation-guest-mode';"
].join("\n"));
}

Просмотреть файл

@ -445,13 +445,14 @@ describe("loop.conversationViews", function () {
});
describe("OutgoingConversationView", function() {
var store;
var store, feedbackStore;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
loop.conversationViews.OutgoingConversationView({
dispatcher: dispatcher,
store: store
store: store,
feedbackStore: feedbackStore
}));
}
@ -461,6 +462,9 @@ describe("loop.conversationViews", function () {
client: {},
sdkDriver: {}
});
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
});
});
it("should render the CallFailedView when the call state is 'terminated'",

Просмотреть файл

@ -233,7 +233,8 @@ describe("loop.conversation", function() {
});
describe("IncomingConversationView", function() {
var conversationAppStore, conversation, client, icView, oldTitle;
var conversationAppStore, conversation, client, icView, oldTitle,
feedbackStore;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
@ -241,7 +242,8 @@ describe("loop.conversation", function() {
client: client,
conversation: conversation,
sdk: {},
conversationAppStore: conversationAppStore
conversationAppStore: conversationAppStore,
feedbackStore: feedbackStore
}));
}
@ -257,6 +259,9 @@ describe("loop.conversation", function() {
dispatcher: dispatcher,
mozLoop: navigator.mozLoop
});
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
});
sandbox.stub(conversation, "setOutgoingSessionData");
});

Просмотреть файл

@ -46,6 +46,8 @@
<script src="../../content/shared/js/store.js"></script>
<script src="../../content/shared/js/roomStore.js"></script>
<script src="../../content/shared/js/activeRoomStore.js"></script>
<script src="../../content/shared/js/feedbackStore.js"></script>
<script src="../../content/shared/js/feedbackViews.js"></script>
<script src="../../content/js/client.js"></script>
<script src="../../content/js/conversationAppStore.js"></script>
<script src="../../content/js/roomViews.js"></script>

Просмотреть файл

@ -356,6 +356,31 @@ describe("loop.panel", function() {
});
});
describe("Help", function() {
var supportUrl = "https://example.com";
beforeEach(function() {
navigator.mozLoop.getLoopPref = function(pref) {
if (pref === "support_url")
return supportUrl;
return "unseen";
};
sandbox.stub(window, "open");
sandbox.stub(window, "close");
});
it("should open a tab to the support page", function() {
var view = TestUtils.renderIntoDocument(loop.panel.SettingsDropdown());
TestUtils.Simulate
.click(view.getDOMNode().querySelector(".icon-help"));
sinon.assert.calledOnce(window.open);
sinon.assert.calledWithExactly(window.open, supportUrl);
});
});
describe("#render", function() {
it("should render a ToSView", function() {
var view = createTestPanelView();

Просмотреть файл

@ -0,0 +1,108 @@
/* global chai, loop */
var expect = chai.expect;
var sharedActions = loop.shared.actions;
describe("loop.store.FeedbackStore", function () {
"use strict";
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
var sandbox, dispatcher, store, feedbackClient;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
feedbackClient = new loop.FeedbackAPIClient("http://invalid", {
product: "Loop"
});
store = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
});
afterEach(function() {
sandbox.restore();
});
describe("#constructor", function() {
it("should throw an error if feedbackClient is missing", function() {
expect(function() {
new loop.store.FeedbackStore(dispatcher);
}).to.Throw(/feedbackClient/);
});
it("should set the store to the INIT feedback state", function() {
var store = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.INIT);
});
});
describe("#requireFeedbackDetails", function() {
it("should transition to DETAILS state", function() {
store.requireFeedbackDetails(new sharedActions.RequireFeedbackDetails());
expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.DETAILS);
});
});
describe("#sendFeedback", function() {
var sadFeedbackData = {
happy: false,
category: "fakeCategory",
description: "fakeDescription"
};
beforeEach(function() {
store.requireFeedbackDetails();
});
it("should send feedback data over the feedback client", function() {
sandbox.stub(feedbackClient, "send");
store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
sinon.assert.calledOnce(feedbackClient.send);
sinon.assert.calledWithMatch(feedbackClient.send, sadFeedbackData);
});
it("should transition to PENDING state", function() {
sandbox.stub(feedbackClient, "send");
store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.PENDING);
});
it("should transition to SENT state on successful submission", function(done) {
sandbox.stub(feedbackClient, "send", function(data, cb) {
cb(null);
});
store.once("change:feedbackState", function() {
expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.SENT);
done();
});
store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
});
it("should transition to FAILED state on failed submission", function(done) {
sandbox.stub(feedbackClient, "send", function(data, cb) {
cb(new Error("failed"));
});
store.once("change:feedbackState", function() {
expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.FAILED);
done();
});
store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
});
});
});

Просмотреть файл

@ -0,0 +1,209 @@
/* 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, React */
/* jshint newcap:false */
var expect = chai.expect;
var l10n = navigator.mozL10n || document.mozL10n;
var TestUtils = React.addons.TestUtils;
var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
describe("loop.shared.views.FeedbackView", function() {
"use strict";
var sandbox, comp, dispatcher, feedbackStore, fakeAudioXHR, fakeFeedbackClient;
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeAudioXHR = {
open: sinon.spy(),
send: function() {},
abort: function() {},
getResponseHeader: function(header) {
if (header === "Content-Type")
return "audio/ogg";
},
responseType: null,
response: new ArrayBuffer(10),
onload: null
};
dispatcher = new loop.Dispatcher();
fakeFeedbackClient = {send: sandbox.stub()};
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: fakeFeedbackClient
});
sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
feedbackStore: feedbackStore
}));
});
afterEach(function() {
sandbox.restore();
});
// 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 dispatch a SendFeedback action", function() {
var dispatch = sandbox.stub(dispatcher, "dispatch");
clickHappyFace(comp);
sinon.assert.calledWithMatch(dispatch, new sharedActions.SendFeedback({
happy: true,
category: "",
description: ""
}));
});
it("should thank the user once feedback data is sent", function() {
feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.SENT});
expect(comp.getDOMNode().querySelectorAll(".thank-you")).not.eql(null);
expect(comp.getDOMNode().querySelector("button.fx-embedded-btn-back"))
.eql(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")).not.eql(null);
});
it("should render a back button", function() {
feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
expect(comp.getDOMNode().querySelector("button.fx-embedded-btn-back"))
.not.eql(null);
});
it("should reset the view when clicking the back button", function() {
feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
TestUtils.Simulate.click(
comp.getDOMNode().querySelector("button.fx-embedded-btn-back"));
expect(comp.getDOMNode().querySelector(".faces")).not.eql(null);
});
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 disable the form submit button when the 'other' category is " +
"chosen but no description has been entered yet",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other");
expect(comp.getDOMNode().querySelector("form button").disabled).eql(true);
});
it("should enable the form submit button when the 'other' category is " +
"chosen and a description is entered",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other", "fake");
expect(comp.getDOMNode().querySelector("form button").disabled).eql(false);
});
it("should empty the description field when a predefined category is " +
"chosen",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
expect(comp.getDOMNode().querySelector(".feedback-description").value).eql("");
});
it("should enable the form submit button once a predefined category is " +
"chosen",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
expect(comp.getDOMNode().querySelector("form button").disabled).eql(false);
});
it("should send feedback data when the form is submitted", function() {
var dispatch = sandbox.stub(dispatcher, "dispatch");
feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithMatch(dispatch, new sharedActions.SendFeedback({
happy: false,
category: "confusing",
description: ""
}));
});
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(fakeFeedbackClient.send);
sinon.assert.calledWith(fakeFeedbackClient.send, {
happy: false,
category: "other",
description: "fake reason"
});
});
it("should thank the user when feedback data has been sent", function() {
fakeFeedbackClient.send = function(data, cb) {
cb();
};
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
submitSadFeedbackForm(comp);
expect(comp.getDOMNode().querySelectorAll(".thank-you")).not.eql(null);
});
});
});

Просмотреть файл

@ -47,6 +47,8 @@
<script src="../../content/shared/js/activeRoomStore.js"></script>
<script src="../../content/shared/js/roomStore.js"></script>
<script src="../../content/shared/js/conversationStore.js"></script>
<script src="../../content/shared/js/feedbackStore.js"></script>
<script src="../../content/shared/js/feedbackViews.js"></script>
<!-- Test scripts -->
<script src="models_test.js"></script>
@ -55,10 +57,12 @@
<script src="views_test.js"></script>
<script src="websocket_test.js"></script>
<script src="feedbackApiClient_test.js"></script>
<script src="feedbackViews_test.js"></script>
<script src="validate_test.js"></script>
<script src="dispatcher_test.js"></script>
<script src="activeRoomStore_test.js"></script>
<script src="conversationStore_test.js"></script>
<script src="feedbackStore_test.js"></script>
<script src="otSdkDriver_test.js"></script>
<script src="store_test.js"></script>
<script src="roomStore_test.js"></script>

Просмотреть файл

@ -526,177 +526,6 @@ describe("loop.shared.views", function() {
});
});
describe("FeedbackView", function() {
var comp, fakeFeedbackApiClient;
beforeEach(function() {
fakeFeedbackApiClient = {send: sandbox.stub()};
sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
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 disable the form submit button when the 'other' category is " +
"chosen but no description has been entered yet",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other");
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(true);
});
it("should enable the form submit button when the 'other' category is " +
"chosen and a description is entered",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "other", "fake");
expect(comp.getDOMNode()
.querySelector("form button").disabled).eql(false);
});
it("should empty the description field when a predefined category is " +
"chosen",
function() {
clickSadFace(comp);
fillSadFeedbackForm(comp, "confusing");
expect(comp.getDOMNode()
.querySelector(".feedback-description").value).eql("");
});
it("should enable the form submit button once a predefined category is " +
"chosen",
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("NotificationListView", function() {
var coll, view, testNotif;

Просмотреть файл

@ -42,6 +42,8 @@
<script src="../../content/shared/js/dispatcher.js"></script>
<script src="../../content/shared/js/store.js"></script>
<script src="../../content/shared/js/activeRoomStore.js"></script>
<script src="../../content/shared/js/feedbackStore.js"></script>
<script src="../../content/shared/js/feedbackViews.js"></script>
<script src="../../content/shared/js/otSdkDriver.js"></script>
<script src="../../standalone/content/js/multiplexGum.js"></script>
<script src="../../standalone/content/js/standaloneAppStore.js"></script>

Просмотреть файл

@ -19,14 +19,20 @@ describe("loop.webapp", function() {
notifications,
feedbackApiClient,
stubGetPermsAndCacheMedia,
fakeAudioXHR;
fakeAudioXHR,
dispatcher,
feedbackStore;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
notifications = new sharedModels.NotificationCollection();
feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
product: "Loop"
});
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
});
stubGetPermsAndCacheMedia = sandbox.stub(
loop.standaloneMedia._MultiplexGum.prototype, "getPermsAndCacheMedia");
@ -123,7 +129,7 @@ describe("loop.webapp", function() {
conversation: conversation,
notifications: notifications,
sdk: {},
feedbackApiClient: feedbackApiClient
feedbackStore: feedbackStore
});
});
@ -582,7 +588,7 @@ describe("loop.webapp", function() {
describe("WebappRootView", function() {
var helper, sdk, conversationModel, client, props, standaloneAppStore;
var dispatcher, activeRoomStore;
var activeRoomStore;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
@ -609,7 +615,6 @@ describe("loop.webapp", function() {
client = new loop.StandaloneClient({
baseServerUrl: "fakeUrl"
});
dispatcher = new loop.Dispatcher();
activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
mozLoop: {},
sdkDriver: {}
@ -1039,7 +1044,7 @@ describe("loop.webapp", function() {
loop.webapp.EndedConversationView({
conversation: conversation,
sdk: {},
feedbackApiClient: feedbackApiClient,
feedbackStore: feedbackStore,
onAfterFeedbackReceived: function(){}
})
);

Просмотреть файл

@ -45,6 +45,8 @@
<script src="../content/shared/js/roomStore.js"></script>
<script src="../content/shared/js/conversationStore.js"></script>
<script src="../content/shared/js/activeRoomStore.js"></script>
<script src="../content/shared/js/feedbackStore.js"></script>
<script src="../content/shared/js/feedbackViews.js"></script>
<script src="../content/js/roomViews.js"></script>
<script src="../content/js/conversationViews.js"></script>
<script src="../content/js/client.js"></script>

Просмотреть файл

@ -39,8 +39,9 @@
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Room constants
// Store constants
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
// Local helpers
function returnTrue() {
@ -69,6 +70,9 @@
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: stageFeedbackApiClient
});
// Local mocks
@ -460,13 +464,13 @@
React.DOM.a({href: "https://input.allizom.org/"}, "input.allizom.org"), "."
),
Example({summary: "Default (useable demo)", dashed: "true", style: {width: "260px"}},
FeedbackView({feedbackApiClient: stageFeedbackApiClient})
FeedbackView({feedbackStore: feedbackStore})
),
Example({summary: "Detailed form", dashed: "true", style: {width: "260px"}},
FeedbackView({feedbackApiClient: stageFeedbackApiClient, step: "form"})
FeedbackView({feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.DETAILS})
),
Example({summary: "Thank you!", dashed: "true", style: {width: "260px"}},
FeedbackView({feedbackApiClient: stageFeedbackApiClient, step: "finished"})
FeedbackView({feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.SENT})
)
),
@ -486,7 +490,7 @@
video: {enabled: true},
audio: {enabled: true},
conversation: mockConversationModel,
feedbackApiClient: stageFeedbackApiClient,
feedbackStore: feedbackStore,
onAfterFeedbackReceived: noop})
)
)

Просмотреть файл

@ -39,8 +39,9 @@
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Room constants
// Store constants
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
// Local helpers
function returnTrue() {
@ -69,6 +70,9 @@
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: stageFeedbackApiClient
});
// Local mocks
@ -460,13 +464,13 @@
<a href="https://input.allizom.org/">input.allizom.org</a>.
</p>
<Example summary="Default (useable demo)" dashed="true" style={{width: "260px"}}>
<FeedbackView feedbackApiClient={stageFeedbackApiClient} />
<FeedbackView feedbackStore={feedbackStore} />
</Example>
<Example summary="Detailed form" dashed="true" style={{width: "260px"}}>
<FeedbackView feedbackApiClient={stageFeedbackApiClient} step="form" />
<FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.DETAILS} />
</Example>
<Example summary="Thank you!" dashed="true" style={{width: "260px"}}>
<FeedbackView feedbackApiClient={stageFeedbackApiClient} step="finished" />
<FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.SENT} />
</Example>
</Section>
@ -486,7 +490,7 @@
video={{enabled: true}}
audio={{enabled: true}}
conversation={mockConversationModel}
feedbackApiClient={stageFeedbackApiClient}
feedbackStore={feedbackStore}
onAfterFeedbackReceived={noop} />
</div>
</Example>

Просмотреть файл

@ -16,14 +16,15 @@ const { indexedDB } = require("sdk/indexed-db");
const IDB = {
_db: null,
databaseName: "AppProjects",
open: function () {
let deferred = promise.defer();
let request = indexedDB.open("AppProjects", 5);
let request = indexedDB.open(IDB.databaseName, 5);
request.onerror = function(event) {
deferred.reject("Unable to open AppProjects indexedDB. " +
"Error code: " + event.target.errorCode);
deferred.reject("Unable to open AppProjects indexedDB: " +
this.error.name + " - " + this.error.message );
};
request.onupgradeneeded = function(event) {
let db = event.target.result;
@ -147,11 +148,10 @@ const store = new ObservableObject({ projects:[] });
let loadDeferred = promise.defer();
IDB.open().then(function (projects) {
loadDeferred.resolve(IDB.open().then(function (projects) {
store.object.projects = projects;
AppProjects.emit("ready", store.object.projects);
loadDeferred.resolve();
});
}));
const AppProjects = {
load: function() {

Просмотреть файл

@ -72,6 +72,9 @@ let UI = {
AppProjects.load().then(() => {
this.autoSelectProject();
}, e => {
console.error(e);
this.reportError("error_appProjectsLoadFailed");
});
// Auto install the ADB Addon Helper and Tools Adapters. Only once.
@ -256,7 +259,7 @@ let UI = {
this._busyTimeout = setTimeout(() => {
this.unbusy();
UI.reportError("error_operationTimeout", this._busyOperationDescription);
}, 6000);
}, Services.prefs.getIntPref("devtools.webide.busyTimeout"));
},
cancelBusyTimeout: function() {

Просмотреть файл

@ -32,3 +32,4 @@ pref("devtools.webide.widget.enabled", false);
pref("devtools.webide.widget.inNavbarByDefault", false);
#endif
pref("devtools.webide.zoom", "1");
pref("devtools.webide.busyTimeout", 10000);

Просмотреть файл

@ -20,7 +20,7 @@ importHostedApp_title=Open Hosted App
importHostedApp_header=Enter Manifest URL
notification_showTroubleShooting_label=Troubleshooting
notification_showTroubleShooting_accesskey=t
notification_showTroubleShooting_accesskey=T
# LOCALIZATION NOTE (project_tab_loading): This is shown as a temporary tab
# title for browser tab projects when the tab is still loading.
@ -42,6 +42,8 @@ error_cantConnectToApp=Can't connect to app: %1$S
# Variable: error message (in english)
error_cantFetchAddonsJSON=Can't fetch the add-on list: %S
error_appProjectsLoadFailed=Unable to load project list. This can occur if you've used this profile with a newer version of Firefox.
addons_stable=stable
addons_unstable=unstable
# LOCALIZATION NOTE (addons_simulator_label): This label is shown as the name of

Просмотреть файл

@ -7,6 +7,7 @@ package org.mozilla.gecko;
import java.io.File;
import java.io.FileNotFoundException;
import java.lang.Override;
import java.lang.reflect.Method;
import java.net.URLEncoder;
import java.util.EnumSet;
@ -24,6 +25,7 @@ import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
import org.mozilla.gecko.Tabs.TabEvents;
import org.mozilla.gecko.animation.PropertyAnimator;
import org.mozilla.gecko.animation.TransitionsTracker;
import org.mozilla.gecko.animation.ViewHelper;
import org.mozilla.gecko.db.BrowserContract.Combined;
import org.mozilla.gecko.db.BrowserContract.SearchHistory;
@ -1971,6 +1973,8 @@ public class BrowserApp extends GeckoApp
final PropertyAnimator animator = new PropertyAnimator(250);
animator.setUseHardwareLayer(false);
TransitionsTracker.track(animator);
mBrowserToolbar.startEditing(url, animator);
final String panelId = selectedTab.getMostRecentHomePanel();

Просмотреть файл

@ -0,0 +1,119 @@
/* 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/. */
package org.mozilla.gecko.animation;
import com.nineoldandroids.animation.Animator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.mozilla.gecko.animation.PropertyAnimator;
import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
import org.mozilla.gecko.util.ThreadUtils;
/**
* {@link TransitionsTracker} provides a simple API to avoid running layout code
* during UI transitions. You should use it whenever you need to time-shift code
* that will likely trigger a layout traversal during an animation.
*/
public class TransitionsTracker {
private static final ArrayList<Runnable> pendingActions = new ArrayList<>();
private static int transitionCount;
private static final PropertyAnimationListener propertyAnimatorListener =
new PropertyAnimationListener() {
@Override
public void onPropertyAnimationStart() {
pushTransition();
}
@Override
public void onPropertyAnimationEnd() {
popTransition();
}
};
private static final Animator.AnimatorListener animatorListener =
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
pushTransition();
}
@Override
public void onAnimationEnd(Animator animation) {
popTransition();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
};
private static void runPendingActions() {
ThreadUtils.assertOnUiThread();
final int size = pendingActions.size();
for (int i = 0; i < size; i++) {
pendingActions.get(i).run();
}
pendingActions.clear();
}
public static void pushTransition() {
ThreadUtils.assertOnUiThread();
transitionCount++;
}
public static void popTransition() {
ThreadUtils.assertOnUiThread();
transitionCount--;
if (transitionCount < 0) {
throw new IllegalStateException("Invalid transition stack update");
}
if (transitionCount == 0) {
runPendingActions();
}
}
public static boolean areTransitionsRunning() {
ThreadUtils.assertOnUiThread();
return (transitionCount > 0);
}
public static void track(PropertyAnimator animator) {
ThreadUtils.assertOnUiThread();
animator.addPropertyAnimationListener(propertyAnimatorListener);
}
public static void track(Animator animator) {
ThreadUtils.assertOnUiThread();
animator.addListener(animatorListener);
}
public static boolean cancelPendingAction(Runnable action) {
ThreadUtils.assertOnUiThread();
return pendingActions.removeAll(Collections.singleton(action));
}
public static void runAfterTransitions(Runnable action) {
ThreadUtils.assertOnUiThread();
if (transitionCount == 0) {
action.run();
} else {
pendingActions.add(action);
}
}
}

Просмотреть файл

@ -216,7 +216,7 @@ public class BookmarksPanel extends HomeFragment {
/**
* Loader callbacks for the LoaderManager of this fragment.
*/
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (args == null) {
@ -229,7 +229,7 @@ public class BookmarksPanel extends HomeFragment {
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
BookmarksLoader bl = (BookmarksLoader) loader;
mListAdapter.swapCursor(c, bl.getFolderInfo(), bl.getRefreshType());
updateUiFromCursor(c);
@ -237,6 +237,8 @@ public class BookmarksPanel extends HomeFragment {
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
if (mList != null) {
mListAdapter.swapCursor(null);
}

Просмотреть файл

@ -262,12 +262,6 @@ public class DynamicPanel extends HomeFragment {
public void requestDataset(DatasetRequest request) {
Log.d(LOGTAG, "Requesting request: " + request);
// Ignore dataset requests while the fragment is not
// allowed to load its content.
if (!getCanLoadHint()) {
return;
}
final Bundle bundle = new Bundle();
bundle.putParcelable(DATASET_REQUEST, request);
@ -352,7 +346,7 @@ public class DynamicPanel extends HomeFragment {
/**
* LoaderCallbacks implementation that interacts with the LoaderManager.
*/
private class PanelLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class PanelLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
final DatasetRequest request = (DatasetRequest) args.getParcelable(DATASET_REQUEST);
@ -362,7 +356,7 @@ public class DynamicPanel extends HomeFragment {
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor cursor) {
final DatasetRequest request = getRequestFromLoader(loader);
Log.d(LOGTAG, "Finished loader for request: " + request);
@ -373,6 +367,8 @@ public class DynamicPanel extends HomeFragment {
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
final DatasetRequest request = getRequestFromLoader(loader);
Log.d(LOGTAG, "Resetting loader for request: " + request);

Просмотреть файл

@ -471,20 +471,21 @@ public class HistoryPanel extends HomeFragment {
}
}
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new HistoryCursorLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
mAdapter.swapCursor(c);
updateUiFromCursor(c);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
mAdapter.swapCursor(null);
}
}

Просмотреть файл

@ -223,7 +223,7 @@ public class HomePager extends ViewPager {
final HomeAdapter adapter = new HomeAdapter(mContext, fm);
adapter.setOnAddPanelListener(mAddPanelListener);
adapter.setCanLoadHint(!shouldAnimate);
adapter.setCanLoadHint(true);
setAdapter(adapter);
// Don't show the tabs strip until we have the
@ -243,7 +243,6 @@ public class HomePager extends ViewPager {
@Override
public void onPropertyAnimationEnd() {
setLayerType(View.LAYER_TYPE_NONE, null);
adapter.setCanLoadHint(true);
}
});
@ -373,9 +372,7 @@ public class HomePager extends ViewPager {
final HomeAdapter adapter = (HomeAdapter) getAdapter();
// Disable any fragment loading until we have the initial
// panel selection done. Store previous value to restore
// it if necessary once the UI is fully updated.
final boolean canLoadHint = adapter.getCanLoadHint();
// panel selection done.
adapter.setCanLoadHint(false);
// Destroy any existing panels currently loaded
@ -436,19 +433,15 @@ public class HomePager extends ViewPager {
}
}
// If the load hint was originally true, this means the pager
// is not animating and it's fine to restore the load hint back.
if (canLoadHint) {
// The selection is updated asynchronously so we need to post to
// UI thread to give the pager time to commit the new page selection
// internally and load the right initial panel.
ThreadUtils.getUiHandler().post(new Runnable() {
@Override
public void run() {
adapter.setCanLoadHint(true);
}
});
}
// The selection is updated asynchronously so we need to post to
// UI thread to give the pager time to commit the new page selection
// internally and load the right initial panel.
ThreadUtils.getUiHandler().post(new Runnable() {
@Override
public void run() {
adapter.setCanLoadHint(true);
}
});
}
public void setOnPanelChangeListener(OnPanelChangeListener listener) {

Просмотреть файл

@ -8,6 +8,7 @@ package org.mozilla.gecko.home;
import org.mozilla.gecko.R;
import org.mozilla.gecko.animation.BounceAnimator;
import org.mozilla.gecko.animation.BounceAnimator.Attributes;
import org.mozilla.gecko.animation.TransitionsTracker;
import android.content.Context;
import android.content.res.Resources;
@ -120,6 +121,8 @@ class HomePagerTabStrip extends PagerTabStrip {
nextBounceAnimator.queue(new Attributes(0, BOUNCE4_MS));
nextBounceAnimator.setStartDelay(ANIMATION_DELAY_MS);
TransitionsTracker.track(nextBounceAnimator);
// Start animations.
alphaAnimatorSet.start();
prevBounceAnimator.start();

Просмотреть файл

@ -197,20 +197,21 @@ public class ReadingListPanel extends HomeFragment {
/**
* LoaderCallbacks implementation that interacts with the LoaderManager.
*/
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new ReadingListLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
mAdapter.swapCursor(c);
updateUiFromCursor(c);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
mAdapter.swapCursor(null);
}
}

Просмотреть файл

@ -404,20 +404,21 @@ public class RecentTabsPanel extends HomeFragment
}
}
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new RecentTabsCursorLoader(getActivity(), mClosedTabs);
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
mAdapter.swapCursor(c);
updateUiFromCursor(c);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
mAdapter.swapCursor(null);
}
}

Просмотреть файл

@ -392,14 +392,14 @@ public class RemoteTabsExpandableListFragment extends HomeFragment implements Re
}
}
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new RemoteTabsCursorLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
public void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
final List<RemoteClient> clients = TabsAccessor.getClientsFromCursor(c);
// Filter the hidden clients out of the clients list. The clients
@ -421,6 +421,7 @@ public class RemoteTabsExpandableListFragment extends HomeFragment implements Re
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
mAdapter.replaceClients(null);
}
}

Просмотреть файл

@ -689,7 +689,7 @@ public class TopSitesPanel extends HomeFragment {
}
}
private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
trace("Creating TopSitesLoader: " + id);
@ -707,7 +707,7 @@ public class TopSitesPanel extends HomeFragment {
* Why that is... dunno.
*/
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
protected void onLoadFinishedAfterTransitions(Loader<Cursor> loader, Cursor c) {
debug("onLoadFinished: " + c.getCount() + " rows.");
mListAdapter.swapCursor(c);
@ -752,6 +752,8 @@ public class TopSitesPanel extends HomeFragment {
@Override
public void onLoaderReset(Loader<Cursor> loader) {
super.onLoaderReset(loader);
if (mListAdapter != null) {
mListAdapter.swapCursor(null);
}

Просмотреть файл

@ -0,0 +1,57 @@
/* 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/. */
package org.mozilla.gecko.home;
import android.database.Cursor;
import android.support.v4.app.LoaderManager.LoaderCallbacks;
import android.support.v4.content.Loader;
import org.mozilla.gecko.animation.TransitionsTracker;
/**
* A {@link LoaderCallbacks} implementation that avoids running its
* {@link #onLoadFinished(Loader, Cursor)} method during animations as it's
* likely to trigger a layout traversal as a result of a cursor swap in the
* target adapter.
*/
public abstract class TransitionAwareCursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
private OnLoadFinishedRunnable onLoadFinishedRunnable;
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
if (onLoadFinishedRunnable != null) {
TransitionsTracker.cancelPendingAction(onLoadFinishedRunnable);
}
onLoadFinishedRunnable = new OnLoadFinishedRunnable(loader, c);
TransitionsTracker.runAfterTransitions(onLoadFinishedRunnable);
}
protected abstract void onLoadFinishedAfterTransitions(Loader<Cursor> loade, Cursor c);
@Override
public void onLoaderReset(Loader<Cursor> loader) {
if (onLoadFinishedRunnable != null) {
TransitionsTracker.cancelPendingAction(onLoadFinishedRunnable);
onLoadFinishedRunnable = null;
}
}
private class OnLoadFinishedRunnable implements Runnable {
private final Loader<Cursor> loader;
private final Cursor cursor;
public OnLoadFinishedRunnable(Loader<Cursor> loader, Cursor cursor) {
this.loader = loader;
this.cursor = cursor;
}
@Override
public void run() {
onLoadFinishedAfterTransitions(loader, cursor);
onLoadFinishedRunnable = null;
}
}
}

Просмотреть файл

@ -124,6 +124,7 @@ gbjar.sources += [
'animation/HeightChangeAnimation.java',
'animation/PropertyAnimator.java',
'animation/Rotate3DAnimation.java',
'animation/TransitionsTracker.java',
'animation/ViewHelper.java',
'ANRReporter.java',
'AppNotificationClient.java',
@ -310,6 +311,7 @@ gbjar.sources += [
'home/TopSitesGridView.java',
'home/TopSitesPanel.java',
'home/TopSitesThumbnailView.java',
'home/TransitionAwareCursorLoaderCallbacks.java',
'home/TwoLinePageRow.java',
'InputMethods.java',
'IntentHelper.java',

Просмотреть файл

@ -12,6 +12,8 @@
android:layout_weight="1"
android:paddingTop="4dp"/>
<!-- The right margin creates a "dead area" on the right side of the button
which we compensate for with a touch delegate. See TabStrip -->
<ImageButton
android:id="@+id/add_tab"
style="@style/UrlBar.ImageButton"

Просмотреть файл

@ -6,9 +6,12 @@
package org.mozilla.gecko.tabs;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.TouchDelegate;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.ImageButton;
import org.mozilla.gecko.R;
@ -51,6 +54,25 @@ public class TabStrip extends ThemedLinearLayout {
}
});
getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(this);
final Rect r = new Rect();
r.left = addTabButton.getRight();
r.right = getWidth();
r.top = 0;
r.bottom = getHeight();
// Redirect touch events between the 'new tab' button and the edge
// of the screen to the 'new tab' button.
setTouchDelegate(new TouchDelegate(r, addTabButton));
return true;
}
});
tabsListener = new TabsListener();
}

Просмотреть файл

@ -25,6 +25,7 @@ import com.nineoldandroids.animation.ObjectAnimator;
import java.util.ArrayList;
import java.util.List;
import org.mozilla.gecko.animation.TransitionsTracker;
import org.mozilla.gecko.R;
import org.mozilla.gecko.Tab;
import org.mozilla.gecko.Tabs;
@ -134,6 +135,9 @@ public class TabStripView extends TwoWayView {
animatorSet.setDuration(ANIM_TIME_MS);
animatorSet.setInterpolator(ANIM_INTERPOLATOR);
animatorSet.addListener(animatorListener);
TransitionsTracker.track(animatorSet);
animatorSet.start();
return true;
@ -183,6 +187,9 @@ public class TabStripView extends TwoWayView {
animatorSet.setDuration(ANIM_TIME_MS);
animatorSet.setInterpolator(ANIM_INTERPOLATOR);
animatorSet.addListener(animatorListener);
TransitionsTracker.track(animatorSet);
animatorSet.start();
return true;

Просмотреть файл

@ -4,6 +4,7 @@
package org.mozilla.gecko.tabs;
import org.mozilla.gecko.NewTabletUI;
import org.mozilla.gecko.R;
import org.mozilla.gecko.Tab;
import org.mozilla.gecko.widget.TabThumbnailWrapper;
@ -89,24 +90,26 @@ public class TabsLayoutItemView extends LinearLayout
mCloseButton = (ImageButton) findViewById(R.id.close);
mThumbnailWrapper = (TabThumbnailWrapper) findViewById(R.id.wrapper);
if (NewTabletUI.isEnabled(getContext())) {
growCloseButtonHitArea();
}
}
private void growCloseButtonHitArea() {
getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(this);
final Rect hitRect = new Rect();
mCloseButton.getHitRect(hitRect);
// Ideally we want the close button hit area to be 40x40dp but we are constrained by the height of the parent, so
// we make it as tall as the parent view and 40dp across.
final int targetHitArea = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40, getResources().getDisplayMetrics());;
final View parent = ((View) mCloseButton.getParent());
final Rect hitRect = new Rect();
hitRect.top = 0;
hitRect.right = getWidth();
hitRect.left = getWidth() - targetHitArea;
hitRect.bottom = parent.getHeight();
hitRect.bottom = targetHitArea;
setTouchDelegate(new TouchDelegate(hitRect, mCloseButton));

Просмотреть файл

@ -834,6 +834,17 @@ this.PlacesUtils = {
return null;
},
/**
* Gets the href and the post data for a given keyword, if any.
*
* @param keyword
* The string keyword to look for.
* @return {Promise}
* @resolves to a { href, postData } object. Both properties evaluate to null
* if no keyword is found.
*/
promiseHrefAndPostDataForKeyword(keyword) KeywordsCache.promiseEntry(keyword),
/**
* Get the URI (and any associated POST data) for a given keyword.
* @param aKeyword string keyword
@ -1965,6 +1976,114 @@ let GuidHelper = {
}
};
// Cache of bookmarks keywords, used to quickly resolve keyword => URL requests.
let KeywordsCache = {
/**
* Initializes the cache.
* Every method should check _initialized and, if false, yield _initialize().
*/
_initialized: false,
_initialize: Task.async(function* () {
// First populate the cache...
yield this._reloadCache();
// ...then observe changes to keep the cache up-to-date.
PlacesUtils.bookmarks.addObserver(this, false);
PlacesUtils.registerShutdownFunction(() => {
PlacesUtils.bookmarks.removeObserver(this);
});
this._initialized = true;
}),
// nsINavBookmarkObserver
// Manually updating the cache would be tricky because some notifications
// don't report the original bookmark url and we also keep urls sorted by
// last modified. Since changing a keyword-ed bookmark is a rare event,
// it's easier to reload the cache.
onItemChanged(itemId, property, isAnno, val, lastModified, type,
parentId, guid, parentGuid) {
if (property == "keyword" || property == this.POST_DATA_ANNO ||
this._keywordedGuids.has(guid)) {
// Since this cache is used in hot paths, it should be readily available
// as fast as possible.
this._reloadCache().catch(Cu.reportError);
}
},
onItemRemoved(itemId, parentId, index, type, uri, guid, parentGuid) {
if (this._keywordedGuids.has(guid)) {
// Since this cache is used in hot paths, it should be readily available
// as fast as possible.
this._reloadCache().catch(Cu.reportError);
}
},
QueryInterface: XPCOMUtils.generateQI([ Ci.nsINavBookmarkObserver ]),
__noSuchMethod__() {}, // Catch all remaining onItem* methods.
// Maps an { href, postData } object to each keyword.
// Even if a keyword may be associated to multiple URLs, only the last
// modified bookmark href is retained here.
_urlDataForKeyword: null,
// Tracks GUIDs having a keyword.
_keywordedGuids: null,
/**
* Reloads the cache.
*/
_reloadPromise: null,
_reloadCache() {
return this._reloadPromise = Task.spawn(function* () {
let db = yield PlacesUtils.promiseDBConnection();
let rows = yield db.execute(
`/* do not warn (bug no) - there is no index on keyword_id */
SELECT b.id, b.guid, h.url, k.keyword FROM moz_bookmarks b
JOIN moz_places h ON h.id = b.fk
JOIN moz_keywords k ON k.id = b.keyword_id
ORDER BY b.lastModified DESC
`);
this._urlDataForKeyword = new Map();
this._keywordedGuids = new Set();
for (let row of rows) {
let guid = row.getResultByName("guid");
this._keywordedGuids.add(guid);
let keyword = row.getResultByName("keyword");
// Only keep the most recent href.
let urlData = this._urlDataForKeyword.get(keyword);
if (urlData)
continue;
let id = row.getResultByName("id");
let href = row.getResultByName("url");
let postData = PlacesUtils.getPostDataForBookmark(id);
this._urlDataForKeyword.set(keyword, { href, postData });
}
}.bind(this)).then(() => {
this._reloadPromise = null;
});
},
/**
* Fetches a { href, postData } entry for the given keyword.
*
* @param keyword
* The keyword to look for.
* @return {promise}
* @resolves when the fetching is complete.
*/
promiseEntry: Task.async(function* (keyword) {
// We could yield regardless and do the checks internally, but that would
// waste at least a couple ticks and this can be used on hot paths.
if (!this._initialized)
yield this._initialize();
if (this._reloadPromise)
yield this._reloadPromise;
return this._urlDataForKeyword.get(keyword) || { href: null, postData: null };
}),
};
////////////////////////////////////////////////////////////////////////////////
//// Transactions handlers.

Просмотреть файл

@ -768,7 +768,7 @@ Search.prototype = {
let hasFirstResult = false;
if (this._searchTokens.length > 0 &&
PlacesUtils.bookmarks.getURIForKeyword(this._searchTokens[0])) {
(yield PlacesUtils.promiseHrefAndPostDataForKeyword(this._searchTokens[0])).href) {
// This may be a keyword of a bookmark.
queries.unshift(this._keywordQuery);
hasFirstResult = true;

Просмотреть файл

@ -19,8 +19,6 @@
#include "GeckoProfiler.h"
#define BOOKMARKS_TO_KEYWORDS_INITIAL_CACHE_LENGTH 32
using namespace mozilla;
// These columns sit to the right of the kGetInfoIndex_* columns.
@ -40,25 +38,6 @@ PLACES_FACTORY_SINGLETON_IMPLEMENTATION(nsNavBookmarks, gBookmarksService)
namespace {
struct keywordSearchData
{
int64_t itemId;
nsString keyword;
};
PLDHashOperator
SearchBookmarkForKeyword(nsTrimInt64HashKey::KeyType aKey,
const nsString aValue,
void* aUserArg)
{
keywordSearchData* data = reinterpret_cast<keywordSearchData*>(aUserArg);
if (data->keyword.Equals(aValue)) {
data->itemId = aKey;
return PL_DHASH_STOP;
}
return PL_DHASH_NEXT;
}
template<typename Method, typename DataType>
class AsyncGetBookmarksForURI : public AsyncStatementCallback
{
@ -143,8 +122,6 @@ nsNavBookmarks::nsNavBookmarks()
, mCanNotify(false)
, mCacheObservers("bookmark-observers")
, mBatching(false)
, mBookmarkToKeywordHash(BOOKMARKS_TO_KEYWORDS_INITIAL_CACHE_LENGTH)
, mBookmarkToKeywordHashInitialized(false)
{
NS_ASSERTION(!gBookmarksService,
"Attempting to create two instances of the service!");
@ -646,7 +623,7 @@ nsNavBookmarks::RemoveItem(int64_t aItemId)
NS_ENSURE_SUCCESS(rv, rv);
}
rv = UpdateKeywordsHashForRemovedBookmark(aItemId);
rv = removeOrphanKeywords();
NS_ENSURE_SUCCESS(rv, rv);
// A broken url should not interrupt the removal process.
@ -1119,7 +1096,7 @@ nsNavBookmarks::RemoveFolderChildren(int64_t aFolderId)
NS_ENSURE_SUCCESS(rv, rv);
}
rv = UpdateKeywordsHashForRemovedBookmark(child.id);
rv = removeOrphanKeywords();
NS_ENSURE_SUCCESS(rv, rv);
}
}
@ -2255,39 +2232,23 @@ nsNavBookmarks::SetItemIndex(int64_t aItemId, int32_t aNewIndex)
nsresult
nsNavBookmarks::UpdateKeywordsHashForRemovedBookmark(int64_t aItemId)
nsNavBookmarks::removeOrphanKeywords()
{
nsAutoString keyword;
if (NS_SUCCEEDED(GetKeywordForBookmark(aItemId, keyword)) &&
!keyword.IsEmpty()) {
nsresult rv = EnsureKeywordsHash();
NS_ENSURE_SUCCESS(rv, rv);
mBookmarkToKeywordHash.Remove(aItemId);
// If the keyword is unused, remove it from the database.
nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
"DELETE FROM moz_keywords "
"WHERE NOT EXISTS ( "
"SELECT id "
"FROM moz_bookmarks "
"WHERE keyword_id = moz_keywords.id "
")"
);
NS_ENSURE_STATE(stmt);
// If the keyword is unused, remove it from the database.
keywordSearchData searchData;
searchData.keyword.Assign(keyword);
searchData.itemId = -1;
mBookmarkToKeywordHash.EnumerateRead(SearchBookmarkForKeyword, &searchData);
if (searchData.itemId == -1) {
nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
"DELETE FROM moz_keywords "
"WHERE keyword = :keyword "
"AND NOT EXISTS ( "
"SELECT id "
"FROM moz_bookmarks "
"WHERE keyword_id = moz_keywords.id "
")"
);
NS_ENSURE_STATE(stmt);
nsCOMPtr<mozIStoragePendingStatement> pendingStmt;
nsresult rv = stmt->ExecuteAsync(nullptr, getter_AddRefs(pendingStmt));
NS_ENSURE_SUCCESS(rv, rv);
rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
NS_ENSURE_SUCCESS(rv, rv);
nsCOMPtr<mozIStoragePendingStatement> pendingStmt;
rv = stmt->ExecuteAsync(nullptr, getter_AddRefs(pendingStmt));
NS_ENSURE_SUCCESS(rv, rv);
}
}
return NS_OK;
}
@ -2303,9 +2264,6 @@ nsNavBookmarks::SetKeywordForBookmark(int64_t aBookmarkId,
nsresult rv = FetchItemInfo(aBookmarkId, bookmark);
NS_ENSURE_SUCCESS(rv, rv);
rv = EnsureKeywordsHash();
NS_ENSURE_SUCCESS(rv, rv);
// Shortcuts are always lowercased internally.
nsAutoString keyword(aUserCasedKeyword);
ToLowerCase(keyword);
@ -2331,8 +2289,6 @@ nsNavBookmarks::SetKeywordForBookmark(int64_t aBookmarkId,
mozStorageStatementScoper updateBookmarkScoper(updateBookmarkStmt);
if (keyword.IsEmpty()) {
// Remove keyword association from the hash.
mBookmarkToKeywordHash.Remove(bookmark.id);
rv = updateBookmarkStmt->BindNullByName(NS_LITERAL_CSTRING("keyword"));
}
else {
@ -2350,10 +2306,6 @@ nsNavBookmarks::SetKeywordForBookmark(int64_t aBookmarkId,
rv = newKeywordStmt->Execute();
NS_ENSURE_SUCCESS(rv, rv);
// Add new keyword association to the hash, removing the old one if needed.
if (!oldKeyword.IsEmpty())
mBookmarkToKeywordHash.Remove(bookmark.id);
mBookmarkToKeywordHash.Put(bookmark.id, keyword);
rv = updateBookmarkStmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
}
NS_ENSURE_SUCCESS(rv, rv);
@ -2411,12 +2363,12 @@ nsNavBookmarks::GetKeywordForURI(nsIURI* aURI, nsAString& aKeyword)
rv = stmt->ExecuteStep(&hasMore);
if (NS_FAILED(rv) || !hasMore) {
aKeyword.SetIsVoid(true);
return NS_OK; // not found: return void keyword string
return NS_OK;
}
// found, get the keyword
rv = stmt->GetString(0, aKeyword);
NS_ENSURE_SUCCESS(rv, rv);
return NS_OK;
}
@ -2427,16 +2379,28 @@ nsNavBookmarks::GetKeywordForBookmark(int64_t aBookmarkId, nsAString& aKeyword)
NS_ENSURE_ARG_MIN(aBookmarkId, 1);
aKeyword.Truncate(0);
nsresult rv = EnsureKeywordsHash();
nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
"/* do not warn (bug no) - there is no index on keyword_id) */ "
"SELECT k.keyword "
"FROM moz_bookmarks b "
"JOIN moz_keywords k ON k.id = b.keyword_id "
"WHERE b.id = :id "
);
NS_ENSURE_STATE(stmt);
mozStorageStatementScoper scoper(stmt);
nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("id"), aBookmarkId);
NS_ENSURE_SUCCESS(rv, rv);
nsAutoString keyword;
if (!mBookmarkToKeywordHash.Get(aBookmarkId, &keyword)) {
bool hasMore = false;
rv = stmt->ExecuteStep(&hasMore);
if (NS_FAILED(rv) || !hasMore) {
aKeyword.SetIsVoid(true);
return NS_OK;
}
else {
aKeyword.Assign(keyword);
}
rv = stmt->GetString(0, aKeyword);
NS_ENSURE_SUCCESS(rv, rv);
return NS_OK;
}
@ -2454,53 +2418,33 @@ nsNavBookmarks::GetURIForKeyword(const nsAString& aUserCasedKeyword,
nsAutoString keyword(aUserCasedKeyword);
ToLowerCase(keyword);
nsresult rv = EnsureKeywordsHash();
nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
"/* do not warn (bug no) - there is no index on keyword_id) */ "
"SELECT url FROM moz_keywords k "
"JOIN moz_bookmarks b ON b.keyword_id = k.id "
"JOIN moz_places h ON b.fk = h.id "
"WHERE k.keyword = :keyword "
"ORDER BY b.dateAdded DESC"
);
NS_ENSURE_STATE(stmt);
mozStorageStatementScoper scoper(stmt);
nsresult rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
NS_ENSURE_SUCCESS(rv, rv);
keywordSearchData searchData;
searchData.keyword.Assign(keyword);
searchData.itemId = -1;
mBookmarkToKeywordHash.EnumerateRead(SearchBookmarkForKeyword, &searchData);
if (searchData.itemId == -1) {
// Not found.
bool hasMore = false;
rv = stmt->ExecuteStep(&hasMore);
if (NS_FAILED(rv) || !hasMore) {
return NS_OK;
}
rv = GetBookmarkURI(searchData.itemId, aURI);
nsCString url;
rv = stmt->GetUTF8String(0, url);
NS_ENSURE_SUCCESS(rv, rv);
return NS_OK;
}
nsresult
nsNavBookmarks::EnsureKeywordsHash() {
if (mBookmarkToKeywordHashInitialized) {
return NS_OK;
}
mBookmarkToKeywordHashInitialized = true;
nsCOMPtr<mozIStorageStatement> stmt;
nsresult rv = mDB->MainConn()->CreateStatement(NS_LITERAL_CSTRING(
"SELECT b.id, k.keyword "
"FROM moz_bookmarks b "
"JOIN moz_keywords k ON k.id = b.keyword_id "
), getter_AddRefs(stmt));
rv = NS_NewURI(aURI, url);
NS_ENSURE_SUCCESS(rv, rv);
bool hasMore;
while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
int64_t itemId;
rv = stmt->GetInt64(0, &itemId);
NS_ENSURE_SUCCESS(rv, rv);
nsAutoString keyword;
rv = stmt->GetString(1, keyword);
NS_ENSURE_SUCCESS(rv, rv);
mBookmarkToKeywordHash.Put(itemId, keyword);
}
return NS_OK;
}

Просмотреть файл

@ -422,20 +422,9 @@ private:
bool mBatching;
/**
* Always call EnsureKeywordsHash() and check it for errors before actually
* using the hash. Internal keyword methods are already doing that.
* Removes orphan keywords.
*/
nsresult EnsureKeywordsHash();
nsDataHashtable<nsTrimInt64HashKey, nsString> mBookmarkToKeywordHash;
bool mBookmarkToKeywordHashInitialized;
/**
* This function must be called every time a bookmark is removed.
*
* @param aURI
* Uri to test.
*/
nsresult UpdateKeywordsHashForRemovedBookmark(int64_t aItemId);
nsresult removeOrphanKeywords();
};
#endif // nsNavBookmarks_h_

Просмотреть файл

@ -12,6 +12,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch",
"resource://gre/modules/TelemetryStopwatch.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
////////////////////////////////////////////////////////////////////////////////
//// Constants
@ -1447,74 +1449,76 @@ urlInlineComplete.prototype = {
this._listener = aListener;
// Don't autoFill if the search term is recognized as a keyword, otherwise
// it will override default keywords behavior. Note that keywords are
// hashed on first use, so while the first query may delay a little bit,
// next ones will just hit the memory hash.
if (this._currentSearchString.length == 0 || !this._db ||
PlacesUtils.bookmarks.getURIForKeyword(this._currentSearchString)) {
this._finishSearch();
return;
}
// Don't try to autofill if the search term includes any whitespace.
// This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH
// tokenizer ends up trimming the search string and returning a value
// that doesn't match it, or is even shorter.
if (/\s/.test(this._currentSearchString)) {
this._finishSearch();
return;
}
// Hosts have no "/" in them.
let lastSlashIndex = this._currentSearchString.lastIndexOf("/");
// Search only URLs if there's a slash in the search string...
if (lastSlashIndex != -1) {
// ...but not if it's exactly at the end of the search string.
if (lastSlashIndex < this._currentSearchString.length - 1)
this._queryURL();
else
Task.spawn(function* () {
// Don't autoFill if the search term is recognized as a keyword, otherwise
// it will override default keywords behavior. Note that keywords are
// hashed on first use, so while the first query may delay a little bit,
// next ones will just hit the memory hash.
if (this._currentSearchString.length == 0 || !this._db ||
(yield PlacesUtils.promiseHrefAndPostDataForKeyword(this._currentSearchString)).href) {
this._finishSearch();
return;
}
// Do a synchronous search on the table of hosts.
let query = this._hostQuery;
query.params.search_string = this._currentSearchString.toLowerCase();
// This is just to measure the delay to reach the UI, not the query time.
TelemetryStopwatch.start(DOMAIN_QUERY_TELEMETRY);
let ac = this;
let wrapper = new AutoCompleteStatementCallbackWrapper(this, {
handleResult: function (aResultSet) {
let row = aResultSet.getNextRow();
let trimmedHost = row.getResultByIndex(0);
let untrimmedHost = row.getResultByIndex(1);
// If the untrimmed value doesn't preserve the user's input just
// ignore it and complete to the found host.
if (untrimmedHost &&
!untrimmedHost.toLowerCase().contains(ac._originalSearchString.toLowerCase())) {
untrimmedHost = null;
}
ac._result.appendMatch(ac._strippedPrefix + trimmedHost, "", "", "", untrimmedHost);
// handleCompletion() will cause the result listener to be called, and
// will display the result in the UI.
},
handleError: function (aError) {
Components.utils.reportError(
"URL Inline Complete: An async statement encountered an " +
"error: " + aError.result + ", '" + aError.message + "'");
},
handleCompletion: function (aReason) {
TelemetryStopwatch.finish(DOMAIN_QUERY_TELEMETRY);
ac._finishSearch();
return;
}
}, this._db);
this._pendingQuery = wrapper.executeAsync([query]);
// Don't try to autofill if the search term includes any whitespace.
// This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH
// tokenizer ends up trimming the search string and returning a value
// that doesn't match it, or is even shorter.
if (/\s/.test(this._currentSearchString)) {
this._finishSearch();
return;
}
// Hosts have no "/" in them.
let lastSlashIndex = this._currentSearchString.lastIndexOf("/");
// Search only URLs if there's a slash in the search string...
if (lastSlashIndex != -1) {
// ...but not if it's exactly at the end of the search string.
if (lastSlashIndex < this._currentSearchString.length - 1)
this._queryURL();
else
this._finishSearch();
return;
}
// Do a synchronous search on the table of hosts.
let query = this._hostQuery;
query.params.search_string = this._currentSearchString.toLowerCase();
// This is just to measure the delay to reach the UI, not the query time.
TelemetryStopwatch.start(DOMAIN_QUERY_TELEMETRY);
let ac = this;
let wrapper = new AutoCompleteStatementCallbackWrapper(this, {
handleResult: function (aResultSet) {
let row = aResultSet.getNextRow();
let trimmedHost = row.getResultByIndex(0);
let untrimmedHost = row.getResultByIndex(1);
// If the untrimmed value doesn't preserve the user's input just
// ignore it and complete to the found host.
if (untrimmedHost &&
!untrimmedHost.toLowerCase().contains(ac._originalSearchString.toLowerCase())) {
untrimmedHost = null;
}
ac._result.appendMatch(ac._strippedPrefix + trimmedHost, "", "", "", untrimmedHost);
// handleCompletion() will cause the result listener to be called, and
// will display the result in the UI.
},
handleError: function (aError) {
Components.utils.reportError(
"URL Inline Complete: An async statement encountered an " +
"error: " + aError.result + ", '" + aError.message + "'");
},
handleCompletion: function (aReason) {
TelemetryStopwatch.finish(DOMAIN_QUERY_TELEMETRY);
ac._finishSearch();
}
}, this._db);
this._pendingQuery = wrapper.executeAsync([query]);
}.bind(this));
},
/**

Просмотреть файл

@ -0,0 +1,117 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
add_task(function* test_no_keyword() {
Assert.deepEqual({ href: null, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should not exist");
});
add_task(function* test_add_remove() {
let item1 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example1.com/",
keyword: "test" });
Assert.deepEqual({ href: item1.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item1.url.href);
// Add a second url for the same keyword, since it's newer it should be
// returned.
let item2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example2.com/",
keyword: "test" });
Assert.deepEqual({ href: item2.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item2.url.href);
// Now remove item2, should return item1 again.
yield PlacesUtils.bookmarks.remove(item2);
Assert.deepEqual({ href: item1.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item1.url.href);
// Now remove item1, should return null again.
yield PlacesUtils.bookmarks.remove(item1);
Assert.deepEqual({ href: null, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should not exist");
});
add_task(function* test_change_url() {
let item = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example.com/",
keyword: "test" });
Assert.deepEqual({ href: item.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item.url.href);
// Change the bookmark url.
let updatedItem = yield PlacesUtils.bookmarks.update({ guid: item.guid,
url: "http://example2.com" });
Assert.deepEqual({ href: updatedItem.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + updatedItem.url.href);
yield PlacesUtils.bookmarks.remove(updatedItem);
});
add_task(function* test_change_keyword() {
let item = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example.com/",
keyword: "test" });
Assert.deepEqual({ href: item.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item.url.href);
// Change the bookmark keywprd.
let updatedItem = yield PlacesUtils.bookmarks.update({ guid: item.guid,
keyword: "test2" });
Assert.deepEqual({ href: null, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should not exist");
Assert.deepEqual({ href: updatedItem.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test2")),
"Keyword 'test' should point to " + updatedItem.url.href);
// Remove the bookmark keyword.
updatedItem = yield PlacesUtils.bookmarks.update({ guid: item.guid,
keyword: "" });
Assert.deepEqual({ href: null, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should not exist");
Assert.deepEqual({ href: null, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test2")),
"Keyword 'test' should not exist");
yield PlacesUtils.bookmarks.remove(updatedItem);
});
add_task(function* test_postData() {
let item1 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example1.com/",
keyword: "test" });
let itemId1 = yield PlacesUtils.promiseItemId(item1.guid);
PlacesUtils.setPostDataForBookmark(itemId1, "testData");
Assert.deepEqual({ href: item1.url.href, postData: "testData" },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item1.url.href);
// Add a second url for the same keyword, but without postData.
let item2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
url: "http://example2.com/",
keyword: "test" });
Assert.deepEqual({ href: item2.url.href, postData: null },
(yield PlacesUtils.promiseHrefAndPostDataForKeyword("test")),
"Keyword 'test' should point to " + item2.url.href);
yield PlacesUtils.bookmarks.remove(item1);
yield PlacesUtils.bookmarks.remove(item2);
});
function run_test() {
run_next_test();
}

Просмотреть файл

@ -119,6 +119,7 @@ skip-if = true
[test_PlacesSearchAutocompleteProvider.js]
[test_PlacesUtils_asyncGetBookmarkIds.js]
[test_PlacesUtils_lazyobservers.js]
[test_PlacesUtils_promiseHrefAndPostDataForKeyword.js]
[test_placesTxn.js]
[test_preventive_maintenance.js]
# Bug 676989: test hangs consistently on Android

Просмотреть файл

@ -148,34 +148,6 @@ function goOnEvent(aNode, aEvent)
}
}
function visitLink(aEvent) {
var node = aEvent.target;
while (node.nodeType != Node.ELEMENT_NODE)
node = node.parentNode;
var url = node.getAttribute("link");
if (!url)
return;
var protocolSvc = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"]
.getService(Components.interfaces.nsIExternalProtocolService);
var ioService = Components.classes["@mozilla.org/network/io-service;1"]
.getService(Components.interfaces.nsIIOService);
var uri = ioService.newURI(url, null, null);
// if the scheme is not an exposed protocol, then opening this link
// should be deferred to the system's external protocol handler
if (protocolSvc.isExposedProtocol(uri.scheme)) {
var win = window.top;
if (win instanceof Components.interfaces.nsIDOMChromeWindow) {
while (win.opener && !win.opener.closed)
win = win.opener;
}
win.open(uri.spec);
}
else
protocolSvc.loadUrl(uri);
}
function setTooltipText(aID, aTooltipText)
{
var element = document.getElementById(aID);