diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index 4424cb4d956..2f4bf665812 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -1222,9 +1222,9 @@ var gBrowserInit = { // Delay this a minute because there's no rush setTimeout(() => { this.gmpInstallManager = new GMPInstallManager(); - // We don't really care about the results, if somenoe is interested they + // We don't really care about the results, if someone is interested they // can check the log. - this.gmpInstallManager.simpleCheckAndInstall(); + this.gmpInstallManager.simpleCheckAndInstall().then(null, () => {}); }, 1000 * 60); SessionStore.promiseInitialized.then(() => { diff --git a/browser/base/content/test/general/browser.ini b/browser/base/content/test/general/browser.ini index 974ac8dcf8c..b659c573938 100644 --- a/browser/base/content/test/general/browser.ini +++ b/browser/base/content/test/general/browser.ini @@ -316,7 +316,6 @@ skip-if = e10s # Bug ?????? - test directly manipulates content (tries to grab a [browser_fullscreen-window-open.js] skip-if = buildapp == 'mulet' || e10s || os == "linux" # Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly. Linux: Intermittent failures - bug 941575. [browser_fxa_oauth.js] -skip-if = e10s [browser_gestureSupport.js] skip-if = e10s # Bug 863514 - no gesture support. [browser_getshortcutoruri.js] diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_4.js b/browser/base/content/test/popupNotifications/browser_popupNotification_4.js index 63c79e5f238..c83909374ae 100644 --- a/browser/base/content/test/popupNotifications/browser_popupNotification_4.js +++ b/browser/base/content/test/popupNotifications/browser_popupNotification_4.js @@ -1,7 +1,7 @@ /* 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/. */ - + function test() { waitForExplicitFinish(); @@ -206,5 +206,53 @@ let tests = [ notification.remove(); goNext(); } + }, + // panel updates should fire the showing and shown callbacks again. + { id: "Test#11", + run: function() { + this.notifyObj = new BasicNotification(this.id); + this.notification = showNotification(this.notifyObj); + }, + onShown: function (popup) { + checkPopup(popup, this.notifyObj); + + this.notifyObj.showingCallbackTriggered = false; + this.notifyObj.shownCallbackTriggered = false; + + // Force an update of the panel. This is typically called + // automatically when receiving 'activate' or 'TabSelect' events, + // but from a setTimeout, which is inconvenient for the test. + PopupNotifications._update(); + + checkPopup(popup, this.notifyObj); + + this.notification.remove(); + }, + onHidden: function() { } + }, + // A first dismissed notification shouldn't stop _update from showing a second notification + { id: "Test#12", + run: function () { + this.notifyObj1 = new BasicNotification(this.id); + this.notifyObj1.id += "_1"; + this.notifyObj1.anchorID = "default-notification-icon"; + this.notifyObj1.options.dismissed = true; + this.notification1 = showNotification(this.notifyObj1); + + this.notifyObj2 = new BasicNotification(this.id); + this.notifyObj2.id += "_2"; + this.notifyObj2.anchorID = "geo-notification-icon"; + this.notifyObj2.options.dismissed = true; + this.notification2 = showNotification(this.notifyObj2); + + this.notification2.dismissed = false; + PopupNotifications._update(); + }, + onShown: function (popup) { + checkPopup(popup, this.notifyObj2); + this.notification1.remove(); + this.notification2.remove(); + }, + onHidden: function(popup) { } } ]; diff --git a/browser/components/loop/MozLoopService.jsm b/browser/components/loop/MozLoopService.jsm index 4533a091fbd..58e3f3ff694 100644 --- a/browser/components/loop/MozLoopService.jsm +++ b/browser/components/loop/MozLoopService.jsm @@ -1379,6 +1379,6 @@ this.MozLoopService = { */ hawkRequest: function(sessionType, path, method, payloadObj) { return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch( - error => {this._hawkRequestError(error);}); + error => {MozLoopServiceInternal._hawkRequestError(error);}); }, }; diff --git a/browser/components/loop/content/conversation.html b/browser/components/loop/content/conversation.html index 149ba2e04d7..d673be68c52 100644 --- a/browser/components/loop/content/conversation.html +++ b/browser/components/loop/content/conversation.html @@ -27,13 +27,11 @@ - - diff --git a/browser/components/loop/content/js/conversation.js b/browser/components/loop/content/js/conversation.js index 19cec5ecbce..d94325c51c7 100644 --- a/browser/components/loop/content/js/conversation.js +++ b/browser/components/loop/content/js/conversation.js @@ -8,16 +8,11 @@ /* global loop:true, React */ var loop = loop || {}; -loop.conversation = (function(OT, mozL10n) { +loop.conversation = (function(mozL10n) { "use strict"; - var sharedViews = loop.shared.views; - - /** - * App router. - * @type {loop.desktopRouter.DesktopConversationRouter} - */ - var router; + var sharedViews = loop.shared.views, + sharedModels = loop.shared.models; var IncomingCallView = React.createClass({displayName: 'IncomingCallView', @@ -200,92 +195,183 @@ loop.conversation = (function(OT, mozL10n) { }); /** - * Conversation router. + * This view manages the incoming conversation views - from + * call initiation through to the actual conversation and call end. * - * Required options: - * - {loop.shared.models.ConversationModel} conversation Conversation model. - * - {loop.shared.models.NotificationCollection} notifications - * - * @type {loop.shared.router.BaseConversationRouter} + * At the moment, it does more than that, these parts need refactoring out. */ - var ConversationRouter = loop.desktopRouter.DesktopConversationRouter.extend({ - routes: { - "incoming/:callId": "incoming", - "call/accept": "accept", - "call/decline": "decline", - "call/ongoing": "conversation", - "call/declineAndBlock": "declineAndBlock", - "call/shutdown": "shutdown", - "call/feedback": "feedback" + var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView', + propTypes: { + client: React.PropTypes.instanceOf(loop.Client).isRequired, + conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) + .isRequired, + notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) + .isRequired, + sdk: React.PropTypes.object.isRequired + }, + + getInitialState: function() { + return { + callStatus: "start" + } + }, + + componentDidMount: function() { + this.props.conversation.on("accept", this.accept, this); + this.props.conversation.on("decline", this.decline, this); + this.props.conversation.on("declineAndBlock", this.declineAndBlock, this); + this.props.conversation.on("call:accepted", this.accepted, this); + this.props.conversation.on("change:publishedStream", this._checkConnected, this); + this.props.conversation.on("change:subscribedStream", this._checkConnected, this); + this.props.conversation.on("session:ended", this.endCall, this); + this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this); + this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this); + this.props.conversation.on("session:connection-error", this._notifyError, this); + + this.setupIncomingCall(); + }, + + componentDidUnmount: function() { + this.props.conversation.off(null, null, this); + }, + + render: function() { + switch (this.state.callStatus) { + case "start": { + document.title = mozL10n.get("incoming_call_title2"); + + // XXX Don't render anything initially, though this should probably + // be some sort of pending view, whilst we connect the websocket. + return null; + } + case "incoming": { + document.title = mozL10n.get("incoming_call_title2"); + + return ( + IncomingCallView({ + model: this.props.conversation, + video: this.props.conversation.hasVideoStream("incoming")} + ) + ); + } + case "connected": { + // XXX This should be the caller id (bug 1020449) + document.title = mozL10n.get("incoming_call_title2"); + + var callType = this.props.conversation.get("selectedCallType"); + + return ( + sharedViews.ConversationView({ + initiate: true, + sdk: this.props.sdk, + model: this.props.conversation, + video: {enabled: callType !== "audio"}} + ) + ); + } + case "end": { + document.title = mozL10n.get("conversation_has_ended"); + + var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref( + "feedback.baseUrl"); + + var appVersionInfo = navigator.mozLoop.appVersionInfo; + + var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, { + product: navigator.mozLoop.getLoopCharPref("feedback.product"), + platform: appVersionInfo.OS, + channel: appVersionInfo.channel, + version: appVersionInfo.version + }); + + return ( + sharedViews.FeedbackView({ + feedbackApiClient: feedbackClient, + onAfterFeedbackReceived: this.closeWindow.bind(this)} + ) + ); + } + case "close": { + window.close(); + return (React.DOM.div(null)); + } + } }, /** - * @override {loop.shared.router.BaseConversationRouter.startCall} + * Notify the user that the connection was not possible + * @param {{code: number, message: string}} error */ - startCall: function() { - this.navigate("call/ongoing", {trigger: true}); + _notifyError: function(error) { + console.error(error); + this.props.notifications.errorL10n("connection_error_see_console_notification"); + this.setState({callStatus: "end"}); }, /** - * @override {loop.shared.router.BaseConversationRouter.endCall} + * Peer hung up. Notifies the user and ends the call. + * + * Event properties: + * - {String} connectionId: OT session id */ - endCall: function() { - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); - this.navigate("call/feedback", {trigger: true}); + _onPeerHungup: function() { + this.props.notifications.warnL10n("peer_ended_conversation2"); + this.setState({callStatus: "end"}); }, - shutdown: function() { - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); + /** + * Network disconnected. Notifies the user and ends the call. + */ + _onNetworkDisconnected: function() { + this.props.notifications.warnL10n("network_disconnected"); + this.setState({callStatus: "end"}); }, /** * Incoming call route. - * - * @param {String} callId Identifier assigned by the LoopService - * to this incoming call. */ - incoming: function(callId) { + setupIncomingCall: function() { navigator.mozLoop.startAlerting(); - this._conversation.once("accept", function() { - this.navigate("call/accept", {trigger: true}); - }.bind(this)); - this._conversation.once("decline", function() { - this.navigate("call/decline", {trigger: true}); - }.bind(this)); - this._conversation.once("declineAndBlock", function() { - this.navigate("call/declineAndBlock", {trigger: true}); - }.bind(this)); - this._conversation.once("call:incoming", this.startCall, this); - this._conversation.once("change:publishedStream", this._checkConnected, this); - this._conversation.once("change:subscribedStream", this._checkConnected, this); - var callData = navigator.mozLoop.getCallData(callId); + var callData = navigator.mozLoop.getCallData(this.props.conversation.get("callId")); if (!callData) { console.error("Failed to get the call data"); // XXX Not the ideal response, but bug 1047410 will be replacing // this by better "call failed" UI. - this._notifications.errorL10n("cannot_start_call_session_not_ready"); + this.props.notifications.errorL10n("cannot_start_call_session_not_ready"); return; } - this._conversation.setIncomingSessionData(callData); - this._setupWebSocketAndCallView(); + this.props.conversation.setIncomingSessionData(callData); + this._setupWebSocket(); + }, + + /** + * Starts the actual conversation + */ + accepted: function() { + this.setState({callStatus: "connected"}); + }, + + /** + * Moves the call to the end state + */ + endCall: function() { + navigator.mozLoop.releaseCallData(this.props.conversation.get("callId")); + this.setState({callStatus: "end"}); }, /** * Used to set up the web socket connection and navigate to the * call view if appropriate. */ - _setupWebSocketAndCallView: function() { + _setupWebSocket: function() { this._websocket = new loop.CallConnectionWebSocket({ - url: this._conversation.get("progressURL"), - websocketToken: this._conversation.get("websocketToken"), - callId: this._conversation.get("callId"), + url: this.props.conversation.get("progressURL"), + websocketToken: this.props.conversation.get("websocketToken"), + callId: this.props.conversation.get("callId"), }); this._websocket.promiseConnect().then(function() { - this.loadReactComponent(loop.conversation.IncomingCallView({ - model: this._conversation, - video: this._conversation.hasVideoStream("incoming") - })); + this.setState({callStatus: "incoming"}); }.bind(this), function() { this._handleSessionError(); return; @@ -301,7 +387,7 @@ loop.conversation = (function(OT, mozL10n) { _checkConnected: function() { // Check we've had both local and remote streams connected before // sending the media up message. - if (this._conversation.streamsConnected()) { + if (this.props.conversation.streamsConnected()) { this._websocket.mediaUp(); } }, @@ -337,6 +423,12 @@ loop.conversation = (function(OT, mozL10n) { _abortIncomingCall: function() { navigator.mozLoop.stopAlerting(); this._websocket.close(); + // Having a timeout here lets the logging for the websocket complete and be + // displayed on the console if both are on. + setTimeout(this.closeWindow, 0); + }, + + closeWindow: function() { window.close(); }, @@ -346,7 +438,7 @@ loop.conversation = (function(OT, mozL10n) { accept: function() { navigator.mozLoop.stopAlerting(); this._websocket.accept(); - this._conversation.incoming(); + this.props.conversation.accepted(); }, /** @@ -354,13 +446,11 @@ loop.conversation = (function(OT, mozL10n) { */ _declineCall: function() { this._websocket.decline(); - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); - // XXX Don't close the window straight away, but let any sends happen - // first. Ideally we'd wait to close the window until after we have a - // response from the server, to know that everything has completed - // successfully. However, that's quite difficult to ensure at the - // moment so we'll add it later. - setTimeout(window.close, 0); + navigator.mozLoop.releaseCallData(this.props.conversation.get("callId")); + this._websocket.close(); + // Having a timeout here lets the logging for the websocket complete and be + // displayed on the console if both are on. + setTimeout(this.closeWindow, 0); }, /** @@ -379,8 +469,8 @@ loop.conversation = (function(OT, mozL10n) { */ declineAndBlock: function() { navigator.mozLoop.stopAlerting(); - var token = this._conversation.get("callToken"); - this._client.deleteCallUrl(token, function(error) { + var token = this.props.conversation.get("callToken"); + this.props.client.deleteCallUrl(token, function(error) { // XXX The conversation window will be closed when this cb is triggered // figure out if there is a better way to report the error to the user // (bug 1048909). @@ -389,62 +479,14 @@ loop.conversation = (function(OT, mozL10n) { this._declineCall(); }, - /** - * conversation is the route when the conversation is active. The start - * route should be navigated to first. - */ - conversation: function() { - if (!this._conversation.isSessionReady()) { - console.error("Error: navigated to conversation route without " + - "the start route to initialise the call first"); - this._handleSessionError(); - return; - } - - var callType = this._conversation.get("selectedCallType"); - var videoStream = callType === "audio" ? false : true; - - /*jshint newcap:false*/ - this.loadReactComponent(sharedViews.ConversationView({ - initiate: true, - sdk: OT, - model: this._conversation, - video: {enabled: videoStream} - })); - }, - /** * Handles a error starting the session */ _handleSessionError: function() { // XXX Not the ideal response, but bug 1047410 will be replacing // this by better "call failed" UI. - this._notifications.errorL10n("cannot_start_call_session_not_ready"); + this.props.notifications.errorL10n("cannot_start_call_session_not_ready"); }, - - /** - * Call has ended, display a feedback form. - */ - feedback: function() { - document.title = mozL10n.get("conversation_has_ended"); - - var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref( - "feedback.baseUrl"); - - var appVersionInfo = navigator.mozLoop.appVersionInfo; - - var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, { - product: navigator.mozLoop.getLoopCharPref("feedback.product"), - platform: appVersionInfo.OS, - channel: appVersionInfo.channel, - version: appVersionInfo.version - }); - - this.loadReactComponent(sharedViews.FeedbackView({ - feedbackApiClient: feedbackClient, - onAfterFeedbackReceived: window.close.bind(window) - })); - } }); /** @@ -457,44 +499,50 @@ loop.conversation = (function(OT, mozL10n) { // Plug in an alternate client ID mechanism, as localStorage and cookies // don't work in the conversation window - if (OT && OT.hasOwnProperty("overrideGuidStorage")) { - OT.overrideGuidStorage({ - get: function(callback) { - callback(null, navigator.mozLoop.getLoopCharPref("ot.guid")); - }, - set: function(guid, callback) { - navigator.mozLoop.setLoopCharPref("ot.guid", guid); - callback(null); - } - }); - } - - document.title = mozL10n.get("incoming_call_title2"); + window.OT.overrideGuidStorage({ + get: function(callback) { + callback(null, navigator.mozLoop.getLoopCharPref("ot.guid")); + }, + set: function(guid, callback) { + navigator.mozLoop.setLoopCharPref("ot.guid", guid); + callback(null); + } + }); document.body.classList.add(loop.shared.utils.getTargetPlatform()); var client = new loop.Client(); - router = new ConversationRouter({ - client: client, - conversation: new loop.shared.models.ConversationModel( - {}, // Model attributes - {sdk: OT}), // Model dependencies - notifications: new loop.shared.models.NotificationCollection() - }); + var conversation = new sharedModels.ConversationModel( + {}, // Model attributes + {sdk: window.OT} // Model dependencies + ); + var notifications = new sharedModels.NotificationCollection(); window.addEventListener("unload", function(event) { // Handle direct close of dialog box via [x] control. - navigator.mozLoop.releaseCallData(router._conversation.get("callId")); + navigator.mozLoop.releaseCallData(conversation.get("callId")); }); - Backbone.history.start(); + // Obtain the callId and pass it to the conversation + var helper = new loop.shared.utils.Helper(); + var locationHash = helper.locationHash(); + if (locationHash) { + conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]); + } + + React.renderComponent(IncomingConversationView({ + client: client, + conversation: conversation, + notifications: notifications, + sdk: window.OT} + ), document.querySelector('#main')); } return { - ConversationRouter: ConversationRouter, + IncomingConversationView: IncomingConversationView, IncomingCallView: IncomingCallView, init: init }; -})(window.OT, document.mozL10n); +})(document.mozL10n); document.addEventListener('DOMContentLoaded', loop.conversation.init); diff --git a/browser/components/loop/content/js/conversation.jsx b/browser/components/loop/content/js/conversation.jsx index 27a9797ad74..9adbae36310 100644 --- a/browser/components/loop/content/js/conversation.jsx +++ b/browser/components/loop/content/js/conversation.jsx @@ -8,16 +8,11 @@ /* global loop:true, React */ var loop = loop || {}; -loop.conversation = (function(OT, mozL10n) { +loop.conversation = (function(mozL10n) { "use strict"; - var sharedViews = loop.shared.views; - - /** - * App router. - * @type {loop.desktopRouter.DesktopConversationRouter} - */ - var router; + var sharedViews = loop.shared.views, + sharedModels = loop.shared.models; var IncomingCallView = React.createClass({ @@ -200,92 +195,183 @@ loop.conversation = (function(OT, mozL10n) { }); /** - * Conversation router. + * This view manages the incoming conversation views - from + * call initiation through to the actual conversation and call end. * - * Required options: - * - {loop.shared.models.ConversationModel} conversation Conversation model. - * - {loop.shared.models.NotificationCollection} notifications - * - * @type {loop.shared.router.BaseConversationRouter} + * At the moment, it does more than that, these parts need refactoring out. */ - var ConversationRouter = loop.desktopRouter.DesktopConversationRouter.extend({ - routes: { - "incoming/:callId": "incoming", - "call/accept": "accept", - "call/decline": "decline", - "call/ongoing": "conversation", - "call/declineAndBlock": "declineAndBlock", - "call/shutdown": "shutdown", - "call/feedback": "feedback" + var IncomingConversationView = React.createClass({ + propTypes: { + client: React.PropTypes.instanceOf(loop.Client).isRequired, + conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) + .isRequired, + notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) + .isRequired, + sdk: React.PropTypes.object.isRequired + }, + + getInitialState: function() { + return { + callStatus: "start" + } + }, + + componentDidMount: function() { + this.props.conversation.on("accept", this.accept, this); + this.props.conversation.on("decline", this.decline, this); + this.props.conversation.on("declineAndBlock", this.declineAndBlock, this); + this.props.conversation.on("call:accepted", this.accepted, this); + this.props.conversation.on("change:publishedStream", this._checkConnected, this); + this.props.conversation.on("change:subscribedStream", this._checkConnected, this); + this.props.conversation.on("session:ended", this.endCall, this); + this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this); + this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this); + this.props.conversation.on("session:connection-error", this._notifyError, this); + + this.setupIncomingCall(); + }, + + componentDidUnmount: function() { + this.props.conversation.off(null, null, this); + }, + + render: function() { + switch (this.state.callStatus) { + case "start": { + document.title = mozL10n.get("incoming_call_title2"); + + // XXX Don't render anything initially, though this should probably + // be some sort of pending view, whilst we connect the websocket. + return null; + } + case "incoming": { + document.title = mozL10n.get("incoming_call_title2"); + + return ( + + ); + } + case "connected": { + // XXX This should be the caller id (bug 1020449) + document.title = mozL10n.get("incoming_call_title2"); + + var callType = this.props.conversation.get("selectedCallType"); + + return ( + + ); + } + case "end": { + document.title = mozL10n.get("conversation_has_ended"); + + var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref( + "feedback.baseUrl"); + + var appVersionInfo = navigator.mozLoop.appVersionInfo; + + var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, { + product: navigator.mozLoop.getLoopCharPref("feedback.product"), + platform: appVersionInfo.OS, + channel: appVersionInfo.channel, + version: appVersionInfo.version + }); + + return ( + + ); + } + case "close": { + window.close(); + return (
); + } + } }, /** - * @override {loop.shared.router.BaseConversationRouter.startCall} + * Notify the user that the connection was not possible + * @param {{code: number, message: string}} error */ - startCall: function() { - this.navigate("call/ongoing", {trigger: true}); + _notifyError: function(error) { + console.error(error); + this.props.notifications.errorL10n("connection_error_see_console_notification"); + this.setState({callStatus: "end"}); }, /** - * @override {loop.shared.router.BaseConversationRouter.endCall} + * Peer hung up. Notifies the user and ends the call. + * + * Event properties: + * - {String} connectionId: OT session id */ - endCall: function() { - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); - this.navigate("call/feedback", {trigger: true}); + _onPeerHungup: function() { + this.props.notifications.warnL10n("peer_ended_conversation2"); + this.setState({callStatus: "end"}); }, - shutdown: function() { - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); + /** + * Network disconnected. Notifies the user and ends the call. + */ + _onNetworkDisconnected: function() { + this.props.notifications.warnL10n("network_disconnected"); + this.setState({callStatus: "end"}); }, /** * Incoming call route. - * - * @param {String} callId Identifier assigned by the LoopService - * to this incoming call. */ - incoming: function(callId) { + setupIncomingCall: function() { navigator.mozLoop.startAlerting(); - this._conversation.once("accept", function() { - this.navigate("call/accept", {trigger: true}); - }.bind(this)); - this._conversation.once("decline", function() { - this.navigate("call/decline", {trigger: true}); - }.bind(this)); - this._conversation.once("declineAndBlock", function() { - this.navigate("call/declineAndBlock", {trigger: true}); - }.bind(this)); - this._conversation.once("call:incoming", this.startCall, this); - this._conversation.once("change:publishedStream", this._checkConnected, this); - this._conversation.once("change:subscribedStream", this._checkConnected, this); - var callData = navigator.mozLoop.getCallData(callId); + var callData = navigator.mozLoop.getCallData(this.props.conversation.get("callId")); if (!callData) { console.error("Failed to get the call data"); // XXX Not the ideal response, but bug 1047410 will be replacing // this by better "call failed" UI. - this._notifications.errorL10n("cannot_start_call_session_not_ready"); + this.props.notifications.errorL10n("cannot_start_call_session_not_ready"); return; } - this._conversation.setIncomingSessionData(callData); - this._setupWebSocketAndCallView(); + this.props.conversation.setIncomingSessionData(callData); + this._setupWebSocket(); + }, + + /** + * Starts the actual conversation + */ + accepted: function() { + this.setState({callStatus: "connected"}); + }, + + /** + * Moves the call to the end state + */ + endCall: function() { + navigator.mozLoop.releaseCallData(this.props.conversation.get("callId")); + this.setState({callStatus: "end"}); }, /** * Used to set up the web socket connection and navigate to the * call view if appropriate. */ - _setupWebSocketAndCallView: function() { + _setupWebSocket: function() { this._websocket = new loop.CallConnectionWebSocket({ - url: this._conversation.get("progressURL"), - websocketToken: this._conversation.get("websocketToken"), - callId: this._conversation.get("callId"), + url: this.props.conversation.get("progressURL"), + websocketToken: this.props.conversation.get("websocketToken"), + callId: this.props.conversation.get("callId"), }); this._websocket.promiseConnect().then(function() { - this.loadReactComponent(loop.conversation.IncomingCallView({ - model: this._conversation, - video: this._conversation.hasVideoStream("incoming") - })); + this.setState({callStatus: "incoming"}); }.bind(this), function() { this._handleSessionError(); return; @@ -301,7 +387,7 @@ loop.conversation = (function(OT, mozL10n) { _checkConnected: function() { // Check we've had both local and remote streams connected before // sending the media up message. - if (this._conversation.streamsConnected()) { + if (this.props.conversation.streamsConnected()) { this._websocket.mediaUp(); } }, @@ -337,6 +423,12 @@ loop.conversation = (function(OT, mozL10n) { _abortIncomingCall: function() { navigator.mozLoop.stopAlerting(); this._websocket.close(); + // Having a timeout here lets the logging for the websocket complete and be + // displayed on the console if both are on. + setTimeout(this.closeWindow, 0); + }, + + closeWindow: function() { window.close(); }, @@ -346,7 +438,7 @@ loop.conversation = (function(OT, mozL10n) { accept: function() { navigator.mozLoop.stopAlerting(); this._websocket.accept(); - this._conversation.incoming(); + this.props.conversation.accepted(); }, /** @@ -354,13 +446,11 @@ loop.conversation = (function(OT, mozL10n) { */ _declineCall: function() { this._websocket.decline(); - navigator.mozLoop.releaseCallData(this._conversation.get("callId")); - // XXX Don't close the window straight away, but let any sends happen - // first. Ideally we'd wait to close the window until after we have a - // response from the server, to know that everything has completed - // successfully. However, that's quite difficult to ensure at the - // moment so we'll add it later. - setTimeout(window.close, 0); + navigator.mozLoop.releaseCallData(this.props.conversation.get("callId")); + this._websocket.close(); + // Having a timeout here lets the logging for the websocket complete and be + // displayed on the console if both are on. + setTimeout(this.closeWindow, 0); }, /** @@ -379,8 +469,8 @@ loop.conversation = (function(OT, mozL10n) { */ declineAndBlock: function() { navigator.mozLoop.stopAlerting(); - var token = this._conversation.get("callToken"); - this._client.deleteCallUrl(token, function(error) { + var token = this.props.conversation.get("callToken"); + this.props.client.deleteCallUrl(token, function(error) { // XXX The conversation window will be closed when this cb is triggered // figure out if there is a better way to report the error to the user // (bug 1048909). @@ -389,62 +479,14 @@ loop.conversation = (function(OT, mozL10n) { this._declineCall(); }, - /** - * conversation is the route when the conversation is active. The start - * route should be navigated to first. - */ - conversation: function() { - if (!this._conversation.isSessionReady()) { - console.error("Error: navigated to conversation route without " + - "the start route to initialise the call first"); - this._handleSessionError(); - return; - } - - var callType = this._conversation.get("selectedCallType"); - var videoStream = callType === "audio" ? false : true; - - /*jshint newcap:false*/ - this.loadReactComponent(sharedViews.ConversationView({ - initiate: true, - sdk: OT, - model: this._conversation, - video: {enabled: videoStream} - })); - }, - /** * Handles a error starting the session */ _handleSessionError: function() { // XXX Not the ideal response, but bug 1047410 will be replacing // this by better "call failed" UI. - this._notifications.errorL10n("cannot_start_call_session_not_ready"); + this.props.notifications.errorL10n("cannot_start_call_session_not_ready"); }, - - /** - * Call has ended, display a feedback form. - */ - feedback: function() { - document.title = mozL10n.get("conversation_has_ended"); - - var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref( - "feedback.baseUrl"); - - var appVersionInfo = navigator.mozLoop.appVersionInfo; - - var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, { - product: navigator.mozLoop.getLoopCharPref("feedback.product"), - platform: appVersionInfo.OS, - channel: appVersionInfo.channel, - version: appVersionInfo.version - }); - - this.loadReactComponent(sharedViews.FeedbackView({ - feedbackApiClient: feedbackClient, - onAfterFeedbackReceived: window.close.bind(window) - })); - } }); /** @@ -457,44 +499,50 @@ loop.conversation = (function(OT, mozL10n) { // Plug in an alternate client ID mechanism, as localStorage and cookies // don't work in the conversation window - if (OT && OT.hasOwnProperty("overrideGuidStorage")) { - OT.overrideGuidStorage({ - get: function(callback) { - callback(null, navigator.mozLoop.getLoopCharPref("ot.guid")); - }, - set: function(guid, callback) { - navigator.mozLoop.setLoopCharPref("ot.guid", guid); - callback(null); - } - }); - } - - document.title = mozL10n.get("incoming_call_title2"); + window.OT.overrideGuidStorage({ + get: function(callback) { + callback(null, navigator.mozLoop.getLoopCharPref("ot.guid")); + }, + set: function(guid, callback) { + navigator.mozLoop.setLoopCharPref("ot.guid", guid); + callback(null); + } + }); document.body.classList.add(loop.shared.utils.getTargetPlatform()); var client = new loop.Client(); - router = new ConversationRouter({ - client: client, - conversation: new loop.shared.models.ConversationModel( - {}, // Model attributes - {sdk: OT}), // Model dependencies - notifications: new loop.shared.models.NotificationCollection() - }); + var conversation = new sharedModels.ConversationModel( + {}, // Model attributes + {sdk: window.OT} // Model dependencies + ); + var notifications = new sharedModels.NotificationCollection(); window.addEventListener("unload", function(event) { // Handle direct close of dialog box via [x] control. - navigator.mozLoop.releaseCallData(router._conversation.get("callId")); + navigator.mozLoop.releaseCallData(conversation.get("callId")); }); - Backbone.history.start(); + // Obtain the callId and pass it to the conversation + var helper = new loop.shared.utils.Helper(); + var locationHash = helper.locationHash(); + if (locationHash) { + conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]); + } + + React.renderComponent(, document.querySelector('#main')); } return { - ConversationRouter: ConversationRouter, + IncomingConversationView: IncomingConversationView, IncomingCallView: IncomingCallView, init: init }; -})(window.OT, document.mozL10n); +})(document.mozL10n); document.addEventListener('DOMContentLoaded', loop.conversation.init); diff --git a/browser/components/loop/content/js/desktopRouter.js b/browser/components/loop/content/js/desktopRouter.js deleted file mode 100644 index 8adc7da2d87..00000000000 --- a/browser/components/loop/content/js/desktopRouter.js +++ /dev/null @@ -1,35 +0,0 @@ -/* 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 esnext:true */ -/* global loop:true */ - -var loop = loop || {}; -loop.desktopRouter = (function() { - "use strict"; - - /** - * On the desktop app, the use of about: uris prevents us from changing the - * url of the location. As a result, we change the navigate function to simply - * activate the new routes, and not try changing the url. - * - * XXX It is conceivable we might be able to remove this in future, if we - * can either swap to resource uris or remove the limitation on the about uris. - */ - var extendedRouter = { - navigate: function(to) { - this[this.routes[to]](); - } - }; - - var DesktopRouter = loop.shared.router.BaseRouter.extend(extendedRouter); - - var DesktopConversationRouter = - loop.shared.router.BaseConversationRouter.extend(extendedRouter); - - return { - DesktopRouter: DesktopRouter, - DesktopConversationRouter: DesktopConversationRouter - }; -})(); diff --git a/browser/components/loop/content/js/panel.js b/browser/components/loop/content/js/panel.js index b5c0ebebe5b..e732858c9e0 100644 --- a/browser/components/loop/content/js/panel.js +++ b/browser/components/loop/content/js/panel.js @@ -17,12 +17,6 @@ loop.panel = (function(_, mozL10n) { var ContactsList = loop.contacts.ContactsList; var __ = mozL10n.get; // aliasing translation function as __ for concision - /** - * Panel router. - * @type {loop.desktopRouter.DesktopRouter} - */ - var router; - var TabView = React.createClass({displayName: 'TabView', getInitialState: function() { return { diff --git a/browser/components/loop/content/js/panel.jsx b/browser/components/loop/content/js/panel.jsx index 4a8f364c3fd..52224a31ac2 100644 --- a/browser/components/loop/content/js/panel.jsx +++ b/browser/components/loop/content/js/panel.jsx @@ -17,12 +17,6 @@ loop.panel = (function(_, mozL10n) { var ContactsList = loop.contacts.ContactsList; var __ = mozL10n.get; // aliasing translation function as __ for concision - /** - * Panel router. - * @type {loop.desktopRouter.DesktopRouter} - */ - var router; - var TabView = React.createClass({ getInitialState: function() { return { diff --git a/browser/components/loop/content/shared/js/models.js b/browser/components/loop/content/shared/js/models.js index 63092d9f83c..66258d04248 100644 --- a/browser/components/loop/content/shared/js/models.js +++ b/browser/components/loop/content/shared/js/models.js @@ -77,10 +77,10 @@ loop.shared.models = (function(l10n) { }, /** - * Starts an incoming conversation. + * Indicates an incoming conversation has been accepted. */ - incoming: function() { - this.trigger("call:incoming"); + accepted: function() { + this.trigger("call:accepted"); }, /** diff --git a/browser/components/loop/content/shared/js/router.js b/browser/components/loop/content/shared/js/router.js deleted file mode 100644 index 6329ea31ab8..00000000000 --- a/browser/components/loop/content/shared/js/router.js +++ /dev/null @@ -1,153 +0,0 @@ -/* 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.shared = loop.shared || {}; -loop.shared.router = (function() { - "use strict"; - - /** - * Base Router. Allows defining a main active view and ease toggling it when - * the active route changes. - * - * @link http://mikeygee.com/blog/backbone.html - */ - var BaseRouter = Backbone.Router.extend({ - /** - * Notifications collection. - * @type {loop.shared.models.NotificationCollection} - */ - _notifications: undefined, - - /** - * Constructor. - * - * Required options: - * - {loop.shared.models.NotificationCollection} notifications - * - * @param {Object} options Options object. - */ - constructor: function(options) { - options = options || {}; - if (!options.notifications) { - throw new Error("missing required notifications"); - } - this._notifications = options.notifications; - - Backbone.Router.apply(this, arguments); - }, - - /** - * Renders a React component as current active view. - * - * @param {React} reactComponent React component. - */ - loadReactComponent: function(reactComponent) { - this.clearActiveView(); - React.renderComponent(reactComponent, - document.querySelector("#main")); - }, - - /** - * Clears current active view. - */ - clearActiveView: function() { - React.unmountComponentAtNode(document.querySelector("#main")); - } - }); - - /** - * Base conversation router, implementing common behaviors when handling - * a conversation. - */ - var BaseConversationRouter = BaseRouter.extend({ - /** - * Current conversation. - * @type {loop.shared.models.ConversationModel} - */ - _conversation: undefined, - - /** - * Constructor. Defining it as `constructor` allows implementing an - * `initialize` method in child classes without needing calling this parent - * one. See http://backbonejs.org/#Model-constructor (same for Router) - * - * Required options: - * - {loop.shared.model.ConversationModel} model Conversation model. - * - * @param {Object} options Options object. - */ - constructor: function(options) { - options = options || {}; - if (!options.conversation) { - throw new Error("missing required conversation"); - } - if (!options.client) { - throw new Error("missing required client"); - } - this._conversation = options.conversation; - this._client = options.client; - - this.listenTo(this._conversation, "session:ended", this._onSessionEnded); - this.listenTo(this._conversation, "session:peer-hungup", - this._onPeerHungup); - this.listenTo(this._conversation, "session:network-disconnected", - this._onNetworkDisconnected); - this.listenTo(this._conversation, "session:connection-error", - this._notifyError); - - BaseRouter.apply(this, arguments); - }, - - /** - * Notify the user that the connection was not possible - * @param {{code: number, message: string}} error - */ - _notifyError: function(error) { - console.log(error); - this._notifications.errorL10n("connection_error_see_console_notification"); - this.endCall(); - }, - - /** - * Ends the call. This method should be overriden. - */ - endCall: function() {}, - - /** - * Session has ended. Notifies the user and ends the call. - */ - _onSessionEnded: function() { - this.endCall(); - }, - - /** - * Peer hung up. Notifies the user and ends the call. - * - * Event properties: - * - {String} connectionId: OT session id - * - * @param {Object} event - */ - _onPeerHungup: function() { - this._notifications.warnL10n("peer_ended_conversation2"); - this.endCall(); - }, - - /** - * Network disconnected. Notifies the user and ends the call. - */ - _onNetworkDisconnected: function() { - this._notifications.warnL10n("network_disconnected"); - this.endCall(); - } - }); - - return { - BaseRouter: BaseRouter, - BaseConversationRouter: BaseConversationRouter - }; -})(); diff --git a/browser/components/loop/content/shared/js/utils.js b/browser/components/loop/content/shared/js/utils.js index 84186bba084..80478f0d13e 100644 --- a/browser/components/loop/content/shared/js/utils.js +++ b/browser/components/loop/content/shared/js/utils.js @@ -46,7 +46,29 @@ loop.shared.utils = (function() { return !!localStorage.getItem(prefName); } + /** + * Helper for general things + */ + function Helper() { + this._iOSRegex = /^(iPad|iPhone|iPod)/; + } + + Helper.prototype = { + isFirefox: function(platform) { + return platform.indexOf("Firefox") !== -1; + }, + + isIOS: function(platform) { + return this._iOSRegex.test(platform); + }, + + locationHash: function() { + return window.location.hash; + } + }; + return { + Helper: Helper, getTargetPlatform: getTargetPlatform, getBoolPreference: getBoolPreference }; diff --git a/browser/components/loop/jar.mn b/browser/components/loop/jar.mn index 464379e145e..3efd6567574 100644 --- a/browser/components/loop/jar.mn +++ b/browser/components/loop/jar.mn @@ -12,7 +12,6 @@ browser.jar: # Desktop script content/browser/loop/js/client.js (content/js/client.js) - content/browser/loop/js/desktopRouter.js (content/js/desktopRouter.js) content/browser/loop/js/conversation.js (content/js/conversation.js) content/browser/loop/js/otconfig.js (content/js/otconfig.js) content/browser/loop/js/panel.js (content/js/panel.js) @@ -55,7 +54,6 @@ browser.jar: # Shared scripts content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js) content/browser/loop/shared/js/models.js (content/shared/js/models.js) - content/browser/loop/shared/js/router.js (content/shared/js/router.js) content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js) content/browser/loop/shared/js/views.js (content/shared/js/views.js) content/browser/loop/shared/js/utils.js (content/shared/js/utils.js) diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js index fffb8295e5d..732aaca3dc8 100644 --- a/browser/components/loop/standalone/content/js/webapp.js +++ b/browser/components/loop/standalone/content/js/webapp.js @@ -15,7 +15,8 @@ loop.webapp = (function($, _, OT, mozL10n) { loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000"; var sharedModels = loop.shared.models, - sharedViews = loop.shared.views; + sharedViews = loop.shared.views, + sharedUtils = loop.shared.utils; /** * Homepage view. @@ -314,7 +315,7 @@ loop.webapp = (function($, _, OT, mozL10n) { var privacy_notice_name = mozL10n.get("privacy_notice_link_text"); var tosHTML = mozL10n.get("legal_text_and_links", { - "terms_of_use_url": "" + + "terms_of_use_url": "" + tos_link_name + "", "privacy_notice_url": "" + privacy_notice_name + "" @@ -435,7 +436,7 @@ loop.webapp = (function($, _, OT, mozL10n) { client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired, conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) .isRequired, - helper: React.PropTypes.instanceOf(WebappHelper).isRequired, + helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired, notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) .isRequired, sdk: React.PropTypes.object.isRequired, @@ -690,7 +691,7 @@ loop.webapp = (function($, _, OT, mozL10n) { client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired, conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) .isRequired, - helper: React.PropTypes.instanceOf(WebappHelper).isRequired, + helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired, notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) .isRequired, sdk: React.PropTypes.object.isRequired, @@ -726,32 +727,11 @@ loop.webapp = (function($, _, OT, mozL10n) { } }); - /** - * Local helpers. - */ - function WebappHelper() { - this._iOSRegex = /^(iPad|iPhone|iPod)/; - } - - WebappHelper.prototype = { - isFirefox: function(platform) { - return platform.indexOf("Firefox") !== -1; - }, - - isIOS: function(platform) { - return this._iOSRegex.test(platform); - }, - - locationHash: function() { - return window.location.hash; - } - }; - /** * App initialization. */ function init() { - var helper = new WebappHelper(); + var helper = new sharedUtils.Helper(); var client = new loop.StandaloneClient({ baseServerUrl: loop.config.serverUrl }); @@ -797,7 +777,6 @@ loop.webapp = (function($, _, OT, mozL10n) { UnsupportedDeviceView: UnsupportedDeviceView, init: init, PromoteFirefoxView: PromoteFirefoxView, - WebappHelper: WebappHelper, WebappRootView: WebappRootView }; })(jQuery, _, window.OT, navigator.mozL10n); diff --git a/browser/components/loop/standalone/content/js/webapp.jsx b/browser/components/loop/standalone/content/js/webapp.jsx index 0630e824745..a681d2f1f22 100644 --- a/browser/components/loop/standalone/content/js/webapp.jsx +++ b/browser/components/loop/standalone/content/js/webapp.jsx @@ -15,7 +15,8 @@ loop.webapp = (function($, _, OT, mozL10n) { loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000"; var sharedModels = loop.shared.models, - sharedViews = loop.shared.views; + sharedViews = loop.shared.views, + sharedUtils = loop.shared.utils; /** * Homepage view. @@ -314,7 +315,7 @@ loop.webapp = (function($, _, OT, mozL10n) { var privacy_notice_name = mozL10n.get("privacy_notice_link_text"); var tosHTML = mozL10n.get("legal_text_and_links", { - "terms_of_use_url": "" + + "terms_of_use_url": "" + tos_link_name + "", "privacy_notice_url": "" + privacy_notice_name + "" @@ -435,7 +436,7 @@ loop.webapp = (function($, _, OT, mozL10n) { client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired, conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) .isRequired, - helper: React.PropTypes.instanceOf(WebappHelper).isRequired, + helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired, notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) .isRequired, sdk: React.PropTypes.object.isRequired, @@ -690,7 +691,7 @@ loop.webapp = (function($, _, OT, mozL10n) { client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired, conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel) .isRequired, - helper: React.PropTypes.instanceOf(WebappHelper).isRequired, + helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired, notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection) .isRequired, sdk: React.PropTypes.object.isRequired, @@ -726,32 +727,11 @@ loop.webapp = (function($, _, OT, mozL10n) { } }); - /** - * Local helpers. - */ - function WebappHelper() { - this._iOSRegex = /^(iPad|iPhone|iPod)/; - } - - WebappHelper.prototype = { - isFirefox: function(platform) { - return platform.indexOf("Firefox") !== -1; - }, - - isIOS: function(platform) { - return this._iOSRegex.test(platform); - }, - - locationHash: function() { - return window.location.hash; - } - }; - /** * App initialization. */ function init() { - var helper = new WebappHelper(); + var helper = new sharedUtils.Helper(); var client = new loop.StandaloneClient({ baseServerUrl: loop.config.serverUrl }); @@ -797,7 +777,6 @@ loop.webapp = (function($, _, OT, mozL10n) { UnsupportedDeviceView: UnsupportedDeviceView, init: init, PromoteFirefoxView: PromoteFirefoxView, - WebappHelper: WebappHelper, WebappRootView: WebappRootView }; })(jQuery, _, window.OT, navigator.mozL10n); diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index c4ba9e7f899..218d3b1848e 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -9,10 +9,24 @@ var expect = chai.expect; describe("loop.conversation", function() { "use strict"; - var ConversationRouter = loop.conversation.ConversationRouter, + var sharedModels = loop.shared.models, + sharedView = loop.shared.views, sandbox, notifications; + // XXX refactor to Just Work with "sandbox.stubComponent" or else + // just pass in the sandbox and put somewhere generally usable + + function stubComponent(obj, component, mockTagName){ + var reactClass = React.createClass({ + render: function() { + var mockTagName = mockTagName || "div"; + return React.DOM[mockTagName](null, this.props.children); + } + }); + return sandbox.stub(obj, component, reactClass); + } + beforeEach(function() { sandbox = sinon.sandbox.create(); sandbox.useFakeTimers(); @@ -26,14 +40,14 @@ describe("loop.conversation", function() { get locale() { return "en-US"; }, - setLoopCharPref: sandbox.stub(), - getLoopCharPref: sandbox.stub(), - getLoopBoolPref: sandbox.stub(), - getCallData: sandbox.stub(), - releaseCallData: function() {}, - startAlerting: function() {}, - stopAlerting: function() {}, - ensureRegistered: function() {}, + setLoopCharPref: sinon.stub(), + getLoopCharPref: sinon.stub(), + getLoopBoolPref: sinon.stub(), + getCallData: sinon.stub(), + releaseCallData: sinon.stub(), + startAlerting: sinon.stub(), + stopAlerting: sinon.stub(), + ensureRegistered: sinon.stub(), get appVersionInfo() { return { version: "42", @@ -57,21 +71,19 @@ describe("loop.conversation", function() { var oldTitle; beforeEach(function() { - oldTitle = document.title; - + sandbox.stub(React, "renderComponent"); sandbox.stub(document.mozL10n, "initialize"); - sandbox.stub(document.mozL10n, "get").returns("Fake title"); - sandbox.stub(loop.conversation.ConversationRouter.prototype, - "initialize"); sandbox.stub(loop.shared.models.ConversationModel.prototype, "initialize"); - sandbox.stub(Backbone.history, "start"); + window.OT = { + overrideGuidStorage: sinon.stub() + }; }); afterEach(function() { - document.title = oldTitle; + delete window.OT; }); it("should initalize L10n", function() { @@ -82,300 +94,256 @@ describe("loop.conversation", function() { navigator.mozLoop); }); - it("should set the document title", function() { + it("should create the IncomingConversationView", function() { loop.conversation.init(); - expect(document.title).to.be.equal("Fake title"); + sinon.assert.calledOnce(React.renderComponent); + sinon.assert.calledWith(React.renderComponent, + sinon.match(function(value) { + return TestUtils.isDescriptorOfType(value, + loop.conversation.IncomingConversationView); + })); }); - it("should create the router", function() { - loop.conversation.init(); - - sinon.assert.calledOnce( - loop.conversation.ConversationRouter.prototype.initialize); - }); - - it("should start Backbone history", function() { - loop.conversation.init(); - - sinon.assert.calledOnce(Backbone.history.start); - }); }); - describe("ConversationRouter", function() { - var conversation, client; + describe("IncomingConversationView", function() { + var conversation, client, icView, oldTitle; + + function mountTestComponent() { + return TestUtils.renderIntoDocument( + loop.conversation.IncomingConversationView({ + client: client, + conversation: conversation, + notifications: notifications, + sdk: {} + })); + } beforeEach(function() { + oldTitle = document.title; client = new loop.Client(); conversation = new loop.shared.models.ConversationModel({}, { sdk: {} }); - sandbox.spy(conversation, "setIncomingSessionData"); + conversation.set({callId: 42}); sandbox.stub(conversation, "setOutgoingSessionData"); }); - describe("Routes", function() { - var router; + afterEach(function() { + icView = undefined; + document.title = oldTitle; + }); + + describe("start", function() { + it("should set the title to incoming_call_title2", function() { + sandbox.stub(document.mozL10n, "get", function(x) { + return x; + }); + + icView = mountTestComponent(); + + expect(document.title).eql("incoming_call_title2"); + }); + }); + + describe("componentDidMount", function() { + var fakeSessionData; beforeEach(function() { - router = new ConversationRouter({ - client: client, - conversation: conversation, - notifications: notifications - }); - sandbox.stub(conversation, "incoming"); + fakeSessionData = { + sessionId: "sessionId", + sessionToken: "sessionToken", + apiKey: "apiKey", + callType: "callType", + callId: "Hello", + progressURL: "http://progress.example.com", + websocketToken: "7b" + }; + + navigator.mozLoop.getCallData.returns(fakeSessionData); + stubComponent(loop.conversation, "IncomingCallView"); + stubComponent(sharedView, "ConversationView"); }); - describe("#incoming", function() { + it("should start alerting", function() { + icView = mountTestComponent(); - // XXX refactor to Just Work with "sandbox.stubComponent" or else - // just pass in the sandbox and put somewhere generally usable + sinon.assert.calledOnce(navigator.mozLoop.startAlerting); + }); - function stubComponent(obj, component, mockTagName){ - var reactClass = React.createClass({ - render: function() { - var mockTagName = mockTagName || "div"; - return React.DOM[mockTagName](null, this.props.children); - } - }); - return sandbox.stub(obj, component, reactClass); - } + it("should call getCallData on navigator.mozLoop", function() { + icView = mountTestComponent(); - beforeEach(function() { - sandbox.stub(router, "loadReactComponent"); - stubComponent(loop.conversation, "IncomingCallView"); - }); + sinon.assert.calledOnce(navigator.mozLoop.getCallData); + sinon.assert.calledWith(navigator.mozLoop.getCallData, 42); + }); - it("should start alerting", function() { - sandbox.stub(navigator.mozLoop, "startAlerting"); - router.incoming("fakeVersion"); - - sinon.assert.calledOnce(navigator.mozLoop.startAlerting); - }); - - it("should call getCallData on navigator.mozLoop", - function() { - router.incoming(42); - - sinon.assert.calledOnce(navigator.mozLoop.getCallData); - sinon.assert.calledWith(navigator.mozLoop.getCallData, 42); - }); - - describe("getCallData successful", function() { - var fakeSessionData, resolvePromise, rejectPromise; + describe("getCallData successful", function() { + var promise, resolveWebSocketConnect, + rejectWebSocketConnect; + describe("Session Data setup", function() { beforeEach(function() { - fakeSessionData = { - sessionId: "sessionId", - sessionToken: "sessionToken", - apiKey: "apiKey", - callType: "callType", - callId: "Hello", - progressURL: "http://progress.example.com", - websocketToken: 123 - }; - - sandbox.stub(router, "_setupWebSocketAndCallView"); - - navigator.mozLoop.getCallData.returns(fakeSessionData); + sandbox.stub(loop, "CallConnectionWebSocket").returns({ + promiseConnect: function () { + promise = new Promise(function(resolve, reject) { + resolveWebSocketConnect = resolve; + rejectWebSocketConnect = reject; + }); + return promise; + }, + on: sinon.stub() + }); }); it("should store the session data", function() { - router.incoming("fakeVersion"); + sandbox.stub(conversation, "setIncomingSessionData"); + + icView = mountTestComponent(); sinon.assert.calledOnce(conversation.setIncomingSessionData); sinon.assert.calledWithExactly(conversation.setIncomingSessionData, fakeSessionData); }); - it("should call #_setupWebSocketAndCallView", function() { + it("should setup the websocket connection", function() { + icView = mountTestComponent(); - router.incoming("fakeVersion"); - - sinon.assert.calledOnce(router._setupWebSocketAndCallView); - sinon.assert.calledWithExactly(router._setupWebSocketAndCallView); + sinon.assert.calledOnce(loop.CallConnectionWebSocket); + sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, { + callId: "Hello", + url: "http://progress.example.com", + websocketToken: "7b" + }); }); }); - describe("#_setupWebSocketAndCallView", function() { + describe("WebSocket Handling", function() { beforeEach(function() { - conversation.setIncomingSessionData({ - sessionId: "sessionId", - sessionToken: "sessionToken", - apiKey: "apiKey", - callType: "callType", - callId: "Hello", - progressURL: "http://progress.example.com", - websocketToken: 123 + promise = new Promise(function(resolve, reject) { + resolveWebSocketConnect = resolve; + rejectWebSocketConnect = reject; + }); + + sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect").returns(promise); + }); + + it("should set the state to incoming on success", function(done) { + icView = mountTestComponent(); + resolveWebSocketConnect(); + + promise.then(function () { + expect(icView.state.callStatus).eql("incoming"); + done(); }); }); - describe("Websocket connection successful", function() { - var promise; + it("should display an error if the websocket failed to connect", function(done) { + sandbox.stub(notifications, "errorL10n"); + icView = mountTestComponent(); + rejectWebSocketConnect(); + + promise.then(function() { + }, function () { + sinon.assert.calledOnce(notifications.errorL10n); + sinon.assert.calledWithExactly(notifications.errorL10n, + "cannot_start_call_session_not_ready"); + done(); + }); + }); + }); + + describe("WebSocket Events", function() { + describe("Call cancelled or timed out before acceptance", function() { beforeEach(function() { - sandbox.stub(loop, "CallConnectionWebSocket").returns({ - promiseConnect: function() { - promise = new Promise(function(resolve, reject) { - resolve(); + icView = mountTestComponent(); + promise = new Promise(function(resolve, reject) { + resolve(); + }); + + sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect").returns(promise); + sandbox.stub(loop.CallConnectionWebSocket.prototype, "close"); + sandbox.stub(window, "close"); + }); + + describe("progress - terminated - cancel", function() { + it("should stop alerting", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "cancel" }); - return promise; - }, - on: sinon.spy() + + sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); + done(); + }); + }); + + it("should close the websocket", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "cancel" + }); + + sinon.assert.calledOnce(icView._websocket.close); + done(); + }); + }); + + it("should close the window", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "cancel" + }); + + sandbox.clock.tick(1); + + sinon.assert.calledOnce(window.close); + done(); + }); }); }); - it("should create a CallConnectionWebSocket", function(done) { - router._setupWebSocketAndCallView(); + describe("progress - terminated - timeout (previousState = alerting)", function() { + it("should stop alerting", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "timeout" + }, "alerting"); - promise.then(function () { - sinon.assert.calledOnce(loop.CallConnectionWebSocket); - sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, { - callId: "Hello", - url: "http://progress.example.com", - // The websocket token is converted to a hex string. - websocketToken: "7b" - }); - done(); - }); - }); - - it("should create the view with video=false", function(done) { - sandbox.stub(conversation, "get").withArgs("callType").returns("audio"); - - router._setupWebSocketAndCallView(); - - promise.then(function () { - sinon.assert.called(conversation.get); - sinon.assert.calledOnce(loop.conversation.IncomingCallView); - sinon.assert.calledWithExactly(loop.conversation.IncomingCallView, - {model: conversation, - video: false}); - done(); - }); - }); - }); - - describe("Websocket connection failed", function() { - var promise; - - beforeEach(function() { - sandbox.stub(loop, "CallConnectionWebSocket").returns({ - promiseConnect: function() { - promise = new Promise(function(resolve, reject) { - reject(); - }); - return promise; - }, - on: sinon.spy() - }); - }); - - it("should display an error", function(done) { - sandbox.stub(notifications, "errorL10n"); - router._setupWebSocketAndCallView(); - - promise.then(function() { - }, function () { - sinon.assert.calledOnce(router._notifications.errorL10n); - sinon.assert.calledWithExactly(router._notifications.errorL10n, - "cannot_start_call_session_not_ready"); - done(); - }); - }); - }); - - describe("Events", function() { - describe("Call cancelled or timed out before acceptance", function() { - var promise; - - beforeEach(function() { - sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect", function() { - promise = new Promise(function(resolve, reject) { - resolve(); - }); - return promise; - }); - sandbox.stub(loop.CallConnectionWebSocket.prototype, "close"); - sandbox.stub(navigator.mozLoop, "stopAlerting"); - sandbox.stub(window, "close"); - - router._setupWebSocketAndCallView(); - }); - - describe("progress - terminated - cancel", function() { - it("should stop alerting", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "cancel" - }); - - sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); - done(); - }); - }); - - it("should close the websocket", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "cancel" - }); - - sinon.assert.calledOnce(router._websocket.close); - done(); - }); - }); - - it("should close the window", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "cancel" - }); - - sinon.assert.calledOnce(window.close); - done(); - }); + sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); + done(); }); }); - describe("progress - terminated - timeout (previousState = alerting)", function() { - it("should stop alerting", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "timeout" - }, "alerting"); + it("should close the websocket", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "timeout" + }, "alerting"); - sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); - done(); - }); + sinon.assert.calledOnce(icView._websocket.close); + done(); }); + }); - it("should close the websocket", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "timeout" - }, "alerting"); + it("should close the window", function(done) { + promise.then(function() { + icView._websocket.trigger("progress", { + state: "terminated", + reason: "timeout" + }, "alerting"); - sinon.assert.calledOnce(router._websocket.close); - done(); - }); - }); + sandbox.clock.tick(1); - it("should close the window", function(done) { - promise.then(function() { - router._websocket.trigger("progress", { - state: "terminated", - reason: "timeout" - }, "alerting"); - - sinon.assert.calledOnce(window.close); - done(); - }); + sinon.assert.calledOnce(window.close); + done(); }); }); }); @@ -385,6 +353,7 @@ describe("loop.conversation", function() { describe("#accept", function() { beforeEach(function() { + icView = mountTestComponent(); conversation.setIncomingSessionData({ sessionId: "sessionId", sessionToken: "sessionToken", @@ -394,72 +363,38 @@ describe("loop.conversation", function() { progressURL: "http://progress.example.com", websocketToken: 123 }); - router._setupWebSocketAndCallView(); - sandbox.stub(router._websocket, "accept"); - sandbox.stub(navigator.mozLoop, "stopAlerting"); + sandbox.stub(icView._websocket, "accept"); + sandbox.stub(icView.props.conversation, "accepted"); }); it("should initiate the conversation", function() { - router.accept(); + icView.accept(); - sinon.assert.calledOnce(conversation.incoming); + sinon.assert.calledOnce(icView.props.conversation.accepted); }); it("should notify the websocket of the user acceptance", function() { - router.accept(); + icView.accept(); - sinon.assert.calledOnce(router._websocket.accept); + sinon.assert.calledOnce(icView._websocket.accept); }); it("should stop alerting", function() { - router.accept(); + icView.accept(); sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); }); }); - describe("#conversation", function() { - beforeEach(function() { - sandbox.stub(router, "loadReactComponent"); - }); - - it("should load the ConversationView if session is set", function() { - conversation.set("sessionId", "fakeSessionId"); - - router.conversation(); - - sinon.assert.calledOnce(router.loadReactComponent); - sinon.assert.calledWith(router.loadReactComponent, - sinon.match(function(value) { - return TestUtils.isDescriptorOfType(value, - loop.shared.views.ConversationView); - })); - }); - - it("should not load the ConversationView if session is not set", - function() { - router.conversation(); - - sinon.assert.notCalled(router.loadReactComponent); - }); - - it("should notify the user when session is not set", - function() { - sandbox.stub(notifications, "errorL10n"); - router.conversation(); - - sinon.assert.calledOnce(router._notifications.errorL10n); - sinon.assert.calledWithExactly(router._notifications.errorL10n, - "cannot_start_call_session_not_ready"); - }); - }); - describe("#decline", function() { beforeEach(function() { + icView = mountTestComponent(); + sandbox.stub(window, "close"); - router._websocket = { - decline: sandbox.spy() + icView._websocket = { + decline: sinon.stub(), + close: sinon.stub() }; conversation.setIncomingSessionData({ callId: 8699, @@ -468,76 +403,40 @@ describe("loop.conversation", function() { }); it("should close the window", function() { - router.decline(); + icView.decline(); + sandbox.clock.tick(1); sinon.assert.calledOnce(window.close); }); it("should stop alerting", function() { - sandbox.stub(navigator.mozLoop, "stopAlerting"); - router.decline(); + icView.decline(); sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); }); it("should release callData", function() { - sandbox.stub(navigator.mozLoop, "releaseCallData"); - router.decline(); + icView.decline(); sinon.assert.calledOnce(navigator.mozLoop.releaseCallData); sinon.assert.calledWithExactly(navigator.mozLoop.releaseCallData, 8699); }); }); - describe("#feedback", function() { - var oldTitle; - - beforeEach(function() { - oldTitle = document.title; - sandbox.stub(document.mozL10n, "get").returns("Call ended"); - }); - - beforeEach(function() { - sandbox.stub(loop, "FeedbackAPIClient"); - sandbox.stub(router, "loadReactComponent"); - }); - - afterEach(function() { - document.title = oldTitle; - }); - - // XXX When the call is ended gracefully, we should check that we - // close connections nicely (see bug 1046744) - it("should display a feedback form view", function() { - router.feedback(); - - sinon.assert.calledOnce(router.loadReactComponent); - sinon.assert.calledWith(router.loadReactComponent, - sinon.match(function(value) { - return TestUtils.isDescriptorOfType(value, - loop.shared.views.FeedbackView); - })); - }); - - it("should update the conversation window title", function() { - router.feedback(); - - expect(document.title).eql("Call ended"); - }); - }); - describe("#blocked", function() { beforeEach(function() { - router._websocket = { - decline: sandbox.spy() + icView = mountTestComponent(); + + icView._websocket = { + decline: sinon.spy(), + close: sinon.stub() }; sandbox.stub(window, "close"); }); it("should call mozLoop.stopAlerting", function() { - sandbox.stub(navigator.mozLoop, "stopAlerting"); - router.declineAndBlock(); + icView.declineAndBlock(); sinon.assert.calledOnce(navigator.mozLoop.stopAlerting); }); @@ -547,7 +446,7 @@ describe("loop.conversation", function() { .returns("fakeToken"); var deleteCallUrl = sandbox.stub(loop.Client.prototype, "deleteCallUrl"); - router.declineAndBlock(); + icView.declineAndBlock(); sinon.assert.calledOnce(deleteCallUrl); sinon.assert.calledWithExactly(deleteCallUrl, "fakeToken", @@ -556,7 +455,7 @@ describe("loop.conversation", function() { it("should get callToken from conversation model", function() { sandbox.stub(conversation, "get"); - router.declineAndBlock(); + icView.declineAndBlock(); sinon.assert.calledTwice(conversation.get); sinon.assert.calledWithExactly(conversation.get, "callToken"); @@ -572,14 +471,14 @@ describe("loop.conversation", function() { sandbox.stub(loop.Client.prototype, "deleteCallUrl", function(_, cb) { cb(fakeError); }); - router.declineAndBlock(); + icView.declineAndBlock(); sinon.assert.calledOnce(log); sinon.assert.calledWithExactly(log, fakeError); }); it("should close the window", function() { - router.declineAndBlock(); + icView.declineAndBlock(); sandbox.clock.tick(1); @@ -589,63 +488,66 @@ describe("loop.conversation", function() { }); describe("Events", function() { - var router, fakeSessionData; + var fakeSessionData; beforeEach(function() { + icView = mountTestComponent(); + fakeSessionData = { sessionId: "sessionId", sessionToken: "sessionToken", apiKey: "apiKey" }; - sandbox.stub(loop.conversation.ConversationRouter.prototype, - "navigate"); conversation.set("loopToken", "fakeToken"); - router = new loop.conversation.ConversationRouter({ - client: client, - conversation: conversation, - notifications: notifications - }); + navigator.mozLoop.getLoopCharPref.returns("http://fake"); + stubComponent(sharedView, "ConversationView"); }); - it("should navigate to call/ongoing once the call is ready", - function() { - router.incoming(42); + describe("call:accepted", function() { + it("should display the ConversationView", + function() { + conversation.accepted(); - conversation.incoming(); - - sinon.assert.calledOnce(router.navigate); - sinon.assert.calledWith(router.navigate, "call/ongoing"); - }); - - it("should navigate to call/feedback when the call session ends", - function() { - conversation.trigger("session:ended"); - - sinon.assert.calledOnce(router.navigate); - sinon.assert.calledWith(router.navigate, "call/feedback"); - }); - - it("should navigate to call/feedback when peer hangs up", function() { - conversation.trigger("session:peer-hungup"); - - sinon.assert.calledOnce(router.navigate); - sinon.assert.calledWith(router.navigate, "call/feedback"); + TestUtils.findRenderedComponentWithType(icView, + sharedView.ConversationView); + }); }); - it("should navigate to call/feedback when network disconnects", - function() { - conversation.trigger("session:network-disconnected"); + describe("session:ended", function() { + it("should display the feedback view when the call session ends", + function() { + conversation.trigger("session:ended"); - sinon.assert.calledOnce(router.navigate); - sinon.assert.calledWith(router.navigate, "call/feedback"); - }); + TestUtils.findRenderedComponentWithType(icView, + sharedView.FeedbackView); + }); + }); + + describe("session:peer-hungup", function() { + it("should display the feedback view when the peer hangs up", + function() { + conversation.trigger("session:peer-hungup"); + + TestUtils.findRenderedComponentWithType(icView, + sharedView.FeedbackView); + }); + }); + + describe("session:peer-hungup", function() { + it("should navigate to call/feedback when network disconnects", + function() { + conversation.trigger("session:network-disconnected"); + + TestUtils.findRenderedComponentWithType(icView, + sharedView.FeedbackView); + }); + }); describe("Published and Subscribed Streams", function() { beforeEach(function() { - router._websocket = { + icView._websocket = { mediaUp: sinon.spy() }; - router.incoming("fakeVersion"); }); describe("publishStream", function() { @@ -653,7 +555,7 @@ describe("loop.conversation", function() { function() { conversation.set("publishedStream", true); - sinon.assert.notCalled(router._websocket.mediaUp); + sinon.assert.notCalled(icView._websocket.mediaUp); }); it("should notify the websocket that media is up if both streams" + @@ -661,7 +563,7 @@ describe("loop.conversation", function() { conversation.set("subscribedStream", true); conversation.set("publishedStream", true); - sinon.assert.calledOnce(router._websocket.mediaUp); + sinon.assert.calledOnce(icView._websocket.mediaUp); }); }); @@ -670,7 +572,7 @@ describe("loop.conversation", function() { function() { conversation.set("subscribedStream", true); - sinon.assert.notCalled(router._websocket.mediaUp); + sinon.assert.notCalled(icView._websocket.mediaUp); }); it("should notify the websocket that media is up if both streams" + @@ -678,7 +580,7 @@ describe("loop.conversation", function() { conversation.set("publishedStream", true); conversation.set("subscribedStream", true); - sinon.assert.calledOnce(router._websocket.mediaUp); + sinon.assert.calledOnce(icView._websocket.mediaUp); }); }); }); diff --git a/browser/components/loop/test/desktop-local/index.html b/browser/components/loop/test/desktop-local/index.html index ec7dc942e68..477a47a701f 100644 --- a/browser/components/loop/test/desktop-local/index.html +++ b/browser/components/loop/test/desktop-local/index.html @@ -35,12 +35,10 @@ - - diff --git a/browser/components/loop/test/desktop-local/panel_test.js b/browser/components/loop/test/desktop-local/panel_test.js index 4e49057c4b4..4f1ab8f5efd 100644 --- a/browser/components/loop/test/desktop-local/panel_test.js +++ b/browser/components/loop/test/desktop-local/panel_test.js @@ -13,13 +13,6 @@ describe("loop.panel", function() { var sandbox, notifications, fakeXHR, requests = []; - function createTestRouter(fakeDocument) { - return new loop.panel.PanelRouter({ - notifications: notifications, - document: fakeDocument - }); - } - beforeEach(function() { sandbox = sinon.sandbox.create(); fakeXHR = sandbox.useFakeXMLHttpRequest(); diff --git a/browser/components/loop/test/shared/index.html b/browser/components/loop/test/shared/index.html index 8261dcb2b24..908b910276b 100644 --- a/browser/components/loop/test/shared/index.html +++ b/browser/components/loop/test/shared/index.html @@ -37,7 +37,6 @@ - @@ -46,7 +45,6 @@ - - - + + + diff --git a/browser/devtools/webaudioeditor/test/head.js b/browser/devtools/webaudioeditor/test/head.js index d088c36a3cf..d792cbb9b8a 100644 --- a/browser/devtools/webaudioeditor/test/head.js +++ b/browser/devtools/webaudioeditor/test/head.js @@ -28,6 +28,7 @@ const MEDIA_NODES_URL = EXAMPLE_URL + "doc_media-node-creation.html"; const BUFFER_AND_ARRAY_URL = EXAMPLE_URL + "doc_buffer-and-array.html"; const DESTROY_NODES_URL = EXAMPLE_URL + "doc_destroy-nodes.html"; const CONNECT_TOGGLE_URL = EXAMPLE_URL + "doc_connect-toggle.html"; +const CONNECT_TOGGLE_PARAM_URL = EXAMPLE_URL + "doc_connect-toggle-param.html"; const CONNECT_PARAM_URL = EXAMPLE_URL + "doc_connect-param.html"; const CONNECT_MULTI_PARAM_URL = EXAMPLE_URL + "doc_connect-multi-param.html"; const IFRAME_CONTEXT_URL = EXAMPLE_URL + "doc_iframe-context.html"; @@ -37,7 +38,10 @@ waitForExplicitFinish(); let gToolEnabled = Services.prefs.getBoolPref("devtools.webaudioeditor.enabled"); +gDevTools.testing = true; + registerCleanupFunction(() => { + gDevTools.testing = false; info("finish() was called, cleaning up..."); Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging); Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", gToolEnabled); @@ -215,10 +219,7 @@ function waitForGraphRendered (front, nodeCount, edgeCount, paramEdgeCount) { let deferred = Promise.defer(); let eventName = front.EVENTS.UI_GRAPH_RENDERED; front.on(eventName, function onGraphRendered (_, nodes, edges, pEdges) { - info(nodes); - info(edges) - info(pEdges); - let paramEdgesDone = paramEdgeCount ? paramEdgeCount === pEdges : true; + let paramEdgesDone = paramEdgeCount != null ? paramEdgeCount === pEdges : true; if (nodes === nodeCount && edges === edgeCount && paramEdgesDone) { front.off(eventName, onGraphRendered); deferred.resolve(); diff --git a/browser/devtools/webaudioeditor/views/context.js b/browser/devtools/webaudioeditor/views/context.js new file mode 100644 index 00000000000..139047c6c2a --- /dev/null +++ b/browser/devtools/webaudioeditor/views/context.js @@ -0,0 +1,305 @@ +/* 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/. */ +"use strict"; + +const { debounce } = require("sdk/lang/functional"); + +// Globals for d3 stuff +// Default properties of the graph on rerender +const GRAPH_DEFAULTS = { + translate: [20, 20], + scale: 1 +}; + +// Sizes of SVG arrows in graph +const ARROW_HEIGHT = 5; +const ARROW_WIDTH = 8; + +// Styles for markers as they cannot be done with CSS. +const MARKER_STYLING = { + light: "#AAA", + dark: "#CED3D9" +}; + +const GRAPH_DEBOUNCE_TIMER = 100; + +// `gAudioNodes` events that should require the graph +// to redraw +const GRAPH_REDRAW_EVENTS = ["add", "connect", "disconnect", "remove"]; + +/** + * Functions handling the graph UI. + */ +let ContextView = { + /** + * Initialization function, called when the tool is started. + */ + initialize: function() { + this._onGraphNodeClick = this._onGraphNodeClick.bind(this); + this._onThemeChange = this._onThemeChange.bind(this); + this._onNodeSelect = this._onNodeSelect.bind(this); + this._onStartContext = this._onStartContext.bind(this); + this._onEvent = this._onEvent.bind(this); + + this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER); + $('#graph-target').addEventListener('click', this._onGraphNodeClick, false); + + window.on(EVENTS.THEME_CHANGE, this._onThemeChange); + window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect); + window.on(EVENTS.START_CONTEXT, this._onStartContext); + gAudioNodes.on("*", this._onEvent); + }, + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function() { + // If the graph was rendered at all, then the handler + // for zooming in will be set. We must remove it to prevent leaks. + if (this._zoomBinding) { + this._zoomBinding.on("zoom", null); + } + $('#graph-target').removeEventListener('click', this._onGraphNodeClick, false); + window.off(EVENTS.THEME_CHANGE, this._onThemeChange); + window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect); + window.off(EVENTS.START_CONTEXT, this._onStartContext); + gAudioNodes.off("*", this._onEvent); + }, + + /** + * Called when a page is reloaded and waiting for a "start-context" event + * and clears out old content + */ + resetUI: function () { + this.clearGraph(); + this.resetGraphTransform(); + }, + + /** + * Clears out the rendered graph, called when resetting the SVG elements to draw again, + * or when resetting the entire UI tool + */ + clearGraph: function () { + $("#graph-target").innerHTML = ""; + }, + + /** + * Moves the graph back to its original scale and translation. + */ + resetGraphTransform: function () { + // Only reset if the graph was ever drawn. + if (this._zoomBinding) { + let { translate, scale } = GRAPH_DEFAULTS; + // Must set the `zoomBinding` so the next `zoom` event is in sync with + // where the graph is visually (set by the `transform` attribute). + this._zoomBinding.scale(scale); + this._zoomBinding.translate(translate); + d3.select("#graph-target") + .attr("transform", "translate(" + translate + ") scale(" + scale + ")"); + } + }, + + getCurrentScale: function () { + return this._zoomBinding ? this._zoomBinding.scale() : null; + }, + + getCurrentTranslation: function () { + return this._zoomBinding ? this._zoomBinding.translate() : null; + }, + + /** + * Makes the corresponding graph node appear "focused", removing + * focused styles from all other nodes. If no `actorID` specified, + * make all nodes appear unselected. + * Called from UI_INSPECTOR_NODE_SELECT. + */ + focusNode: function (actorID) { + // Remove class "selected" from all nodes + Array.forEach($$(".nodes > g"), $node => $node.classList.remove("selected")); + // Add to "selected" + if (actorID) { + this._getNodeByID(actorID).classList.add("selected"); + } + }, + + /** + * Takes an actorID and returns the corresponding DOM SVG element in the graph + */ + _getNodeByID: function (actorID) { + return $(".nodes > g[data-id='" + actorID + "']"); + }, + + /** + * This method renders the nodes currently available in `gAudioNodes` and is + * throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds. + * It's called whenever the audio context routing changes, after being debounced. + */ + draw: function () { + // Clear out previous SVG information + this.clearGraph(); + + let graph = new dagreD3.Digraph(); + let renderer = new dagreD3.Renderer(); + gAudioNodes.populateGraph(graph); + + // Post-render manipulation of the nodes + let oldDrawNodes = renderer.drawNodes(); + renderer.drawNodes(function(graph, root) { + let svgNodes = oldDrawNodes(graph, root); + svgNodes.attr("class", (n) => { + let node = graph.node(n); + return "audionode type-" + node.type; + }); + svgNodes.attr("data-id", (n) => { + let node = graph.node(n); + return node.id; + }); + return svgNodes; + }); + + // Post-render manipulation of edges + // TODO do all of this more efficiently, rather than + // using the direct D3 helper utilities to loop over each + // edge several times + let oldDrawEdgePaths = renderer.drawEdgePaths(); + renderer.drawEdgePaths(function(graph, root) { + let svgEdges = oldDrawEdgePaths(graph, root); + svgEdges.attr("data-source", (n) => { + let edge = graph.edge(n); + return edge.source; + }); + svgEdges.attr("data-target", (n) => { + let edge = graph.edge(n); + return edge.target; + }); + svgEdges.attr("data-param", (n) => { + let edge = graph.edge(n); + return edge.param ? edge.param : null; + }); + // We have to manually specify the default classes on the edges + // as to not overwrite them + let defaultClasses = "edgePath enter"; + svgEdges.attr("class", (n) => { + let edge = graph.edge(n); + return defaultClasses + (edge.param ? (" param-connection " + edge.param) : ""); + }); + + return svgEdges; + }); + + // Override Dagre-d3's post render function by passing in our own. + // This way we can leave styles out of it. + renderer.postRender((graph, root) => { + // We have to manually set the marker styling since we cannot + // do this currently with CSS, although it is in spec for SVG2 + // https://svgwg.org/svg2-draft/painting.html#VertexMarkerProperties + // For now, manually set it on creation, and the `_onThemeChange` + // function will fire when the devtools theme changes to update the + // styling manually. + let theme = Services.prefs.getCharPref("devtools.theme"); + let markerColor = MARKER_STYLING[theme]; + if (graph.isDirected() && root.select("#arrowhead").empty()) { + root + .append("svg:defs") + .append("svg:marker") + .attr("id", "arrowhead") + .attr("viewBox", "0 0 10 10") + .attr("refX", ARROW_WIDTH) + .attr("refY", ARROW_HEIGHT) + .attr("markerUnits", "strokewidth") + .attr("markerWidth", ARROW_WIDTH) + .attr("markerHeight", ARROW_HEIGHT) + .attr("orient", "auto") + .attr("style", "fill: " + markerColor) + .append("svg:path") + .attr("d", "M 0 0 L 10 5 L 0 10 z"); + } + + // Reselect the previously selected audio node + let currentNode = InspectorView.getCurrentAudioNode(); + if (currentNode) { + this.focusNode(currentNode.id); + } + + // Fire an event upon completed rendering, with extra information + // if in testing mode only. + let info = {}; + if (gDevTools.testing) { + info = gAudioNodes.getInfo(); + } + window.emit(EVENTS.UI_GRAPH_RENDERED, info.nodes, info.edges, info.paramEdges); + }); + + let layout = dagreD3.layout().rankDir("LR"); + renderer.layout(layout).run(graph, d3.select("#graph-target")); + + // Handle the sliding and zooming of the graph, + // store as `this._zoomBinding` so we can unbind during destruction + if (!this._zoomBinding) { + this._zoomBinding = d3.behavior.zoom().on("zoom", function () { + var ev = d3.event; + d3.select("#graph-target") + .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); + }); + d3.select("svg").call(this._zoomBinding); + + // Set initial translation and scale -- this puts D3's awareness of + // the graph in sync with what the user sees originally. + this.resetGraphTransform(); + } + }, + + /** + * Event handlers + */ + + /** + * Called once "start-context" is fired, indicating that there is an audio + * context being created to view so render the graph. + */ + _onStartContext: function () { + this.draw(); + }, + + /** + * Called when `gAudioNodes` fires an event -- most events (listed + * in GRAPH_REDRAW_EVENTS) qualify as a redraw event. + */ + _onEvent: function (eventName, ...args) { + if (~GRAPH_REDRAW_EVENTS.indexOf(eventName)) { + this.draw(); + } + }, + + _onNodeSelect: function (eventName, id) { + this.focusNode(id); + }, + + /** + * Fired when the devtools theme changes. + */ + _onThemeChange: function (eventName, theme) { + let markerColor = MARKER_STYLING[theme]; + let marker = $("#arrowhead"); + if (marker) { + marker.setAttribute("style", "fill: " + markerColor); + } + }, + + /** + * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane. + * + * @param Event e + * Click event. + */ + _onGraphNodeClick: function (e) { + let node = findGraphNodeParent(e.target); + // If node not found (clicking outside of an audio node in the graph), + // then ignore this event + if (!node) + return; + + window.emit(EVENTS.UI_SELECT_NODE, node.getAttribute("data-id")); + } +}; diff --git a/browser/devtools/webaudioeditor/views/inspector.js b/browser/devtools/webaudioeditor/views/inspector.js new file mode 100644 index 00000000000..0a807550d86 --- /dev/null +++ b/browser/devtools/webaudioeditor/views/inspector.js @@ -0,0 +1,240 @@ +/* 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/. */ +"use strict"; + +Cu.import("resource:///modules/devtools/VariablesView.jsm"); +Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); + +// Strings for rendering +const EXPAND_INSPECTOR_STRING = L10N.getStr("expandInspector"); +const COLLAPSE_INSPECTOR_STRING = L10N.getStr("collapseInspector"); + +// Store width as a preference rather than hardcode +// TODO bug 1009056 +const INSPECTOR_WIDTH = 300; + +const GENERIC_VARIABLES_VIEW_SETTINGS = { + searchEnabled: false, + editableValueTooltip: "", + editableNameTooltip: "", + preventDisableOnChange: true, + preventDescriptorModifiers: false, + eval: () => {} +}; + +/** + * Functions handling the audio node inspector UI. + */ + +let InspectorView = { + _currentNode: null, + + // Set up config for view toggling + _collapseString: COLLAPSE_INSPECTOR_STRING, + _expandString: EXPAND_INSPECTOR_STRING, + _toggleEvent: EVENTS.UI_INSPECTOR_TOGGLED, + _animated: true, + _delayed: true, + + /** + * Initialization function called when the tool starts up. + */ + initialize: function () { + this._tabsPane = $("#web-audio-editor-tabs"); + + // Set up view controller + this.el = $("#web-audio-inspector"); + this.el.setAttribute("width", INSPECTOR_WIDTH); + this.button = $("#inspector-pane-toggle"); + mixin(this, ToggleMixin); + this.bindToggle(); + + // Hide inspector view on startup + this.hideImmediately(); + + this._onEval = this._onEval.bind(this); + this._onNodeSelect = this._onNodeSelect.bind(this); + this._onDestroyNode = this._onDestroyNode.bind(this); + + this._propsView = new VariablesView($("#properties-tabpanel-content"), GENERIC_VARIABLES_VIEW_SETTINGS); + this._propsView.eval = this._onEval; + + window.on(EVENTS.UI_SELECT_NODE, this._onNodeSelect); + gAudioNodes.on("remove", this._onDestroyNode); + }, + + /** + * Destruction function called when the tool cleans up. + */ + destroy: function () { + this.unbindToggle(); + window.off(EVENTS.UI_SELECT_NODE, this._onNodeSelect); + gAudioNodes.off("remove", this._onDestroyNode); + + this.el = null; + this.button = null; + this._tabsPane = null; + }, + + /** + * Takes a AudioNodeView `node` and sets it as the current + * node and scaffolds the inspector view based off of the new node. + */ + setCurrentAudioNode: function (node) { + this._currentNode = node || null; + + // If no node selected, set the inspector back to "no AudioNode selected" + // view. + if (!node) { + $("#web-audio-editor-details-pane-empty").removeAttribute("hidden"); + $("#web-audio-editor-tabs").setAttribute("hidden", "true"); + window.emit(EVENTS.UI_INSPECTOR_NODE_SET, null); + } + // Otherwise load up the tabs view and hide the empty placeholder + else { + $("#web-audio-editor-details-pane-empty").setAttribute("hidden", "true"); + $("#web-audio-editor-tabs").removeAttribute("hidden"); + this._setTitle(); + this._buildPropertiesView() + .then(() => window.emit(EVENTS.UI_INSPECTOR_NODE_SET, this._currentNode.id)); + } + }, + + /** + * Returns the current AudioNodeView. + */ + getCurrentAudioNode: function () { + return this._currentNode; + }, + + /** + * Empties out the props view. + */ + resetUI: function () { + this._propsView.empty(); + // Set current node to empty to load empty view + this.setCurrentAudioNode(); + + // Reset AudioNode inspector and hide + this.hideImmediately(); + }, + + /** + * Sets the title of the Inspector view + */ + _setTitle: function () { + let node = this._currentNode; + let title = node.type.replace(/Node$/, ""); + $("#web-audio-inspector-title").setAttribute("value", title); + }, + + /** + * Reconstructs the `Properties` tab in the inspector + * with the `this._currentNode` as it's source. + */ + _buildPropertiesView: Task.async(function* () { + let propsView = this._propsView; + let node = this._currentNode; + propsView.empty(); + + let audioParamsScope = propsView.addScope("AudioParams"); + let props = yield node.getParams(); + + // Disable AudioParams VariableView expansion + // when there are no props i.e. AudioDestinationNode + this._togglePropertiesView(!!props.length); + + props.forEach(({ param, value, flags }) => { + let descriptor = { + value: value, + writable: !flags || !flags.readonly, + }; + audioParamsScope.addItem(param, descriptor); + }); + + audioParamsScope.expanded = true; + + window.emit(EVENTS.UI_PROPERTIES_TAB_RENDERED, node.id); + }), + + _togglePropertiesView: function (show) { + let propsView = $("#properties-tabpanel-content"); + let emptyView = $("#properties-tabpanel-content-empty"); + (show ? propsView : emptyView).removeAttribute("hidden"); + (show ? emptyView : propsView).setAttribute("hidden", "true"); + }, + + /** + * Returns the scope for AudioParams in the + * VariablesView. + * + * @return Scope + */ + _getAudioPropertiesScope: function () { + return this._propsView.getScopeAtIndex(0); + }, + + /** + * Event handlers + */ + + /** + * Executed when an audio prop is changed in the UI. + */ + _onEval: Task.async(function* (variable, value) { + let ownerScope = variable.ownerView; + let node = this._currentNode; + let propName = variable.name; + let error; + + if (!variable._initialDescriptor.writable) { + error = new Error("Variable " + propName + " is not writable."); + } else { + // Cast value to proper type + try { + let number = parseFloat(value); + if (!isNaN(number)) { + value = number; + } else { + value = JSON.parse(value); + } + error = yield node.actor.setParam(propName, value); + } + catch (e) { + error = e; + } + } + + // TODO figure out how to handle and display set prop errors + // and enable `test/brorwser_wa_properties-view-edit.js` + // Bug 994258 + if (!error) { + ownerScope.get(propName).setGrip(value); + window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); + } else { + window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); + } + }), + + /** + * Called on EVENTS.UI_SELECT_NODE, and takes an actorID `id` + * and calls `setCurrentAudioNode` to scaffold the inspector view. + */ + _onNodeSelect: function (_, id) { + this.setCurrentAudioNode(gAudioNodes.get(id)); + + // Ensure inspector is visible when selecting a new node + this.show(); + }, + + /** + * Called when `DESTROY_NODE` is fired to remove the node from props view if + * it's currently selected. + */ + _onDestroyNode: function (node) { + if (this._currentNode && this._currentNode.id === node.id) { + this.setCurrentAudioNode(null); + } + } +}; diff --git a/browser/devtools/webaudioeditor/views/utils.js b/browser/devtools/webaudioeditor/views/utils.js new file mode 100644 index 00000000000..c397a16cb11 --- /dev/null +++ b/browser/devtools/webaudioeditor/views/utils.js @@ -0,0 +1,103 @@ +/* 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/. */ +"use strict"; + +/** + * Takes an element in an SVG graph and iterates over + * ancestors until it finds the graph node container. If not found, + * returns null. + */ + +function findGraphNodeParent (el) { + // Some targets may not contain `classList` property + if (!el.classList) + return null; + + while (!el.classList.contains("nodes")) { + if (el.classList.contains("audionode")) + return el; + else + el = el.parentNode; + } + return null; +} + +/** + * Object for use with `mix` into a view. + * Must have the following properties defined on the view: + * - `el` + * - `button` + * - `_collapseString` + * - `_expandString` + * - `_toggleEvent` + * + * Optional properties on the view can be defined to specify default + * visibility options. + * - `_animated` + * - `_delayed` + */ +let ToggleMixin = { + + bindToggle: function () { + this._onToggle = this._onToggle.bind(this); + this.button.addEventListener("mousedown", this._onToggle, false); + }, + + unbindToggle: function () { + this.button.removeEventListener("mousedown", this._onToggle); + }, + + show: function () { + this._viewController({ visible: true }); + }, + + hide: function () { + this._viewController({ visible: false }); + }, + + hideImmediately: function () { + this._viewController({ visible: false, delayed: false, animated: false }); + }, + + /** + * Returns a boolean indicating whether or not the view. + * is currently being shown. + */ + isVisible: function () { + return !this.el.hasAttribute("pane-collapsed"); + }, + + /** + * Toggles the visibility of the view. + * + * @param object visible + * - visible: boolean indicating whether the panel should be shown or not + * - animated: boolean indiciating whether the pane should be animated + * - delayed: boolean indicating whether the pane's opening should wait + * a few cycles or not + */ + _viewController: function ({ visible, animated, delayed }) { + let flags = { + visible: visible, + animated: animated != null ? animated : !!this._animated, + delayed: delayed != null ? delayed : !!this._delayed, + callback: () => window.emit(this._toggleEvent, visible) + }; + + ViewHelpers.togglePane(flags, this.el); + + if (flags.visible) { + this.button.removeAttribute("pane-collapsed"); + this.button.setAttribute("tooltiptext", this._collapseString); + } + else { + this.button.setAttribute("pane-collapsed", ""); + this.button.setAttribute("tooltiptext", this._expandString); + } + }, + + _onToggle: function () { + this._viewController({ visible: !this.isVisible() }); + } +} diff --git a/browser/devtools/webaudioeditor/webaudioeditor-controller.js b/browser/devtools/webaudioeditor/webaudioeditor-controller.js deleted file mode 100644 index ab071add288..00000000000 --- a/browser/devtools/webaudioeditor/webaudioeditor-controller.js +++ /dev/null @@ -1,428 +0,0 @@ -/* 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/. */ -"use strict"; - -const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; - -Cu.import("resource://gre/modules/Services.jsm"); -Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); -Cu.import("resource:///modules/devtools/gDevTools.jsm"); - -// Override DOM promises with Promise.jsm helpers -const { defer, all } = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise; - -const { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); -const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; -const EventEmitter = require("devtools/toolkit/event-emitter"); -const STRINGS_URI = "chrome://browser/locale/devtools/webaudioeditor.properties" -const L10N = new ViewHelpers.L10N(STRINGS_URI); -const Telemetry = require("devtools/shared/telemetry"); -const telemetry = new Telemetry(); - -let { console } = Cu.import("resource://gre/modules/devtools/Console.jsm", {}); - -// The panel's window global is an EventEmitter firing the following events: -const EVENTS = { - // Fired when the first AudioNode has been created, signifying - // that the AudioContext is being used and should be tracked via the editor. - START_CONTEXT: "WebAudioEditor:StartContext", - - // On node creation, connect and disconnect. - CREATE_NODE: "WebAudioEditor:CreateNode", - CONNECT_NODE: "WebAudioEditor:ConnectNode", - DISCONNECT_NODE: "WebAudioEditor:DisconnectNode", - - // When a node gets GC'd. - DESTROY_NODE: "WebAudioEditor:DestroyNode", - - // On a node parameter's change. - CHANGE_PARAM: "WebAudioEditor:ChangeParam", - - // When the devtools theme changes. - THEME_CHANGE: "WebAudioEditor:ThemeChange", - - // When the UI is reset from tab navigation. - UI_RESET: "WebAudioEditor:UIReset", - - // When a param has been changed via the UI and successfully - // pushed via the actor to the raw audio node. - UI_SET_PARAM: "WebAudioEditor:UISetParam", - - // When a node is to be set in the InspectorView. - UI_SELECT_NODE: "WebAudioEditor:UISelectNode", - - // When the inspector is finished setting a new node. - UI_INSPECTOR_NODE_SET: "WebAudioEditor:UIInspectorNodeSet", - - // When the inspector is finished rendering in or out of view. - UI_INSPECTOR_TOGGLED: "WebAudioEditor:UIInspectorToggled", - - // When an audio node is finished loading in the Properties tab. - UI_PROPERTIES_TAB_RENDERED: "WebAudioEditor:UIPropertiesTabRendered", - - // When the Audio Context graph finishes rendering. - // Is called with two arguments, first representing number of nodes - // rendered, second being the number of edge connections rendering (not counting - // param edges), followed by the count of the param edges rendered. - UI_GRAPH_RENDERED: "WebAudioEditor:UIGraphRendered" -}; - -/** - * The current target and the Web Audio Editor front, set by this tool's host. - */ -let gToolbox, gTarget, gFront; - -/** - * Track an array of audio nodes - */ -let AudioNodes = []; -let AudioNodeConnections = new WeakMap(); // > -let AudioParamConnections = new WeakMap(); // - -// Light representation wrapping an AudioNode actor with additional properties -function AudioNodeView (actor) { - this.actor = actor; - this.id = actor.actorID; -} - -// A proxy for the underlying AudioNodeActor to fetch its type -// and subsequently assign the type to the instance. -AudioNodeView.prototype.getType = Task.async(function* () { - this.type = yield this.actor.getType(); - return this.type; -}); - -// Helper method to create connections in the AudioNodeConnections -// WeakMap for rendering. Returns a boolean indicating -// if the connection was successfully created. Will return `false` -// when the connection was previously made. -AudioNodeView.prototype.connect = function (destination) { - let connections = AudioNodeConnections.get(this) || new Set(); - AudioNodeConnections.set(this, connections); - - // Don't duplicate add. - if (!connections.has(destination)) { - connections.add(destination); - return true; - } - return false; -}; - -// Helper method to create connections in the AudioNodeConnections -// WeakMap for rendering. Returns a boolean indicating -// if the connection was successfully created. Will return `false` -// when the connection was previously made. -AudioNodeView.prototype.connectParam = function (destination, param) { - let connections = AudioParamConnections.get(this) || {}; - AudioParamConnections.set(this, connections); - - let params = connections[destination.id] = connections[destination.id] || []; - - if (!~params.indexOf(param)) { - params.push(param); - return true; - } - return false; -}; - -// Helper method to remove audio connections from the current AudioNodeView -AudioNodeView.prototype.disconnect = function () { - AudioNodeConnections.set(this, new Set()); - AudioParamConnections.set(this, {}); -}; - -// Returns a promise that resolves to an array of objects containing -// both a `param` name property and a `value` property. -AudioNodeView.prototype.getParams = function () { - return this.actor.getParams(); -}; - - -/** - * Initializes the web audio editor views - */ -function startupWebAudioEditor() { - return all([ - WebAudioEditorController.initialize(), - WebAudioGraphView.initialize(), - WebAudioInspectorView.initialize(), - ]); -} - -/** - * Destroys the web audio editor controller and views. - */ -function shutdownWebAudioEditor() { - return all([ - WebAudioEditorController.destroy(), - WebAudioGraphView.destroy(), - WebAudioInspectorView.destroy(), - ]); -} - -/** - * Functions handling target-related lifetime events. - */ -let WebAudioEditorController = { - /** - * Listen for events emitted by the current tab target. - */ - initialize: function() { - telemetry.toolOpened("webaudioeditor"); - this._onTabNavigated = this._onTabNavigated.bind(this); - this._onThemeChange = this._onThemeChange.bind(this); - gTarget.on("will-navigate", this._onTabNavigated); - gTarget.on("navigate", this._onTabNavigated); - gFront.on("start-context", this._onStartContext); - gFront.on("create-node", this._onCreateNode); - gFront.on("connect-node", this._onConnectNode); - gFront.on("connect-param", this._onConnectParam); - gFront.on("disconnect-node", this._onDisconnectNode); - gFront.on("change-param", this._onChangeParam); - gFront.on("destroy-node", this._onDestroyNode); - - // Hook into theme change so we can change - // the graph's marker styling, since we can't do this - // with CSS - gDevTools.on("pref-changed", this._onThemeChange); - - // Set up events to refresh the Graph view - window.on(EVENTS.CREATE_NODE, this._onUpdatedContext); - window.on(EVENTS.CONNECT_NODE, this._onUpdatedContext); - window.on(EVENTS.DISCONNECT_NODE, this._onUpdatedContext); - window.on(EVENTS.DESTROY_NODE, this._onUpdatedContext); - window.on(EVENTS.CONNECT_PARAM, this._onUpdatedContext); - }, - - /** - * Remove events emitted by the current tab target. - */ - destroy: function() { - telemetry.toolClosed("webaudioeditor"); - gTarget.off("will-navigate", this._onTabNavigated); - gTarget.off("navigate", this._onTabNavigated); - gFront.off("start-context", this._onStartContext); - gFront.off("create-node", this._onCreateNode); - gFront.off("connect-node", this._onConnectNode); - gFront.off("connect-param", this._onConnectParam); - gFront.off("disconnect-node", this._onDisconnectNode); - gFront.off("change-param", this._onChangeParam); - gFront.off("destroy-node", this._onDestroyNode); - window.off(EVENTS.CREATE_NODE, this._onUpdatedContext); - window.off(EVENTS.CONNECT_NODE, this._onUpdatedContext); - window.off(EVENTS.DISCONNECT_NODE, this._onUpdatedContext); - window.off(EVENTS.DESTROY_NODE, this._onUpdatedContext); - window.off(EVENTS.CONNECT_PARAM, this._onUpdatedContext); - gDevTools.off("pref-changed", this._onThemeChange); - }, - - /** - * Called when page is reloaded to show the reload notice and waiting - * for an audio context notice. - */ - reset: function () { - $("#content").hidden = true; - WebAudioGraphView.resetUI(); - WebAudioInspectorView.resetUI(); - }, - - /** - * Called when a new audio node is created, or the audio context - * routing changes. - */ - _onUpdatedContext: function () { - WebAudioGraphView.draw(); - }, - - /** - * Fired when the devtools theme changes (light, dark, etc.) - * so that the graph can update marker styling, as that - * cannot currently be done with CSS. - */ - _onThemeChange: function (event, data) { - window.emit(EVENTS.THEME_CHANGE, data.newValue); - }, - - /** - * Called for each location change in the debugged tab. - */ - _onTabNavigated: Task.async(function* (event, {isFrameSwitching}) { - switch (event) { - case "will-navigate": { - // Make sure the backend is prepared to handle audio contexts. - if (!isFrameSwitching) { - yield gFront.setup({ reload: false }); - } - - // Clear out current UI. - this.reset(); - - // When switching to an iframe, ensure displaying the reload button. - // As the document has already been loaded without being hooked. - if (isFrameSwitching) { - $("#reload-notice").hidden = false; - $("#waiting-notice").hidden = true; - } else { - // Otherwise, we are loading a new top level document, - // so we don't need to reload anymore and should receive - // new node events. - $("#reload-notice").hidden = true; - $("#waiting-notice").hidden = false; - } - - // Clear out stored audio nodes - AudioNodes.length = 0; - AudioNodeConnections.clear(); - window.emit(EVENTS.UI_RESET); - break; - } - case "navigate": { - // TODO Case of bfcache, needs investigating - // bug 994250 - break; - } - } - }), - - /** - * Called after the first audio node is created in an audio context, - * signaling that the audio context is being used. - */ - _onStartContext: function() { - $("#reload-notice").hidden = true; - $("#waiting-notice").hidden = true; - $("#content").hidden = false; - window.emit(EVENTS.START_CONTEXT); - }, - - /** - * Called when a new node is created. Creates an `AudioNodeView` instance - * for tracking throughout the editor. - */ - _onCreateNode: Task.async(function* (nodeActor) { - let node = new AudioNodeView(nodeActor); - yield node.getType(); - AudioNodes.push(node); - window.emit(EVENTS.CREATE_NODE, node.id); - }), - - /** - * Called on `destroy-node` when an AudioNode is GC'd. Removes - * from the AudioNode array and fires an event indicating the removal. - */ - _onDestroyNode: function (nodeActor) { - for (let i = 0; i < AudioNodes.length; i++) { - if (equalActors(AudioNodes[i].actor, nodeActor)) { - AudioNodes.splice(i, 1); - window.emit(EVENTS.DESTROY_NODE, nodeActor.actorID); - break; - } - } - }, - - /** - * Called when a node is connected to another node. - */ - _onConnectNode: Task.async(function* ({ source: sourceActor, dest: destActor }) { - let [source, dest] = yield waitForNodeCreation(sourceActor, destActor); - - // Connect nodes, and only emit if it's a new connection. - if (source.connect(dest)) { - window.emit(EVENTS.CONNECT_NODE, source.id, dest.id); - } - }), - - /** - * Called when a node is conneceted to another node's AudioParam. - */ - _onConnectParam: Task.async(function* ({ source: sourceActor, dest: destActor, param }) { - let [source, dest] = yield waitForNodeCreation(sourceActor, destActor); - - if (source.connectParam(dest, param)) { - window.emit(EVENTS.CONNECT_PARAM, source.id, dest.id, param); - } - }), - - /** - * Called when a node is disconnected. - */ - _onDisconnectNode: function(nodeActor) { - let node = getViewNodeByActor(nodeActor); - node.disconnect(); - window.emit(EVENTS.DISCONNECT_NODE, node.id); - }, - - /** - * Called when a node param is changed. - */ - _onChangeParam: function({ actor, param, value }) { - window.emit(EVENTS.CHANGE_PARAM, getViewNodeByActor(actor), param, value); - } -}; - -/** - * Convenient way of emitting events from the panel window. - */ -EventEmitter.decorate(this); - -/** - * DOM query helper. - */ -function $(selector, target = document) { return target.querySelector(selector); } -function $$(selector, target = document) { return target.querySelectorAll(selector); } - -/** - * Compare `actorID` between two actors to determine if they're corresponding - * to the same underlying actor. - */ -function equalActors (actor1, actor2) { - return actor1.actorID === actor2.actorID; -} - -/** - * Returns the corresponding ViewNode by actor - */ -function getViewNodeByActor (actor) { - for (let i = 0; i < AudioNodes.length; i++) { - if (equalActors(AudioNodes[i].actor, actor)) - return AudioNodes[i]; - } - return null; -} - -/** - * Returns the corresponding ViewNode by actorID - */ -function getViewNodeById (id) { - return getViewNodeByActor({ actorID: id }); -} - -// Since node create and connect are probably executed back to back, -// and the controller's `_onCreateNode` needs to look up type, -// the edge creation could be called before the graph node is actually -// created. This way, we can check and listen for the event before -// adding an edge. -function waitForNodeCreation (sourceActor, destActor) { - let deferred = defer(); - let eventName = EVENTS.CREATE_NODE; - let source = getViewNodeByActor(sourceActor); - let dest = getViewNodeByActor(destActor); - - if (!source || !dest) - window.on(eventName, function createNodeListener (_, id) { - let createdNode = getViewNodeById(id); - if (equalActors(sourceActor, createdNode.actor)) - source = createdNode; - if (equalActors(destActor, createdNode.actor)) - dest = createdNode; - if (source && dest) { - window.off(eventName, createNodeListener); - deferred.resolve([source, dest]); - } - }); - else - deferred.resolve([source, dest]); - return deferred.promise; -} diff --git a/browser/devtools/webaudioeditor/webaudioeditor-view.js b/browser/devtools/webaudioeditor/webaudioeditor-view.js deleted file mode 100644 index 8bcf4b366ea..00000000000 --- a/browser/devtools/webaudioeditor/webaudioeditor-view.js +++ /dev/null @@ -1,636 +0,0 @@ -/* 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/. */ -"use strict"; - -Cu.import("resource:///modules/devtools/VariablesView.jsm"); -Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); -const { debounce } = require("sdk/lang/functional"); - -// Strings for rendering -const EXPAND_INSPECTOR_STRING = L10N.getStr("expandInspector"); -const COLLAPSE_INSPECTOR_STRING = L10N.getStr("collapseInspector"); - -// Store width as a preference rather than hardcode -// TODO bug 1009056 -const INSPECTOR_WIDTH = 300; - -// Globals for d3 stuff -// Default properties of the graph on rerender -const GRAPH_DEFAULTS = { - translate: [20, 20], - scale: 1 -}; - -// Sizes of SVG arrows in graph -const ARROW_HEIGHT = 5; -const ARROW_WIDTH = 8; - -// Styles for markers as they cannot be done with CSS. -const MARKER_STYLING = { - light: "#AAA", - dark: "#CED3D9" -}; - -const GRAPH_DEBOUNCE_TIMER = 100; - -const GENERIC_VARIABLES_VIEW_SETTINGS = { - searchEnabled: false, - editableValueTooltip: "", - editableNameTooltip: "", - preventDisableOnChange: true, - preventDescriptorModifiers: false, - eval: () => {} -}; - -/** - * Functions handling the graph UI. - */ -let WebAudioGraphView = { - /** - * Initialization function, called when the tool is started. - */ - initialize: function() { - this._onGraphNodeClick = this._onGraphNodeClick.bind(this); - this._onThemeChange = this._onThemeChange.bind(this); - this._onNodeSelect = this._onNodeSelect.bind(this); - this._onStartContext = this._onStartContext.bind(this); - this._onDestroyNode = this._onDestroyNode.bind(this); - - this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER); - $('#graph-target').addEventListener('click', this._onGraphNodeClick, false); - - window.on(EVENTS.THEME_CHANGE, this._onThemeChange); - window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect); - window.on(EVENTS.START_CONTEXT, this._onStartContext); - window.on(EVENTS.DESTROY_NODE, this._onDestroyNode); - }, - - /** - * Destruction function, called when the tool is closed. - */ - destroy: function() { - if (this._zoomBinding) { - this._zoomBinding.on("zoom", null); - } - $('#graph-target').removeEventListener('click', this._onGraphNodeClick, false); - window.off(EVENTS.THEME_CHANGE, this._onThemeChange); - window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect); - window.off(EVENTS.START_CONTEXT, this._onStartContext); - window.off(EVENTS.DESTROY_NODE, this._onDestroyNode); - }, - - /** - * Called when a page is reloaded and waiting for a "start-context" event - * and clears out old content - */ - resetUI: function () { - this.clearGraph(); - this.resetGraphPosition(); - }, - - /** - * Clears out the rendered graph, called when resetting the SVG elements to draw again, - * or when resetting the entire UI tool - */ - clearGraph: function () { - $("#graph-target").innerHTML = ""; - }, - - /** - * Moves the graph back to its original scale and translation. - */ - resetGraphPosition: function () { - if (this._zoomBinding) { - let { translate, scale } = GRAPH_DEFAULTS; - // Must set the `zoomBinding` so the next `zoom` event is in sync with - // where the graph is visually (set by the `transform` attribute). - this._zoomBinding.scale(scale); - this._zoomBinding.translate(translate); - d3.select("#graph-target") - .attr("transform", "translate(" + translate + ") scale(" + scale + ")"); - } - }, - - getCurrentScale: function () { - return this._zoomBinding ? this._zoomBinding.scale() : null; - }, - - getCurrentTranslation: function () { - return this._zoomBinding ? this._zoomBinding.translate() : null; - }, - - /** - * Makes the corresponding graph node appear "focused", removing - * focused styles from all other nodes. If no `actorID` specified, - * make all nodes appear unselected. - * Called from UI_INSPECTOR_NODE_SELECT. - */ - focusNode: function (actorID) { - // Remove class "selected" from all nodes - Array.forEach($$(".nodes > g"), $node => $node.classList.remove("selected")); - // Add to "selected" - if (actorID) { - this._getNodeByID(actorID).classList.add("selected"); - } - }, - - /** - * Takes an actorID and returns the corresponding DOM SVG element in the graph - */ - _getNodeByID: function (actorID) { - return $(".nodes > g[data-id='" + actorID + "']"); - }, - - /** - * `draw` renders the ViewNodes currently available in `AudioNodes` with `AudioNodeConnections`, - * and `AudioParamConnections` and is throttled to be called at most every - * `GRAPH_DEBOUNCE_TIMER` milliseconds. Is called whenever the audio context routing changes, - * after being debounced. - */ - draw: function () { - // Clear out previous SVG information - this.clearGraph(); - - let graph = new dagreD3.Digraph(); - // An array of duples/tuples of pairs [sourceNode, destNode, param]. - // `param` is optional, indicating a connection to an AudioParam, rather than - // an other AudioNode. - let edges = []; - - AudioNodes.forEach(node => { - // Add node to graph - graph.addNode(node.id, { - type: node.type, // Just for storing type data - label: node.type.replace(/Node$/, ""), // Displayed in SVG node - id: node.id // Identification - }); - - // Add all of the connections from this node to the edge array to be added - // after all the nodes are added, otherwise edges will attempted to be created - // for nodes that have not yet been added - AudioNodeConnections.get(node, new Set()).forEach(dest => edges.push([node, dest])); - let paramConnections = AudioParamConnections.get(node, {}); - Object.keys(paramConnections).forEach(destId => { - let dest = getViewNodeById(destId); - let connections = paramConnections[destId] || []; - connections.forEach(param => edges.push([node, dest, param])); - }); - }); - - edges.forEach(([node, dest, param]) => { - let options = { - source: node.id, - target: dest.id - }; - - // Only add `label` if `param` specified, as this is an AudioParam connection then. - // `label` adds the magic to render with dagre-d3, and `param` is just more explicitly - // the param, ignoring implementation details. - if (param) { - options.label = param; - options.param = param; - } - - graph.addEdge(null, node.id, dest.id, options); - }); - - let renderer = new dagreD3.Renderer(); - - // Post-render manipulation of the nodes - let oldDrawNodes = renderer.drawNodes(); - renderer.drawNodes(function(graph, root) { - let svgNodes = oldDrawNodes(graph, root); - svgNodes.attr("class", (n) => { - let node = graph.node(n); - return "audionode type-" + node.type; - }); - svgNodes.attr("data-id", (n) => { - let node = graph.node(n); - return node.id; - }); - return svgNodes; - }); - - // Post-render manipulation of edges - // TODO do all of this more efficiently, rather than - // using the direct D3 helper utilities to loop over each - // edge several times - let oldDrawEdgePaths = renderer.drawEdgePaths(); - renderer.drawEdgePaths(function(graph, root) { - let svgEdges = oldDrawEdgePaths(graph, root); - svgEdges.attr("data-source", (n) => { - let edge = graph.edge(n); - return edge.source; - }); - svgEdges.attr("data-target", (n) => { - let edge = graph.edge(n); - return edge.target; - }); - svgEdges.attr("data-param", (n) => { - let edge = graph.edge(n); - return edge.param ? edge.param : null; - }); - // We have to manually specify the default classes on the edges - // as to not overwrite them - let defaultClasses = "edgePath enter"; - svgEdges.attr("class", (n) => { - let edge = graph.edge(n); - return defaultClasses + (edge.param ? (" param-connection " + edge.param) : ""); - }); - - return svgEdges; - }); - - // Override Dagre-d3's post render function by passing in our own. - // This way we can leave styles out of it. - renderer.postRender((graph, root) => { - // We have to manually set the marker styling since we cannot - // do this currently with CSS, although it is in spec for SVG2 - // https://svgwg.org/svg2-draft/painting.html#VertexMarkerProperties - // For now, manually set it on creation, and the `_onThemeChange` - // function will fire when the devtools theme changes to update the - // styling manually. - let theme = Services.prefs.getCharPref("devtools.theme"); - let markerColor = MARKER_STYLING[theme]; - if (graph.isDirected() && root.select("#arrowhead").empty()) { - root - .append("svg:defs") - .append("svg:marker") - .attr("id", "arrowhead") - .attr("viewBox", "0 0 10 10") - .attr("refX", ARROW_WIDTH) - .attr("refY", ARROW_HEIGHT) - .attr("markerUnits", "strokewidth") - .attr("markerWidth", ARROW_WIDTH) - .attr("markerHeight", ARROW_HEIGHT) - .attr("orient", "auto") - .attr("style", "fill: " + markerColor) - .append("svg:path") - .attr("d", "M 0 0 L 10 5 L 0 10 z"); - } - - // Reselect the previously selected audio node - let currentNode = WebAudioInspectorView.getCurrentAudioNode(); - if (currentNode) { - this.focusNode(currentNode.id); - } - - // Fire an event upon completed rendering - let paramEdgeCount = edges.filter(p => !!p[2]).length; - window.emit(EVENTS.UI_GRAPH_RENDERED, AudioNodes.length, edges.length - paramEdgeCount, paramEdgeCount); - }); - - let layout = dagreD3.layout().rankDir("LR"); - renderer.layout(layout).run(graph, d3.select("#graph-target")); - - // Handle the sliding and zooming of the graph, - // store as `this._zoomBinding` so we can unbind during destruction - if (!this._zoomBinding) { - this._zoomBinding = d3.behavior.zoom().on("zoom", function () { - var ev = d3.event; - d3.select("#graph-target") - .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); - }); - d3.select("svg").call(this._zoomBinding); - - // Set initial translation and scale -- this puts D3's awareness of - // the graph in sync with what the user sees originally. - this.resetGraphPosition(); - } - }, - - /** - * Event handlers - */ - - /** - * Called once "start-context" is fired, indicating that there is an audio - * context being created to view so render the graph. - */ - _onStartContext: function () { - this.draw(); - }, - - /** - * Called when a node gets GC'd -- redraws the graph. - */ - _onDestroyNode: function () { - this.draw(); - }, - - _onNodeSelect: function (eventName, id) { - this.focusNode(id); - }, - - /** - * Fired when the devtools theme changes. - */ - _onThemeChange: function (eventName, theme) { - let markerColor = MARKER_STYLING[theme]; - let marker = $("#arrowhead"); - if (marker) { - marker.setAttribute("style", "fill: " + markerColor); - } - }, - - /** - * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane. - * - * @param Event e - * Click event. - */ - _onGraphNodeClick: function (e) { - let node = findGraphNodeParent(e.target); - // If node not found (clicking outside of an audio node in the graph), - // then ignore this event - if (!node) - return; - - window.emit(EVENTS.UI_SELECT_NODE, node.getAttribute("data-id")); - } -}; - -let WebAudioInspectorView = { - - _propsView: null, - - _currentNode: null, - - _inspectorPane: null, - _inspectorPaneToggleButton: null, - _tabsPane: null, - - /** - * Initialization function called when the tool starts up. - */ - initialize: function () { - this._inspectorPane = $("#web-audio-inspector"); - this._inspectorPaneToggleButton = $("#inspector-pane-toggle"); - this._tabsPane = $("#web-audio-editor-tabs"); - - // Hide inspector view on startup - this._inspectorPane.setAttribute("width", INSPECTOR_WIDTH); - this.toggleInspector({ visible: false, delayed: false, animated: false }); - - this._onEval = this._onEval.bind(this); - this._onNodeSelect = this._onNodeSelect.bind(this); - this._onTogglePaneClick = this._onTogglePaneClick.bind(this); - this._onDestroyNode = this._onDestroyNode.bind(this); - - this._inspectorPaneToggleButton.addEventListener("mousedown", this._onTogglePaneClick, false); - this._propsView = new VariablesView($("#properties-tabpanel-content"), GENERIC_VARIABLES_VIEW_SETTINGS); - this._propsView.eval = this._onEval; - - window.on(EVENTS.UI_SELECT_NODE, this._onNodeSelect); - window.on(EVENTS.DESTROY_NODE, this._onDestroyNode); - }, - - /** - * Destruction function called when the tool cleans up. - */ - destroy: function () { - this._inspectorPaneToggleButton.removeEventListener("mousedown", this._onTogglePaneClick); - window.off(EVENTS.UI_SELECT_NODE, this._onNodeSelect); - window.off(EVENTS.DESTROY_NODE, this._onDestroyNode); - - this._inspectorPane = null; - this._inspectorPaneToggleButton = null; - this._tabsPane = null; - }, - - /** - * Toggles the visibility of the AudioNode Inspector. - * - * @param object visible - * - visible: boolean indicating whether the panel should be shown or not - * - animated: boolean indiciating whether the pane should be animated - * - delayed: boolean indicating whether the pane's opening should wait - * a few cycles or not - * - index: the index of the tab to be selected inside the inspector - * @param number index - * Index of the tab that should be selected when shown. - */ - toggleInspector: function ({ visible, animated, delayed, index }) { - let pane = this._inspectorPane; - let button = this._inspectorPaneToggleButton; - - let flags = { - visible: visible, - animated: animated != null ? animated : true, - delayed: delayed != null ? delayed : true, - callback: () => window.emit(EVENTS.UI_INSPECTOR_TOGGLED, visible) - }; - - ViewHelpers.togglePane(flags, pane); - - if (flags.visible) { - button.removeAttribute("pane-collapsed"); - button.setAttribute("tooltiptext", COLLAPSE_INSPECTOR_STRING); - } - else { - button.setAttribute("pane-collapsed", ""); - button.setAttribute("tooltiptext", EXPAND_INSPECTOR_STRING); - } - - if (index != undefined) { - pane.selectedIndex = index; - } - }, - - /** - * Returns a boolean indicating whether or not the AudioNode inspector - * is currently being shown. - */ - isVisible: function () { - return !this._inspectorPane.hasAttribute("pane-collapsed"); - }, - - /** - * Takes a AudioNodeView `node` and sets it as the current - * node and scaffolds the inspector view based off of the new node. - */ - setCurrentAudioNode: function (node) { - this._currentNode = node || null; - - // If no node selected, set the inspector back to "no AudioNode selected" - // view. - if (!node) { - $("#web-audio-editor-details-pane-empty").removeAttribute("hidden"); - $("#web-audio-editor-tabs").setAttribute("hidden", "true"); - window.emit(EVENTS.UI_INSPECTOR_NODE_SET, null); - } - // Otherwise load up the tabs view and hide the empty placeholder - else { - $("#web-audio-editor-details-pane-empty").setAttribute("hidden", "true"); - $("#web-audio-editor-tabs").removeAttribute("hidden"); - this._setTitle(); - this._buildPropertiesView() - .then(() => window.emit(EVENTS.UI_INSPECTOR_NODE_SET, this._currentNode.id)); - } - }, - - /** - * Returns the current AudioNodeView. - */ - getCurrentAudioNode: function () { - return this._currentNode; - }, - - /** - * Empties out the props view. - */ - resetUI: function () { - this._propsView.empty(); - // Set current node to empty to load empty view - this.setCurrentAudioNode(); - - // Reset AudioNode inspector and hide - this.toggleInspector({ visible: false, animated: false, delayed: false }); - }, - - /** - * Sets the title of the Inspector view - */ - _setTitle: function () { - let node = this._currentNode; - let title = node.type.replace(/Node$/, ""); - $("#web-audio-inspector-title").setAttribute("value", title); - }, - - /** - * Reconstructs the `Properties` tab in the inspector - * with the `this._currentNode` as it's source. - */ - _buildPropertiesView: Task.async(function* () { - let propsView = this._propsView; - let node = this._currentNode; - propsView.empty(); - - let audioParamsScope = propsView.addScope("AudioParams"); - let props = yield node.getParams(); - - // Disable AudioParams VariableView expansion - // when there are no props i.e. AudioDestinationNode - this._togglePropertiesView(!!props.length); - - props.forEach(({ param, value, flags }) => { - let descriptor = { - value: value, - writable: !flags || !flags.readonly, - }; - audioParamsScope.addItem(param, descriptor); - }); - - audioParamsScope.expanded = true; - - window.emit(EVENTS.UI_PROPERTIES_TAB_RENDERED, node.id); - }), - - _togglePropertiesView: function (show) { - let propsView = $("#properties-tabpanel-content"); - let emptyView = $("#properties-tabpanel-content-empty"); - (show ? propsView : emptyView).removeAttribute("hidden"); - (show ? emptyView : propsView).setAttribute("hidden", "true"); - }, - - /** - * Returns the scope for AudioParams in the - * VariablesView. - * - * @return Scope - */ - _getAudioPropertiesScope: function () { - return this._propsView.getScopeAtIndex(0); - }, - - /** - * Event handlers - */ - - /** - * Executed when an audio prop is changed in the UI. - */ - _onEval: Task.async(function* (variable, value) { - let ownerScope = variable.ownerView; - let node = this._currentNode; - let propName = variable.name; - let error; - - if (!variable._initialDescriptor.writable) { - error = new Error("Variable " + propName + " is not writable."); - } else { - // Cast value to proper type - try { - let number = parseFloat(value); - if (!isNaN(number)) { - value = number; - } else { - value = JSON.parse(value); - } - error = yield node.actor.setParam(propName, value); - } - catch (e) { - error = e; - } - } - - // TODO figure out how to handle and display set prop errors - // and enable `test/brorwser_wa_properties-view-edit.js` - // Bug 994258 - if (!error) { - ownerScope.get(propName).setGrip(value); - window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); - } else { - window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); - } - }), - - /** - * Called on EVENTS.UI_SELECT_NODE, and takes an actorID `id` - * and calls `setCurrentAudioNode` to scaffold the inspector view. - */ - _onNodeSelect: function (_, id) { - this.setCurrentAudioNode(getViewNodeById(id)); - - // Ensure inspector is visible when selecting a new node - this.toggleInspector({ visible: true }); - }, - - /** - * Called when clicking on the toggling the inspector into view. - */ - _onTogglePaneClick: function () { - this.toggleInspector({ visible: !this.isVisible() }); - }, - - /** - * Called when `DESTROY_NODE` is fired to remove the node from props view if - * it's currently selected. - */ - _onDestroyNode: function (_, id) { - if (this._currentNode && this._currentNode.id === id) { - this.setCurrentAudioNode(null); - } - } -}; - -/** - * Takes an element in an SVG graph and iterates over - * ancestors until it finds the graph node container. If not found, - * returns null. - */ - -function findGraphNodeParent (el) { - // Some targets may not contain `classList` property - if (!el.classList) - return null; - - while (!el.classList.contains("nodes")) { - if (el.classList.contains("audionode")) - return el; - else - el = el.parentNode; - } - return null; -} diff --git a/browser/devtools/webaudioeditor/webaudioeditor.xul b/browser/devtools/webaudioeditor/webaudioeditor.xul index 0b01556c592..9be6b2b52b9 100644 --- a/browser/devtools/webaudioeditor/webaudioeditor.xul +++ b/browser/devtools/webaudioeditor/webaudioeditor.xul @@ -19,8 +19,12 @@ + + + +
+
+
+
+ + diff --git a/toolkit/modules/BinarySearch.jsm b/toolkit/modules/BinarySearch.jsm index 9db9df7b7a2..16bca739880 100644 --- a/toolkit/modules/BinarySearch.jsm +++ b/toolkit/modules/BinarySearch.jsm @@ -60,7 +60,8 @@ this.BinarySearch = Object.freeze({ let low = 0; let high = array.length - 1; while (low <= high) { - let mid = Math.floor((low + high) / 2); + // Thanks to http://jsperf.com/code-review-1480 for this tip. + let mid = (low + high) >> 1; let cmp = comparator(target, array[mid]); if (cmp == 0) return [true, mid]; diff --git a/toolkit/modules/Log.jsm b/toolkit/modules/Log.jsm index c2c83bc7c6e..1843267b2b1 100644 --- a/toolkit/modules/Log.jsm +++ b/toolkit/modules/Log.jsm @@ -40,7 +40,7 @@ this.Log = { Config: 30, Debug: 20, Trace: 10, - All: 0, + All: -1, // We don't want All to be falsy. Desc: { 70: "FATAL", 60: "ERROR", @@ -49,7 +49,7 @@ this.Log = { 30: "CONFIG", 20: "DEBUG", 10: "TRACE", - 0: "ALL" + "-1": "ALL", }, Numbers: { "FATAL": 70, @@ -59,7 +59,7 @@ this.Log = { "CONFIG": 30, "DEBUG": 20, "TRACE": 10, - "ALL": 0, + "ALL": -1, } }, @@ -535,7 +535,7 @@ BasicFormatter.prototype = { */ formatText: function (message) { let params = message.params; - if (!params) { + if (typeof(params) == "undefined") { return message.message || ""; } // Defensive handling of non-object params @@ -543,7 +543,7 @@ BasicFormatter.prototype = { let pIsObject = (typeof(params) == 'object' || typeof(params) == 'function'); // if we have params, try and find substitutions. - if (message.params && this.parameterFormatter) { + if (this.parameterFormatter) { // have we successfully substituted any parameters into the message? // in the log message let subDone = false; diff --git a/toolkit/modules/PopupNotifications.jsm b/toolkit/modules/PopupNotifications.jsm index 08b950b9263..afe39732293 100644 --- a/toolkit/modules/PopupNotifications.jsm +++ b/toolkit/modules/PopupNotifications.jsm @@ -617,8 +617,12 @@ PopupNotifications.prototype = { this._refreshPanel(notificationsToShow); - if (this.isPanelOpen && this._currentAnchorElement == anchorElement) + if (this.isPanelOpen && this._currentAnchorElement == anchorElement) { + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); return; + } // If the panel is already open but we're changing anchors, we need to hide // it first. Otherwise it can appear in the wrong spot. (_hidePanel is @@ -677,10 +681,15 @@ PopupNotifications.prototype = { notifications = this._currentNotifications; let haveNotifications = notifications.length > 0; if (haveNotifications) { - // Only show the notifications that have the passed-in anchor (or the - // first notification's anchor, if none was passed in). Other - // notifications will be shown once these are dismissed. - anchorElement = anchor || notifications[0].anchorElement; + // Filter out notifications that have been dismissed. + notificationsToShow = notifications.filter(function (n) { + return !n.dismissed && !n.options.neverShow; + }); + + // If no anchor has been passed in, use the anchor of the first + // showable notification. + if (!anchorElement && notificationsToShow.length) + anchorElement = notificationsToShow[0].anchorElement; if (useIconBox) { this._showIcons(notifications); @@ -689,10 +698,9 @@ PopupNotifications.prototype = { this._updateAnchorIcon(notifications, anchorElement); } - // Also filter out notifications that have been dismissed. - notificationsToShow = notifications.filter(function (n) { - return !n.dismissed && n.anchorElement == anchorElement && - !n.options.neverShow; + // Also filter out notifications that are for a different anchor. + notificationsToShow = notificationsToShow.filter(function (n) { + return n.anchorElement == anchorElement; }); } diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index 0e966c12e3d..764fdbb380a 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -60,6 +60,8 @@ EXTRA_JS_MODULES += [ EXTRA_PP_JS_MODULES += [ 'CertUtils.jsm', 'ResetProfile.jsm', + 'secondscreen/RokuApp.jsm', + 'secondscreen/SimpleServiceDiscovery.jsm', 'Services.jsm', 'Troubleshoot.jsm', 'UpdateChannel.jsm', @@ -72,6 +74,8 @@ if 'Android' != CONFIG['OS_TARGET']: 'GMPInstallManager.jsm', 'LightweightThemeConsumer.jsm', ] +else: + DEFINES['ANDROID'] = True if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows': diff --git a/mobile/android/modules/RokuApp.jsm b/toolkit/modules/secondscreen/RokuApp.jsm similarity index 100% rename from mobile/android/modules/RokuApp.jsm rename to toolkit/modules/secondscreen/RokuApp.jsm diff --git a/mobile/android/modules/SimpleServiceDiscovery.jsm b/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm similarity index 98% rename from mobile/android/modules/SimpleServiceDiscovery.jsm rename to toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm index ca352e205b3..c06cbfaaf18 100644 --- a/mobile/android/modules/SimpleServiceDiscovery.jsm +++ b/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm @@ -11,12 +11,18 @@ const { classes: Cc, interfaces: Ci, utils: Cu } = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -Cu.import("resource://gre/modules/Messaging.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); +#ifdef ANDROID +Cu.import("resource://gre/modules/Messaging.jsm"); +#endif // Define the "log" function as a binding of the Log.d function so it specifies // the "debug" priority and a log tag. -let log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.d.bind(null, "SSDP"); +#ifdef ANDROID +let log = Cu.import("resource://gre/modules/AndroidLog.jsm",{}).AndroidLog.d.bind(null, "SSDP"); +#else +let log = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService).logStringMessage +#endif XPCOMUtils.defineLazyGetter(this, "converter", function () { let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].createInstance(Ci.nsIScriptableUnicodeConverter); @@ -188,10 +194,13 @@ var SimpleServiceDiscovery = { } } +#ifdef ANDROID // We also query Java directly here for any devices that Android might support natively (i.e. Chromecast or Miracast) this.getAndroidDevices(); +#endif }, +#ifdef ANDROID getAndroidDevices: function() { Messaging.sendRequestForResult({ type: "MediaPlayer:Get" }).then((result) => { for (let id in result.displays) { @@ -212,6 +221,7 @@ var SimpleServiceDiscovery = { } }); }, +#endif _searchFixedDevices: function _searchFixedDevices() { let fixedDevices = null; diff --git a/toolkit/modules/tests/xpcshell/test_Log.js b/toolkit/modules/tests/xpcshell/test_Log.js index d995d0ece8e..4925e7a4178 100644 --- a/toolkit/modules/tests/xpcshell/test_Log.js +++ b/toolkit/modules/tests/xpcshell/test_Log.js @@ -457,6 +457,14 @@ add_task(function log_message_with_params() { 'non-object no subst: 1'); do_check_eq(formatMessage("non-object all subst ${}", 2), 'non-object all subst 2'); + do_check_eq(formatMessage("false no subst", false), + 'false no subst: false'); + do_check_eq(formatMessage("null no subst", null), + 'null no subst: null'); + // If 'params' is undefined and there are no substitutions expected, + // the message should still be output. + do_check_eq(formatMessage("undefined no subst", undefined), + 'undefined no subst'); // If 'params' is not an object, no named substitutions can succeed; // therefore we leave the placeholder and append the formatted params. do_check_eq(formatMessage("non-object named subst ${junk} space", 3), diff --git a/toolkit/xre/nsAppRunner.cpp b/toolkit/xre/nsAppRunner.cpp index 1e276fbe05b..0e1447c8b0b 100644 --- a/toolkit/xre/nsAppRunner.cpp +++ b/toolkit/xre/nsAppRunner.cpp @@ -669,7 +669,9 @@ NS_IMETHODIMP nsXULAppInfo::GetVendor(nsACString& aResult) { if (XRE_GetProcessType() == GeckoProcessType_Content) { - return NS_ERROR_NOT_AVAILABLE; + ContentChild* cc = ContentChild::GetSingleton(); + aResult = cc->GetAppInfo().vendor; + return NS_OK; } aResult.Assign(gAppData->vendor); @@ -693,7 +695,9 @@ NS_IMETHODIMP nsXULAppInfo::GetID(nsACString& aResult) { if (XRE_GetProcessType() == GeckoProcessType_Content) { - return NS_ERROR_NOT_AVAILABLE; + ContentChild* cc = ContentChild::GetSingleton(); + aResult = cc->GetAppInfo().ID; + return NS_OK; } aResult.Assign(gAppData->ID); @@ -4593,7 +4597,7 @@ mozilla::BrowserTabsRemoteAutostart() !Preferences::GetBool("layers.acceleration.force-enabled", false); // Check env flags if (!accelDisabled) { - const char *acceleratedEnv = PR_GetEnv("MOZ_ACCELERATED"); + const char *acceleratedEnv = PR_GetEnv("MOZ_ACCELERATED"); if (acceleratedEnv && (*acceleratedEnv != '0')) { accelDisabled = false; }