diff --git a/browser/components/loop/content/shared/js/models.js b/browser/components/loop/content/shared/js/models.js index 77cd825be068..a48ce0f270e6 100644 --- a/browser/components/loop/content/shared/js/models.js +++ b/browser/components/loop/content/shared/js/models.js @@ -14,6 +14,7 @@ loop.shared.models = (function() { */ var ConversationModel = Backbone.Model.extend({ defaults: { + connected: false, // Session connected flag ongoing: false, // Ongoing call flag callerId: undefined, // Loop caller id loopToken: undefined, // Loop conversation token @@ -38,11 +39,29 @@ loop.shared.models = (function() { */ session: undefined, + /** + * Pending call timeout value. + * @type {Number} + */ + pendingCallTimeout: undefined, + + /** + * Pending call timer. + * @type {Number} + */ + _pendingCallTimer: undefined, + /** * Constructor. * - * Required options: - * - {OT} sdk: SDK object. + * Options: + * + * Required: + * - {OT} sdk: OT SDK object. + * + * Optional: + * - {Number} pendingCallTimeout: Pending call timeout in milliseconds + * (default: 20000). * * @param {Object} attributes Attributes object. * @param {Object} options Options object. @@ -53,6 +72,10 @@ loop.shared.models = (function() { throw new Error("missing required sdk"); } this.sdk = options.sdk; + this.pendingCallTimeout = options.pendingCallTimeout || 20000; + + // Ensure that any pending call timer is cleared on disconnect/error + this.on("session:ended session:error", this._clearPendingCallTimer, this); }, /** @@ -79,21 +102,38 @@ loop.shared.models = (function() { * @param {Object} options Options object */ initiate: function(options) { + options = options || {}; + + // Outgoing call has never reached destination, closing - see bug 1020448 + function handleOutgoingCallTimeout() { + /*jshint validthis:true */ + if (!this.get("ongoing")) { + this.trigger("timeout").endSession(); + } + } + function handleResult(err, sessionData) { /*jshint validthis:true */ + this._clearPendingCallTimer(); + if (err) { this.trigger("session:error", new Error( "Retrieval of session information failed: HTTP " + err)); return; } - // XXX For incoming calls we might have more than one call queued. - // For now, we'll just assume the first call is the right information. - // We'll probably really want to be getting this data from the - // background worker on the desktop client. - // Bug 990714 should fix this. - if (!options.outgoing) + if (options.outgoing) { + // Setup pending call timeout. + this._pendingCallTimer = setTimeout( + handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout); + } else { + // XXX For incoming calls we might have more than one call queued. + // For now, we'll just assume the first call is the right information. + // We'll probably really want to be getting this data from the + // background worker on the desktop client. + // Bug 990714 should fix this. sessionData = sessionData[0]; + } this.setReady(sessionData); } @@ -156,8 +196,17 @@ loop.shared.models = (function() { */ endSession: function() { this.session.disconnect(); - this.once("session:ended", this.stopListening, this); - this.set("ongoing", false); + this.set("ongoing", false) + .once("session:ended", this.stopListening, this); + }, + + /** + * Clears current pending call timer, if any. + */ + _clearPendingCallTimer: function() { + if (this._pendingCallTimer) { + clearTimeout(this._pendingCallTimer); + } }, /** @@ -175,7 +224,7 @@ loop.shared.models = (function() { this.endSession(); } else { this.trigger("session:connected"); - this.set("ongoing", true); + this.set("connected", true); } }, @@ -186,7 +235,8 @@ loop.shared.models = (function() { * @param {StreamEvent} event */ _streamCreated: function(event) { - this.trigger("session:stream-created", event); + this.set("ongoing", true) + .trigger("session:stream-created", event); }, /** @@ -196,8 +246,9 @@ loop.shared.models = (function() { * @param {SessionDisconnectEvent} event */ _sessionDisconnected: function(event) { - this.trigger("session:ended"); - this.set("ongoing", false); + this.set("connected", false) + .set("ongoing", false) + .trigger("session:ended"); }, /** @@ -207,9 +258,11 @@ loop.shared.models = (function() { * @param {ConnectionEvent} event */ _connectionDestroyed: function(event) { - this.trigger("session:peer-hungup", { - connectionId: event.connection.connectionId - }); + this.set("connected", false) + .set("ongoing", false) + .trigger("session:peer-hungup", { + connectionId: event.connection.connectionId + }); this.endSession(); }, @@ -220,7 +273,9 @@ loop.shared.models = (function() { * @param {ConnectionEvent} event */ _networkDisconnected: function(event) { - this.trigger("session:network-disconnected"); + this.set("connected", false) + .set("ongoing", false) + .trigger("session:network-disconnected"); this.endSession(); }, }); diff --git a/browser/components/loop/standalone/Makefile b/browser/components/loop/standalone/Makefile index f6d5e043083c..bc207e125c42 100644 --- a/browser/components/loop/standalone/Makefile +++ b/browser/components/loop/standalone/Makefile @@ -3,6 +3,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000}) +LOOP_PENDING_CALL_TIMEOUT := $(shell echo $${LOOP_PENDING_CALL_TIMEOUT-20000}) NODE_LOCAL_BIN=./node_modules/.bin install: @@ -21,4 +22,7 @@ frontend: @echo "Not implemented yet." config: - @echo "var loop = loop || {};\nloop.config = {serverUrl: '`echo $(LOOP_SERVER_URL)`'};" > content/config.js + @echo "var loop = loop || {};" > content/config.js + @echo "loop.config = loop.config || {};" >> content/config.js + @echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js + @echo "loop.config.pendingCallTimeout = `echo $(LOOP_PENDING_CALL_TIMEOUT)`;" >> content/config.js diff --git a/browser/components/loop/standalone/README.md b/browser/components/loop/standalone/README.md index ca378d659c2d..15750f779ce2 100644 --- a/browser/components/loop/standalone/README.md +++ b/browser/components/loop/standalone/README.md @@ -16,11 +16,15 @@ Configuration You will need to generate a configuration file, you can do so with: - $ make config + $ make config -It will read the configuration from the `LOOP_SERVER_URL` env variable and -generate the appropriate configuration file. This setting defines the root url -of the loop server, without trailing slash. +It will read the configuration from the following env variables and generate the +appropriate configuration file: + +- `LOOP_SERVER_URL` defines the root url of the loop server, without trailing + slash (default: `http://localhost:5000`). +- `LOOP_PENDING_CALL_TIMEOUT` defines the amount of time a pending outgoing call + should be considered timed out, in milliseconds (default: `20000`). Usage ----- diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js index 435124ddf465..28a16bf8677f 100644 --- a/browser/components/loop/standalone/content/js/webapp.js +++ b/browser/components/loop/standalone/content/js/webapp.js @@ -119,6 +119,8 @@ loop.webapp = (function($, _, OT) { initialize: function() { // Load default view this.loadView(new HomeView()); + + this.listenTo(this._conversation, "timeout", this._onTimeout); }, /** @@ -146,6 +148,10 @@ loop.webapp = (function($, _, OT) { this.navigate(route, {trigger: true}); }, + _onTimeout: function() { + this._notifier.errorL10n("call_timeout_notification_text"); + }, + /** * Default entry point. */ @@ -213,8 +219,11 @@ loop.webapp = (function($, _, OT) { function init() { var helper = new WebappHelper(); router = new WebappRouter({ - conversation: new sharedModels.ConversationModel({}, {sdk: OT}), - notifier: new sharedViews.NotificationListView({el: "#messages"}) + notifier: new sharedViews.NotificationListView({el: "#messages"}), + conversation: new sharedModels.ConversationModel({}, { + sdk: OT, + pendingCallTimeout: loop.config.pendingCallTimeout + }) }); Backbone.history.start(); if (helper.isIOS(navigator.platform)) { diff --git a/browser/components/loop/standalone/content/l10n/data.ini b/browser/components/loop/standalone/content/l10n/data.ini index f77fe38dbf78..f8b89364d2b5 100644 --- a/browser/components/loop/standalone/content/l10n/data.ini +++ b/browser/components/loop/standalone/content/l10n/data.ini @@ -1,5 +1,6 @@ [en] call_has_ended=Your call has ended. +call_timeout_notification_text=Your call did not go through. missing_conversation_info=Missing conversation information. network_disconnected=The network connection terminated abruptly. peer_ended_conversation=Your peer ended the conversation. @@ -21,6 +22,7 @@ connection_error_see_console_notification=Call failed; see console for details. [fr] call_has_ended=L'appel est terminé. +call_timeout_notification_text=Votre appel n'a pas abouti. missing_conversation_info=Informations de communication manquantes. network_disconnected=La connexion réseau semble avoir été interrompue. peer_ended_conversation=Votre correspondant a mis fin à la communication. diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index 5510796fccf8..a92c72da26dc 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -96,7 +96,10 @@ describe("loop.conversation", function() { var conversation; beforeEach(function() { - conversation = new loop.shared.models.ConversationModel({}, {sdk: {}}); + conversation = new loop.shared.models.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000 + }); sandbox.stub(conversation, "initiate"); }); @@ -285,7 +288,10 @@ describe("loop.conversation", function() { var conversation, view; beforeEach(function() { - conversation = new loop.shared.models.ConversationModel({}, {sdk: {}}); + conversation = new loop.shared.models.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000 + }); view = new loop.conversation.IncomingCallView({model: conversation}); }); diff --git a/browser/components/loop/test/shared/models_test.js b/browser/components/loop/test/shared/models_test.js index b1c5978f751a..618dd4f884cb 100644 --- a/browser/components/loop/test/shared/models_test.js +++ b/browser/components/loop/test/shared/models_test.js @@ -14,6 +14,7 @@ describe("loop.shared.models", function() { beforeEach(function() { sandbox = sinon.sandbox.create(); + sandbox.useFakeTimers(); fakeXHR = sandbox.useFakeXMLHttpRequest(); requests = []; // https://github.com/cjohansen/Sinon.JS/issues/393 @@ -46,9 +47,16 @@ describe("loop.shared.models", function() { describe("#initialize", function() { it("should require a sdk option", function() { expect(function() { - new sharedModels.ConversationModel(); + new sharedModels.ConversationModel({}, {}); }).to.Throw(Error, /missing required sdk/); }); + + it("should accept a pendingCallTimeout option", function() { + expect(new sharedModels.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000 + }).pendingCallTimeout).eql(1000); + }); }); describe("constructed", function() { @@ -56,7 +64,10 @@ describe("loop.shared.models", function() { requestCallInfoStub, requestCallsInfoStub; beforeEach(function() { - conversation = new sharedModels.ConversationModel({}, {sdk: fakeSDK}); + conversation = new sharedModels.ConversationModel({}, { + sdk: fakeSDK, + pendingCallTimeout: 1000 + }); conversation.set("loopToken", "fakeToken"); fakeBaseServerUrl = "http://fakeBaseServerUrl"; fakeClient = { @@ -68,6 +79,10 @@ describe("loop.shared.models", function() { }); describe("#initiate", function() { + beforeEach(function() { + sandbox.stub(conversation, "endSession"); + }); + it("call requestCallInfo on the client for outgoing calls", function() { conversation.initiate({ @@ -139,6 +154,35 @@ describe("loop.shared.models", function() { outgoing: true }); }); + + it("should end the session on outgoing call timeout", function() { + requestCallInfoStub.callsArgWith(2, null, fakeSessionData); + + conversation.initiate({ + client: fakeClient, + outgoing: true + }); + + sandbox.clock.tick(1001); + + sinon.assert.calledOnce(conversation.endSession); + }); + + it("should trigger a `timeout` event on outgoing call timeout", + function(done) { + requestCallInfoStub.callsArgWith(2, null, fakeSessionData); + + conversation.once("timeout", function() { + done(); + }); + + conversation.initiate({ + client: fakeClient, + outgoing: true + }); + + sandbox.clock.tick(1001); + }); }); describe("#setReady", function() { @@ -161,8 +205,11 @@ describe("loop.shared.models", function() { var model; beforeEach(function() { + sandbox.stub(sharedModels.ConversationModel.prototype, + "_clearPendingCallTimer"); model = new sharedModels.ConversationModel(fakeSessionData, { - sdk: fakeSDK + sdk: fakeSDK, + pendingCallTimeout: 1000 }); model.startSession(); }); @@ -182,16 +229,16 @@ describe("loop.shared.models", function() { sinon.match.func); }); - it("should set ongoing to true when no error is called back", + it("should set connected to true when no error is called back", function() { fakeSession.connect = function(key, token, cb) { cb(null); }; - sinon.stub(model, "set"); + sandbox.stub(model, "set"); model.startSession(); - sinon.assert.calledWith(model.set, "ongoing", true); + sinon.assert.calledWith(model.set, "connected", true); }); it("should trigger session:connected when no error is called back", @@ -215,7 +262,7 @@ describe("loop.shared.models", function() { error: true }); }; - sinon.stub(model, "endSession"); + sandbox.stub(model, "endSession"); model.startSession(); @@ -239,6 +286,17 @@ describe("loop.shared.models", function() { "session:connection-error", sinon.match.object); }); + it("should set the connected attr to true on connection completed", + function() { + fakeSession.connect = function(key, token, cb) { + cb(); + }; + + model.startSession(); + + expect(model.get("connected")).eql(true); + }); + it("should trigger a session:ended event on sessionDisconnected", function(done) { model.once("session:ended", function(){ done(); }); @@ -246,16 +304,32 @@ describe("loop.shared.models", function() { fakeSession.trigger("sessionDisconnected", {reason: "ko"}); }); - it("should set the ongoing attribute to false on sessionDisconnected", - function(done) { - model.once("session:ended", function() { - expect(model.get("ongoing")).eql(false); - done(); - }); - + it("should set the connected attribute to false on sessionDisconnected", + function() { fakeSession.trigger("sessionDisconnected", {reason: "ko"}); + + expect(model.get("connected")).eql(false); }); + it("should set the ongoing attribute to false on sessionDisconnected", + function() { + fakeSession.trigger("sessionDisconnected", {reason: "ko"}); + + expect(model.get("ongoing")).eql(false); + }); + + it("should clear a pending timer on session:ended", function() { + model.trigger("session:ended"); + + sinon.assert.calledOnce(model._clearPendingCallTimer); + }); + + it("should clear a pending timer on session:error", function() { + model.trigger("session:error"); + + sinon.assert.calledOnce(model._clearPendingCallTimer); + }); + describe("connectionDestroyed event received", function() { var fakeEvent = {reason: "ko", connection: {connectionId: 42}}; @@ -304,7 +378,8 @@ describe("loop.shared.models", function() { beforeEach(function() { model = new sharedModels.ConversationModel(fakeSessionData, { - sdk: fakeSDK + sdk: fakeSDK, + pendingCallTimeout: 1000 }); model.startSession(); }); @@ -315,6 +390,12 @@ describe("loop.shared.models", function() { sinon.assert.calledOnce(fakeSession.disconnect); }); + it("should set the connected attribute to false", function() { + model.endSession(); + + expect(model.get("connected")).eql(false); + }); + it("should set the ongoing attribute to false", function() { model.endSession(); diff --git a/browser/components/loop/test/shared/router_test.js b/browser/components/loop/test/shared/router_test.js index 1ec21c276d38..7d2437fcd7c4 100644 --- a/browser/components/loop/test/shared/router_test.js +++ b/browser/components/loop/test/shared/router_test.js @@ -102,7 +102,10 @@ describe("loop.shared.router", function() { }); conversation = new loop.shared.models.ConversationModel({ loopToken: "fakeToken" - }, {sdk: {}}); + }, { + sdk: {}, + pendingCallTimeout: 1000 + }); }); describe("#constructor", function() { diff --git a/browser/components/loop/test/shared/views_test.js b/browser/components/loop/test/shared/views_test.js index a75bc9bf942d..a07a2e87b3c9 100644 --- a/browser/components/loop/test/shared/views_test.js +++ b/browser/components/loop/test/shared/views_test.js @@ -205,7 +205,8 @@ describe("loop.shared.views", function() { initSession: sandbox.stub().returns(fakeSession) }; model = new sharedModels.ConversationModel(fakeSessionData, { - sdk: fakeSDK + sdk: fakeSDK, + pendingCallTimeout: 1000 }); }); diff --git a/browser/components/loop/test/standalone/webapp_test.js b/browser/components/loop/test/standalone/webapp_test.js index 5765f0bb166e..ac2aac33ac3f 100644 --- a/browser/components/loop/test/standalone/webapp_test.js +++ b/browser/components/loop/test/standalone/webapp_test.js @@ -23,10 +23,12 @@ describe("loop.webapp", function() { error: sandbox.spy(), errorL10n: sandbox.spy(), }; + loop.config.pendingCallTimeout = 1000; }); afterEach(function() { sandbox.restore(); + delete loop.config.pendingCallTimeout; }); describe("#init", function() { @@ -69,7 +71,10 @@ describe("loop.webapp", function() { var router, conversation; beforeEach(function() { - conversation = new sharedModels.ConversationModel({}, {sdk: {}}); + conversation = new sharedModels.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000 + }); router = new loop.webapp.WebappRouter({ conversation: conversation, notifier: notifier @@ -253,7 +258,9 @@ describe("loop.webapp", function() { var conversation; beforeEach(function() { - conversation = new sharedModels.ConversationModel({}, {sdk: {}}); + conversation = new sharedModels.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000}); }); describe("#initialize", function() { @@ -268,7 +275,10 @@ describe("loop.webapp", function() { var conversation, initiate, view, fakeSubmitEvent; beforeEach(function() { - conversation = new sharedModels.ConversationModel({}, {sdk: {}}); + conversation = new sharedModels.ConversationModel({}, { + sdk: {}, + pendingCallTimeout: 1000 + }); view = new loop.webapp.ConversationFormView({ model: conversation, notifier: notifier @@ -307,7 +317,10 @@ describe("loop.webapp", function() { beforeEach(function() { conversation = new sharedModels.ConversationModel({ loopToken: "fake" - }, {sdk: {}}); + }, { + sdk: {}, + pendingCallTimeout: 1000 + }); view = new loop.webapp.ConversationFormView({ model: conversation, notifier: notifier