From 122e4eddcd810a4ba9aaa449754e511e4c70cdc4 Mon Sep 17 00:00:00 2001 From: Mark Banner Date: Tue, 30 Sep 2014 20:44:05 +0100 Subject: [PATCH] Bug 972017 Part 2 - Set up actions and a dispatcher and start to handle obtaining call data for outgoing Loop calls from the desktop client. r=mikedeboer --- browser/app/profile/firefox.js | 1 + .../components/loop/content/conversation.html | 3 + browser/components/loop/content/js/client.js | 44 +++ .../loop/content/js/conversation.js | 34 +- .../loop/content/js/conversation.jsx | 34 +- .../loop/content/js/conversationViews.js | 32 +- .../loop/content/js/conversationViews.jsx | 32 +- .../loop/content/shared/js/actions.js | 78 +++++ .../content/shared/js/conversationStore.js | 233 ++++++++++++- .../loop/content/shared/js/dispatcher.js | 84 +++++ .../loop/content/shared/js/utils.js | 9 + .../loop/content/shared/js/validate.js | 127 +++++++ browser/components/loop/jar.mn | 5 +- .../loop/test/desktop-local/client_test.js | 65 ++++ .../desktop-local/conversationViews_test.js | 131 +++++++ .../test/desktop-local/conversation_test.js | 26 +- .../loop/test/desktop-local/index.html | 4 + .../test/shared/conversationStore_test.js | 321 ++++++++++++++++++ .../loop/test/shared/dispatcher_test.js | 140 ++++++++ .../components/loop/test/shared/index.html | 7 + .../loop/test/shared/validate_test.js | 82 +++++ 21 files changed, 1462 insertions(+), 30 deletions(-) create mode 100644 browser/components/loop/content/shared/js/actions.js create mode 100644 browser/components/loop/content/shared/js/dispatcher.js create mode 100644 browser/components/loop/content/shared/js/validate.js create mode 100644 browser/components/loop/test/desktop-local/conversationViews_test.js create mode 100644 browser/components/loop/test/shared/conversationStore_test.js create mode 100644 browser/components/loop/test/shared/dispatcher_test.js create mode 100644 browser/components/loop/test/shared/validate_test.js diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index a395a0a60114..086ddac446c4 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1613,6 +1613,7 @@ pref("loop.retry_delay.limit", 300000); pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback"); pref("loop.feedback.product", "Loop"); pref("loop.debug.loglevel", "Error"); +pref("loop.debug.dispatcher", false); pref("loop.debug.websocket", false); pref("loop.debug.sdk", false); diff --git a/browser/components/loop/content/conversation.html b/browser/components/loop/content/conversation.html index 3051dadcf9bd..95916cba8f2f 100644 --- a/browser/components/loop/content/conversation.html +++ b/browser/components/loop/content/conversation.html @@ -30,6 +30,9 @@ + + + diff --git a/browser/components/loop/content/js/client.js b/browser/components/loop/content/js/client.js index c9d93660e1db..94f856e7211b 100644 --- a/browser/components/loop/content/js/client.js +++ b/browser/components/loop/content/js/client.js @@ -15,6 +15,12 @@ loop.Client = (function($) { // The expected properties to be returned from the GET /calls request. var expectedCallProperties = ["calls"]; + // THe expected properties to be returned from the POST /calls request. + var expectedPostCallProperties = [ + "apiKey", "callId", "progressURL", + "sessionId", "sessionToken", "websocketToken" + ]; + /** * Loop server client. * @@ -209,6 +215,44 @@ loop.Client = (function($) { }.bind(this)); }, + /** + * Sets up an outgoing call, getting the relevant data from the server. + * + * Callback parameters: + * - err null on successful registration, non-null otherwise. + * - result an object of the obtained data for starting the call, if successful + * + * @param {Array} calleeIds an array of emails and phone numbers. + * @param {String} callType the type of call. + * @param {Function} cb Callback(err, result) + */ + setupOutgoingCall: function(calleeIds, callType, cb) { + this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA, + "/calls", "POST", { + calleeId: calleeIds, + callType: callType + }, + function (err, responseText) { + if (err) { + this._failureHandler(cb, err); + return; + } + + try { + var postData = JSON.parse(responseText); + + var outgoingCallData = this._validate(postData, + expectedPostCallProperties); + + cb(null, outgoingCallData); + } catch (err) { + console.log("Error requesting call info", err); + cb(err); + } + }.bind(this) + ); + }, + /** * Adds a value to a telemetry histogram, ignoring errors. * diff --git a/browser/components/loop/content/js/conversation.js b/browser/components/loop/content/js/conversation.js index 247a4160b846..f33b6ffcde05 100644 --- a/browser/components/loop/content/js/conversation.js +++ b/browser/components/loop/content/js/conversation.js @@ -502,14 +502,25 @@ loop.conversation = (function(mozL10n) { sdk: React.PropTypes.object.isRequired, // XXX New types for OutgoingConversationView - store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired + store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired }, getInitialState: function() { return this.props.store.attributes; }, + componentWillMount: function() { + this.props.store.on("change:outgoing", function() { + this.setState(this.props.store.attributes); + }, this); + }, + render: function() { + // Don't display anything, until we know what type of call we are. + if (this.state.outgoing === undefined) { + return null; + } + if (this.state.outgoing) { return (OutgoingConversationView({ store: this.props.store} @@ -545,19 +556,19 @@ loop.conversation = (function(mozL10n) { } }); - var conversationStore = new loop.ConversationStore(); + var dispatcher = new loop.Dispatcher(); + var client = new loop.Client(); + var conversationStore = new loop.store.ConversationStore({}, { + client: client, + dispatcher: dispatcher + }); // XXX For now key this on the pref, but this should really be // set by the information from the mozLoop API when we can get it (bug 1072323). var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail"); - if (outgoingEmail) { - conversationStore.set("outgoing", true); - conversationStore.set("calleeId", outgoingEmail); - } // XXX Old class creation for the incoming conversation view, whilst // we transition across (bug 1072323). - var client = new loop.Client(); var conversation = new sharedModels.ConversationModel( {}, // Model attributes {sdk: window.OT} // Model dependencies @@ -567,8 +578,10 @@ loop.conversation = (function(mozL10n) { // Obtain the callId and pass it through var helper = new loop.shared.utils.Helper(); var locationHash = helper.locationHash(); + var callId; if (locationHash) { - conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]); + callId = locationHash.match(/\#incoming\/(.*)/)[1] + conversation.set("callId", callId); } window.addEventListener("unload", function(event) { @@ -585,6 +598,11 @@ loop.conversation = (function(mozL10n) { notifications: notifications, sdk: window.OT} ), document.querySelector('#main')); + + dispatcher.dispatch(new loop.shared.actions.GatherCallData({ + callId: callId, + calleeId: outgoingEmail + })); } return { diff --git a/browser/components/loop/content/js/conversation.jsx b/browser/components/loop/content/js/conversation.jsx index d12fb91e703a..1030b88da942 100644 --- a/browser/components/loop/content/js/conversation.jsx +++ b/browser/components/loop/content/js/conversation.jsx @@ -502,14 +502,25 @@ loop.conversation = (function(mozL10n) { sdk: React.PropTypes.object.isRequired, // XXX New types for OutgoingConversationView - store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired + store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired }, getInitialState: function() { return this.props.store.attributes; }, + componentWillMount: function() { + this.props.store.on("change:outgoing", function() { + this.setState(this.props.store.attributes); + }, this); + }, + render: function() { + // Don't display anything, until we know what type of call we are. + if (this.state.outgoing === undefined) { + return null; + } + if (this.state.outgoing) { return (, document.querySelector('#main')); + + dispatcher.dispatch(new loop.shared.actions.GatherCallData({ + callId: callId, + calleeId: outgoingEmail + })); } return { diff --git a/browser/components/loop/content/js/conversationViews.js b/browser/components/loop/content/js/conversationViews.js index 3fd72602d676..de65a250fb74 100644 --- a/browser/components/loop/content/js/conversationViews.js +++ b/browser/components/loop/content/js/conversationViews.js @@ -9,6 +9,8 @@ var loop = loop || {}; loop.conversationViews = (function(mozL10n) { + var CALL_STATES = loop.store.CALL_STATES; + /** * Displays details of the incoming/outgoing conversation * (name, link, audio/video type etc). @@ -45,8 +47,8 @@ loop.conversationViews = (function(mozL10n) { render: function() { var pendingStateString; - if (this.props.callState === "ringing") { - pendingStateString = mozL10n.get("call_progress_pending_description"); + if (this.props.callState === CALL_STATES.ALERTING) { + pendingStateString = mozL10n.get("call_progress_ringing_description"); } else { pendingStateString = mozL10n.get("call_progress_connecting_description"); } @@ -69,6 +71,19 @@ loop.conversationViews = (function(mozL10n) { } }); + /** + * Call failed view. Displayed when a call fails. + */ + var CallFailedView = React.createClass({displayName: 'CallFailedView', + render: function() { + return ( + React.DOM.div({className: "call-window"}, + React.DOM.h2(null, mozL10n.get("generic_failure_title")) + ) + ); + } + }); + /** * Master View Controller for outgoing calls. This manages * the different views that need displaying. @@ -76,14 +91,24 @@ loop.conversationViews = (function(mozL10n) { var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView', propTypes: { store: React.PropTypes.instanceOf( - loop.ConversationStore).isRequired + loop.store.ConversationStore).isRequired }, getInitialState: function() { return this.props.store.attributes; }, + componentWillMount: function() { + this.props.store.on("change", function() { + this.setState(this.props.store.attributes); + }, this); + }, + render: function() { + if (this.state.callState === CALL_STATES.TERMINATED) { + return (CallFailedView(null)); + } + return (PendingConversationView({ callState: this.state.callState, calleeId: this.state.calleeId} @@ -94,6 +119,7 @@ loop.conversationViews = (function(mozL10n) { return { PendingConversationView: PendingConversationView, ConversationDetailView: ConversationDetailView, + CallFailedView: CallFailedView, OutgoingConversationView: OutgoingConversationView }; diff --git a/browser/components/loop/content/js/conversationViews.jsx b/browser/components/loop/content/js/conversationViews.jsx index 51e9da3e5472..5d774e1ac576 100644 --- a/browser/components/loop/content/js/conversationViews.jsx +++ b/browser/components/loop/content/js/conversationViews.jsx @@ -9,6 +9,8 @@ var loop = loop || {}; loop.conversationViews = (function(mozL10n) { + var CALL_STATES = loop.store.CALL_STATES; + /** * Displays details of the incoming/outgoing conversation * (name, link, audio/video type etc). @@ -45,8 +47,8 @@ loop.conversationViews = (function(mozL10n) { render: function() { var pendingStateString; - if (this.props.callState === "ringing") { - pendingStateString = mozL10n.get("call_progress_pending_description"); + if (this.props.callState === CALL_STATES.ALERTING) { + pendingStateString = mozL10n.get("call_progress_ringing_description"); } else { pendingStateString = mozL10n.get("call_progress_connecting_description"); } @@ -69,6 +71,19 @@ loop.conversationViews = (function(mozL10n) { } }); + /** + * Call failed view. Displayed when a call fails. + */ + var CallFailedView = React.createClass({ + render: function() { + return ( +
+

