зеркало из https://github.com/mozilla/gecko-dev.git
Merge fx-team to m-c a=merge
This commit is contained in:
Коммит
a98bb652c6
|
@ -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}>
|
||||
« {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}>
|
||||
« {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);
|
||||
|
|
Загрузка…
Ссылка в новой задаче