diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index a395a0a6011..086ddac446c 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 3051dadcf9b..95916cba8f2 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 c9d93660e1d..94f856e7211 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 247a4160b84..f33b6ffcde0 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 d12fb91e703..1030b88da94 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 3fd72602d67..de65a250fb7 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 51e9da3e547..5d774e1ac57 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 cfabbebe85e..2a238174256 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 85d156ffe04..56ed988878a 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 00000000000..83f8e70d9c9
--- /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 21b48cd2e3d..90ab387f9b6 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 45e2652a954..64e60b36f41 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 @@
+
+
+