{mozL10n.get("generic_failure_title")}

+
+ ); + } + }); + /** * Master View Controller for outgoing calls. This manages * the different views that need displaying. @@ -76,14 +91,24 @@ loop.conversationViews = (function(mozL10n) { var OutgoingConversationView = React.createClass({ propTypes: { store: React.PropTypes.instanceOf( - loop.ConversationStore).isRequired + loop.store.ConversationStore).isRequired }, getInitialState: function() { return this.props.store.attributes; }, + componentWillMount: function() { + this.props.store.on("change", function() { + this.setState(this.props.store.attributes); + }, this); + }, + render: function() { + if (this.state.callState === CALL_STATES.TERMINATED) { + return (); + } + return ( 0) + throw new TypeError("missing required " + diff.join(", ")); + }, + + /** + * Checks if a given value matches any of the provided type requirements. + * + * @param {Object} value The value to check + * @param {Array} types The list of types to check the value against + * @return {Boolean} + * @throws {TypeError} If the value doesn't match any types. + */ + _dependencyMatchTypes: function(value, types) { + return types.some(function(Type) { + /*jshint eqeqeq:false*/ + try { + return typeof Type === "undefined" || // skip checking + Type === null && value === null || // null type + value.constructor == Type || // native type + Type.prototype.isPrototypeOf(value) || // custom type + typeName(value) === typeName(Type); // type string eq. + } catch (e) { + return false; + } + }); + } + }; + + return { + Validator: Validator + }; +})(); diff --git a/browser/components/loop/jar.mn b/browser/components/loop/jar.mn index cfabbebe85ea..2a2381742567 100644 --- a/browser/components/loop/jar.mn +++ b/browser/components/loop/jar.mn @@ -53,13 +53,16 @@ browser.jar: content/browser/loop/shared/img/icons-16x16.svg (content/shared/img/icons-16x16.svg) # Shared scripts + content/browser/loop/shared/js/actions.js (content/shared/js/actions.js) + content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js) + content/browser/loop/shared/js/dispatcher.js (content/shared/js/dispatcher.js) content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js) content/browser/loop/shared/js/models.js (content/shared/js/models.js) content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js) content/browser/loop/shared/js/views.js (content/shared/js/views.js) content/browser/loop/shared/js/utils.js (content/shared/js/utils.js) + content/browser/loop/shared/js/validate.js (content/shared/js/validate.js) content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js) - content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js) # Shared libs #ifdef DEBUG diff --git a/browser/components/loop/test/desktop-local/client_test.js b/browser/components/loop/test/desktop-local/client_test.js index 85d156ffe043..56ed988878a7 100644 --- a/browser/components/loop/test/desktop-local/client_test.js +++ b/browser/components/loop/test/desktop-local/client_test.js @@ -253,5 +253,70 @@ describe("loop.Client", function() { }); }); }); + + describe("#setupOutgoingCall", function() { + var calleeIds, callType; + + beforeEach(function() { + calleeIds = [ + "fakeemail", "fake phone" + ]; + callType = "audio"; + }); + + it("should make a POST call to /calls", function() { + client.setupOutgoingCall(calleeIds, callType); + + sinon.assert.calledOnce(hawkRequestStub); + sinon.assert.calledWith(hawkRequestStub, + mozLoop.LOOP_SESSION_TYPE.FXA, + "/calls", + "POST", + { calleeId: calleeIds, callType: callType } + ); + }); + + it("should call the callback if the request is successful", function() { + var requestData = { + apiKey: "fake", + callId: "fakeCall", + progressURL: "fakeurl", + sessionId: "12345678", + sessionToken: "15263748", + websocketToken: "13572468" + }; + + hawkRequestStub.callsArgWith(4, null, JSON.stringify(requestData)); + + client.setupOutgoingCall(calleeIds, callType, callback); + + sinon.assert.calledOnce(callback); + sinon.assert.calledWithExactly(callback, null, requestData); + }); + + it("should send an error when the request fails", function() { + hawkRequestStub.callsArgWith(4, fakeErrorRes); + + client.setupOutgoingCall(calleeIds, callType, callback); + + sinon.assert.calledOnce(callback); + sinon.assert.calledWithExactly(callback, sinon.match(function(err) { + return /400.*invalid token/.test(err.message); + })); + }); + + it("should send an error if the data is not valid", function() { + // Sets up the hawkRequest stub to trigger the callback with + // an error + hawkRequestStub.callsArgWith(4, null, "{}"); + + client.setupOutgoingCall(calleeIds, callType, callback); + + sinon.assert.calledOnce(callback); + sinon.assert.calledWithMatch(callback, sinon.match(function(err) { + return /Invalid data received/.test(err.message); + })); + }); + }); }); }); diff --git a/browser/components/loop/test/desktop-local/conversationViews_test.js b/browser/components/loop/test/desktop-local/conversationViews_test.js new file mode 100644 index 000000000000..83f8e70d9c94 --- /dev/null +++ b/browser/components/loop/test/desktop-local/conversationViews_test.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var expect = chai.expect; + +describe("loop.conversationViews", function () { + var sandbox, oldTitle, view; + + var CALL_STATES = loop.store.CALL_STATES; + + beforeEach(function() { + sandbox = sinon.sandbox.create(); + + oldTitle = document.title; + sandbox.stub(document.mozL10n, "get", function(x) { + return x; + }); + }); + + afterEach(function() { + document.title = oldTitle; + view = undefined; + sandbox.restore(); + }); + + describe("ConversationDetailView", function() { + function mountTestComponent(props) { + return TestUtils.renderIntoDocument( + loop.conversationViews.ConversationDetailView(props)); + } + + it("should set the document title to the calledId", function() { + mountTestComponent({calleeId: "mrsmith"}); + + expect(document.title).eql("mrsmith"); + }); + + it("should set display the calledId", function() { + view = mountTestComponent({calleeId: "mrsmith"}); + + expect(TestUtils.findRenderedDOMComponentWithTag( + view, "h2").props.children).eql("mrsmith"); + }); + }); + + describe("PendingConversationView", function() { + function mountTestComponent(props) { + return TestUtils.renderIntoDocument( + loop.conversationViews.PendingConversationView(props)); + } + + it("should set display connecting string when the state is not alerting", + function() { + view = mountTestComponent({ + callState: CALL_STATES.CONNECTING, + calleeId: "mrsmith" + }); + + var label = TestUtils.findRenderedDOMComponentWithClass( + view, "btn-label").props.children; + + expect(label).to.have.string("connecting"); + }); + + it("should set display ringing string when the state is alerting", + function() { + view = mountTestComponent({ + callState: CALL_STATES.ALERTING, + calleeId: "mrsmith" + }); + + var label = TestUtils.findRenderedDOMComponentWithClass( + view, "btn-label").props.children; + + expect(label).to.have.string("ringing"); + }); + }); + + describe("OutgoingConversationView", function() { + var store; + + function mountTestComponent() { + return TestUtils.renderIntoDocument( + loop.conversationViews.OutgoingConversationView({ + store: store + })); + } + + beforeEach(function() { + store = new loop.store.ConversationStore({}, { + dispatcher: new loop.Dispatcher(), + client: {} + }); + }); + + it("should render the CallFailedView when the call state is 'terminated'", + function() { + store.set({callState: CALL_STATES.TERMINATED}); + + view = mountTestComponent(); + + TestUtils.findRenderedComponentWithType(view, + loop.conversationViews.CallFailedView); + }); + + it("should render the PendingConversationView when the call state is connecting", + function() { + store.set({callState: CALL_STATES.CONNECTING}); + + view = mountTestComponent(); + + TestUtils.findRenderedComponentWithType(view, + loop.conversationViews.PendingConversationView); + }); + + it("should update the rendered views when the state is changed.", + function() { + store.set({callState: CALL_STATES.CONNECTING}); + + view = mountTestComponent(); + + TestUtils.findRenderedComponentWithType(view, + loop.conversationViews.PendingConversationView); + + store.set({callState: CALL_STATES.TERMINATED}); + + TestUtils.findRenderedComponentWithType(view, + loop.conversationViews.CallFailedView); + }); + }); +}); diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index 21b48cd2e3d0..90ab387f9b66 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -41,7 +41,7 @@ describe("loop.conversation", function() { return "en-US"; }, setLoopCharPref: sinon.stub(), - getLoopCharPref: sinon.stub(), + getLoopCharPref: sinon.stub().returns(null), getLoopBoolPref: sinon.stub(), getCallData: sinon.stub(), releaseCallData: sinon.stub(), @@ -75,6 +75,11 @@ describe("loop.conversation", function() { sandbox.stub(loop.shared.models.ConversationModel.prototype, "initialize"); + sandbox.stub(loop.Dispatcher.prototype, "dispatch"); + + sandbox.stub(loop.shared.utils.Helper.prototype, + "locationHash").returns("#incoming/42"); + window.OT = { overrideGuidStorage: sinon.stub() }; @@ -102,10 +107,21 @@ describe("loop.conversation", function() { loop.conversation.ConversationControllerView); })); }); + + it("should trigger a gatherCallData action", function() { + loop.conversation.init(); + + sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch); + sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch, + new loop.shared.actions.GatherCallData({ + calleeId: null, + callId: "42" + })); + }); }); describe("ConversationControllerView", function() { - var store, conversation, client, ccView, oldTitle; + var store, conversation, client, ccView, oldTitle, dispatcher; function mountTestComponent() { return TestUtils.renderIntoDocument( @@ -124,7 +140,11 @@ describe("loop.conversation", function() { conversation = new loop.shared.models.ConversationModel({}, { sdk: {} }); - store = new loop.ConversationStore(); + dispatcher = new loop.Dispatcher(); + store = new loop.store.ConversationStore({}, { + client: client, + dispatcher: dispatcher + }); }); afterEach(function() { diff --git a/browser/components/loop/test/desktop-local/index.html b/browser/components/loop/test/desktop-local/index.html index 45e2652a954f..64e60b36f41b 100644 --- a/browser/components/loop/test/desktop-local/index.html +++ b/browser/components/loop/test/desktop-local/index.html @@ -39,6 +39,9 @@ + + + @@ -49,6 +52,7 @@ + + + + + @@ -47,6 +51,9 @@ + + +