From 8d7d43989311d9b091762945ca926d5930a43830 Mon Sep 17 00:00:00 2001 From: Nicolas Perriault Date: Mon, 21 Jul 2014 13:04:43 -0700 Subject: [PATCH] Bug 1000131 - Expired Loop call url notification, r=dmose --HG-- rename : browser/components/loop/standalone/content/js/webapp.js => browser/components/loop/standalone/content/js/webapp.jsx --- .../loop/content/shared/js/models.js | 28 +- .../standalone/content/js/standaloneClient.js | 35 +-- .../loop/standalone/content/js/webapp.js | 40 ++- .../loop/standalone/content/js/webapp.jsx | 274 ++++++++++++++++++ .../loop/standalone/content/l10n/data.ini | 2 + .../loop/test/shared/models_test.js | 44 ++- .../test/standalone/standalone_client_test.js | 37 ++- .../loop/test/standalone/webapp_test.js | 21 ++ 8 files changed, 429 insertions(+), 52 deletions(-) create mode 100644 browser/components/loop/standalone/content/js/webapp.jsx diff --git a/browser/components/loop/content/shared/js/models.js b/browser/components/loop/content/shared/js/models.js index a48ce0f270e6..a6541427a443 100644 --- a/browser/components/loop/content/shared/js/models.js +++ b/browser/components/loop/content/shared/js/models.js @@ -117,8 +117,7 @@ loop.shared.models = (function() { this._clearPendingCallTimer(); if (err) { - this.trigger("session:error", new Error( - "Retrieval of session information failed: HTTP " + err)); + this._handleServerError(err); return; } @@ -200,6 +199,31 @@ loop.shared.models = (function() { .once("session:ended", this.stopListening, this); }, + /** + * Handle a loop-server error, which has an optional `errno` property which + * is server error identifier. + * + * Triggers the following events: + * + * - `session:expired` for expired call urls + * - `session:error` for other generic errors + * + * @param {Error} err Error object. + */ + _handleServerError: function(err) { + switch (err.errno) { + // loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is + // missing OR expired; we treat this information as if the url is always + // expired. + case 105: + this.trigger("session:expired", err); + break; + default: + this.trigger("session:error", err); + break; + } + }, + /** * Clears current pending call timer, if any. */ diff --git a/browser/components/loop/standalone/content/js/standaloneClient.js b/browser/components/loop/standalone/content/js/standaloneClient.js index 5bdce75a8c5b..fe86cd8c72fe 100644 --- a/browser/components/loop/standalone/content/js/standaloneClient.js +++ b/browser/components/loop/standalone/content/js/standaloneClient.js @@ -2,7 +2,7 @@ * 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, hawk, deriveHawkCredentials */ +/* global loop:true */ var loop = loop || {}; loop.StandaloneClient = (function($) { @@ -46,7 +46,7 @@ loop.StandaloneClient = (function($) { } }); - if (properties.length == 1) { + if (properties.length === 1) { return data[properties[0]]; } @@ -62,26 +62,17 @@ loop.StandaloneClient = (function($) { * @param errorThrown See jQuery docs */ _failureHandler: function(cb, jqXHR, textStatus, errorThrown) { - var error = "Unknown error.", - jsonRes = jqXHR && jqXHR.responseJSON || {}; - // Received error response format: - // { "status": "errors", - // "errors": [{ - // "location": "url", - // "name": "token", - // "description": "invalid token" - // }]} - if (jsonRes.status === "errors" && Array.isArray(jsonRes.errors)) { - error = "Details: " + jsonRes.errors.map(function(err) { - return Object.keys(err).map(function(field) { - return field + ": " + err[field]; - }).join(", "); - }).join("; "); - } - var message = "HTTP " + jqXHR.status + " " + errorThrown + - "; " + error; - console.error(message); - cb(new Error(message)); + var jsonErr = jqXHR && jqXHR.responseJSON || {}; + var message = "HTTP " + jqXHR.status + " " + errorThrown; + + // Logging the technical error to the console + console.error("Server error", message, jsonErr); + + // Create an error with server error `errno` code attached as a property + var err = new Error(message); + err.errno = jsonErr.errno; + + cb(err); }, /** diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js index 28a16bf8677f..c2714d546dc2 100644 --- a/browser/components/loop/standalone/content/js/webapp.js +++ b/browser/components/loop/standalone/content/js/webapp.js @@ -1,11 +1,14 @@ +/** @jsx React.DOM */ + /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ -/* global loop:true */ +/* global loop:true, React */ +/* jshint newcap:false */ var loop = loop || {}; -loop.webapp = (function($, _, OT) { +loop.webapp = (function($, _, OT, webL10n) { "use strict"; loop.config = loop.config || {}; @@ -13,7 +16,8 @@ loop.webapp = (function($, _, OT) { var sharedModels = loop.shared.models, sharedViews = loop.shared.views, - baseServerUrl = loop.config.serverUrl; + baseServerUrl = loop.config.serverUrl, + __ = webL10n.get; /** * App router. @@ -28,6 +32,20 @@ loop.webapp = (function($, _, OT) { template: _.template('

') }); + /** + * Expired call URL view. + */ + var CallUrlExpiredView = React.createClass({displayName: 'CallUrlExpiredView', + render: function() { + /* jshint ignore:start */ + return ( + // XXX proper UX/design should be implemented here (see bug 1000131) + React.DOM.div(null, __("call_url_unavailable_notification")) + ); + /* jshint ignore:end */ + } + }); + /** * Conversation launcher view. A ConversationModel is associated and attached * as a `model` property. @@ -93,7 +111,7 @@ loop.webapp = (function($, _, OT) { event.preventDefault(); this.model.initiate({ client: new loop.StandaloneClient({ - baseServerUrl: baseServerUrl, + baseServerUrl: baseServerUrl }), outgoing: true, // For now, we assume both audio and video as there is no @@ -112,6 +130,7 @@ loop.webapp = (function($, _, OT) { "": "home", "unsupportedDevice": "unsupportedDevice", "unsupportedBrowser": "unsupportedBrowser", + "call/expired": "expired", "call/ongoing/:token": "loadConversation", "call/:token": "initiate" }, @@ -121,6 +140,12 @@ loop.webapp = (function($, _, OT) { this.loadView(new HomeView()); this.listenTo(this._conversation, "timeout", this._onTimeout); + this.listenTo(this._conversation, "session:expired", + this._onSessionExpired); + }, + + _onSessionExpired: function() { + this.navigate("/call/expired", {trigger: true}); }, /** @@ -167,6 +192,10 @@ loop.webapp = (function($, _, OT) { this.loadView(new sharedViews.UnsupportedBrowserView()); }, + expired: function() { + this.loadReactComponent(CallUrlExpiredView()); + }, + /** * Loads conversation launcher view, setting the received conversation token * to the current conversation model. If a session is currently established, @@ -235,10 +264,11 @@ loop.webapp = (function($, _, OT) { return { baseServerUrl: baseServerUrl, + CallUrlExpiredView: CallUrlExpiredView, ConversationFormView: ConversationFormView, HomeView: HomeView, WebappHelper: WebappHelper, init: init, WebappRouter: WebappRouter }; -})(jQuery, _, window.OT); +})(jQuery, _, window.OT, document.webL10n); diff --git a/browser/components/loop/standalone/content/js/webapp.jsx b/browser/components/loop/standalone/content/js/webapp.jsx new file mode 100644 index 000000000000..a3d90095abf0 --- /dev/null +++ b/browser/components/loop/standalone/content/js/webapp.jsx @@ -0,0 +1,274 @@ +/** @jsx React.DOM */ + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global loop:true, React */ +/* jshint newcap:false */ + +var loop = loop || {}; +loop.webapp = (function($, _, OT, webL10n) { + "use strict"; + + loop.config = loop.config || {}; + loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000"; + + var sharedModels = loop.shared.models, + sharedViews = loop.shared.views, + baseServerUrl = loop.config.serverUrl, + __ = webL10n.get; + + /** + * App router. + * @type {loop.webapp.WebappRouter} + */ + var router; + + /** + * Homepage view. + */ + var HomeView = sharedViews.BaseView.extend({ + template: _.template('

') + }); + + /** + * Expired call URL view. + */ + var CallUrlExpiredView = React.createClass({ + render: function() { + /* jshint ignore:start */ + return ( + // XXX proper UX/design should be implemented here (see bug 1000131) +
{__("call_url_unavailable_notification")}
+ ); + /* jshint ignore:end */ + } + }); + + /** + * Conversation launcher view. A ConversationModel is associated and attached + * as a `model` property. + */ + var ConversationFormView = sharedViews.BaseView.extend({ + template: _.template([ + '
', + '

', + ' ', + '

', + '
' + ].join("")), + + events: { + "submit": "initiate" + }, + + /** + * Constructor. + * + * Required options: + * - {loop.shared.model.ConversationModel} model Conversation model. + * - {loop.shared.views.NotificationListView} notifier Notifier component. + * + * @param {Object} options Options object. + */ + initialize: function(options) { + options = options || {}; + + if (!options.model) { + throw new Error("missing required model"); + } + this.model = options.model; + + if (!options.notifier) { + throw new Error("missing required notifier"); + } + this.notifier = options.notifier; + + this.listenTo(this.model, "session:error", this._onSessionError); + }, + + _onSessionError: function(error) { + console.error(error); + this.notifier.errorL10n("unable_retrieve_call_info"); + }, + + /** + * Disables this form to prevent multiple submissions. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=991126 + */ + disableForm: function() { + this.$("button").attr("disabled", "disabled"); + }, + + /** + * Initiates the call. + * + * @param {SubmitEvent} event + */ + initiate: function(event) { + event.preventDefault(); + this.model.initiate({ + client: new loop.StandaloneClient({ + baseServerUrl: baseServerUrl + }), + outgoing: true, + // For now, we assume both audio and video as there is no + // other option to select. + callType: "audio-video" + }); + this.disableForm(); + } + }); + + /** + * Webapp Router. + */ + var WebappRouter = loop.shared.router.BaseConversationRouter.extend({ + routes: { + "": "home", + "unsupportedDevice": "unsupportedDevice", + "unsupportedBrowser": "unsupportedBrowser", + "call/expired": "expired", + "call/ongoing/:token": "loadConversation", + "call/:token": "initiate" + }, + + initialize: function() { + // Load default view + this.loadView(new HomeView()); + + this.listenTo(this._conversation, "timeout", this._onTimeout); + this.listenTo(this._conversation, "session:expired", + this._onSessionExpired); + }, + + _onSessionExpired: function() { + this.navigate("/call/expired", {trigger: true}); + }, + + /** + * @override {loop.shared.router.BaseConversationRouter.startCall} + */ + startCall: function() { + if (!this._conversation.get("loopToken")) { + this._notifier.errorL10n("missing_conversation_info"); + this.navigate("home", {trigger: true}); + } else { + this.navigate("call/ongoing/" + this._conversation.get("loopToken"), { + trigger: true + }); + } + }, + + /** + * @override {loop.shared.router.BaseConversationRouter.endCall} + */ + endCall: function() { + var route = "home"; + if (this._conversation.get("loopToken")) { + route = "call/" + this._conversation.get("loopToken"); + } + this.navigate(route, {trigger: true}); + }, + + _onTimeout: function() { + this._notifier.errorL10n("call_timeout_notification_text"); + }, + + /** + * Default entry point. + */ + home: function() { + this.loadView(new HomeView()); + }, + + unsupportedDevice: function() { + this.loadView(new sharedViews.UnsupportedDeviceView()); + }, + + unsupportedBrowser: function() { + this.loadView(new sharedViews.UnsupportedBrowserView()); + }, + + expired: function() { + this.loadReactComponent(CallUrlExpiredView()); + }, + + /** + * Loads conversation launcher view, setting the received conversation token + * to the current conversation model. If a session is currently established, + * terminates it first. + * + * @param {String} loopToken Loop conversation token. + */ + initiate: function(loopToken) { + // Check if a session is ongoing; if so, terminate it + if (this._conversation.get("ongoing")) { + this._conversation.endSession(); + } + this._conversation.set("loopToken", loopToken); + this.loadView(new ConversationFormView({ + model: this._conversation, + notifier: this._notifier + })); + }, + + /** + * Loads conversation establishment view. + * + */ + loadConversation: function(loopToken) { + if (!this._conversation.isSessionReady()) { + // User has loaded this url directly, actually setup the call. + return this.navigate("call/" + loopToken, {trigger: true}); + } + this.loadReactComponent(sharedViews.ConversationView({ + sdk: OT, + model: this._conversation + })); + } + }); + + /** + * Local helpers. + */ + function WebappHelper() { + this._iOSRegex = /^(iPad|iPhone|iPod)/; + } + + WebappHelper.prototype.isIOS = function isIOS(platform) { + return this._iOSRegex.test(platform); + }; + + /** + * App initialization. + */ + function init() { + var helper = new WebappHelper(); + router = new WebappRouter({ + notifier: new sharedViews.NotificationListView({el: "#messages"}), + conversation: new sharedModels.ConversationModel({}, { + sdk: OT, + pendingCallTimeout: loop.config.pendingCallTimeout + }) + }); + Backbone.history.start(); + if (helper.isIOS(navigator.platform)) { + router.navigate("unsupportedDevice", {trigger: true}); + } else if (!OT.checkSystemRequirements()) { + router.navigate("unsupportedBrowser", {trigger: true}); + } + } + + return { + baseServerUrl: baseServerUrl, + CallUrlExpiredView: CallUrlExpiredView, + ConversationFormView: ConversationFormView, + HomeView: HomeView, + WebappHelper: WebappHelper, + init: init, + WebappRouter: WebappRouter + }; +})(jQuery, _, window.OT, document.webL10n); diff --git a/browser/components/loop/standalone/content/l10n/data.ini b/browser/components/loop/standalone/content/l10n/data.ini index f8b89364d2b5..a6d57cb337c9 100644 --- a/browser/components/loop/standalone/content/l10n/data.ini +++ b/browser/components/loop/standalone/content/l10n/data.ini @@ -19,6 +19,7 @@ incompatible_device=Incompatible device sorry_device_unsupported=Sorry, Loop does not currently support your device. use_firefox_windows_mac_linux=Please open this page using the latest Firefox on Windows, Android, Mac or Linux. connection_error_see_console_notification=Call failed; see console for details. +call_url_unavailable_notification=This URL is unavailable. [fr] call_has_ended=L'appel est terminé. @@ -40,3 +41,4 @@ use_latest_firefox.innerHTML=Pour utiliser Loop, merci d'utiliser la dernière v incompatible_device=Plateforme non supportée sorry_device_unsupported=Désolé, Loop ne fonctionne actuellement pas sur votre appareil. use_firefox_windows_mac_linux=Merci d'ouvrir cette page avec une version récente de Firefox pour Windows, Android, Mac ou Linux. +call_url_unavailable_notification=Cette URL n'est pas disponible. diff --git a/browser/components/loop/test/shared/models_test.js b/browser/components/loop/test/shared/models_test.js index 618dd4f884cb..11199f2dd83f 100644 --- a/browser/components/loop/test/shared/models_test.js +++ b/browser/components/loop/test/shared/models_test.js @@ -142,18 +142,42 @@ describe("loop.shared.models", function() { sinon.assert.calledWith(conversation.setReady, fakeSessionData); }); - it("should trigger a `session:error` on failure", function(done) { - requestCallInfoStub.callsArgWith(2, - new Error("failed: HTTP 400 Bad Request; fake")); + it("should trigger a `session:error` event errno is undefined", + function(done) { + var errMsg = "HTTP 500 Server Error; fake"; + var err = new Error(errMsg); + requestCallInfoStub.callsArgWith(2, err); - conversation.on("session:error", function(err) { - expect(err.message).to.match(/failed: HTTP 400 Bad Request; fake/); - done(); - }).initiate({ - client: fakeClient, - outgoing: true + conversation.on("session:error", function(err) { + expect(err.message).eql(errMsg); + done(); + }).initiate({ client: fakeClient, outgoing: true }); + }); + + it("should trigger a `session:error` event when errno is not 105", + function(done) { + var errMsg = "HTTP 400 Bad Request; fake"; + var err = new Error(errMsg); + err.errno = 101; + requestCallInfoStub.callsArgWith(2, err); + + conversation.on("session:error", function(err) { + expect(err.message).eql(errMsg); + done(); + }).initiate({ client: fakeClient, outgoing: true }); + }); + + it("should trigger a `session:expired` event when errno is 105", + function(done) { + var err = new Error("HTTP 404 Not Found; fake"); + err.errno = 105; + requestCallInfoStub.callsArgWith(2, err); + + conversation.on("session:expired", function(err2) { + expect(err2).eql(err); + done(); + }).initiate({ client: fakeClient, outgoing: true }); }); - }); it("should end the session on outgoing call timeout", function() { requestCallInfoStub.callsArgWith(2, null, fakeSessionData); diff --git a/browser/components/loop/test/standalone/standalone_client_test.js b/browser/components/loop/test/standalone/standalone_client_test.js index c0d80d136ae1..cabc88f3dc67 100644 --- a/browser/components/loop/test/standalone/standalone_client_test.js +++ b/browser/components/loop/test/standalone/standalone_client_test.js @@ -15,15 +15,6 @@ describe("loop.StandaloneClient", function() { callback, fakeToken; - var fakeErrorRes = JSON.stringify({ - status: "errors", - errors: [{ - location: "url", - name: "token", - description: "invalid token" - }] - }); - beforeEach(function() { sandbox = sinon.sandbox.create(); fakeXHR = sandbox.useFakeXMLHttpRequest(); @@ -50,12 +41,19 @@ describe("loop.StandaloneClient", function() { }); describe("requestCallInfo", function() { - var client; + var client, fakeServerErrorDescription; beforeEach(function() { client = new loop.StandaloneClient( {baseServerUrl: "http://fake.api"} ); + fakeServerErrorDescription = { + code: 401, + errno: 101, + error: "error", + message: "invalid token", + info: "error info" + }; }); it("should prevent launching a conversation when token is missing", @@ -91,13 +89,26 @@ describe("loop.StandaloneClient", function() { it("should send an error when the request fails", function() { client.requestCallInfo("fake", "audio", callback); - requests[0].respond(400, {"Content-Type": "application/json"}, - fakeErrorRes); + requests[0].respond(401, {"Content-Type": "application/json"}, + JSON.stringify(fakeServerErrorDescription)); sinon.assert.calledWithMatch(callback, sinon.match(function(err) { - return /400.*invalid token/.test(err.message); + return /HTTP 401 Unauthorized/.test(err.message); })); }); + it("should attach the server error description object to the error " + + "passed to the callback", + function() { + client.requestCallInfo("fake", "audio", callback); + + requests[0].respond(401, {"Content-Type": "application/json"}, + JSON.stringify(fakeServerErrorDescription)); + + sinon.assert.calledWithMatch(callback, sinon.match(function(err) { + return err.errno === fakeServerErrorDescription.errno; + })); + }); + it("should send an error if the data is not valid", function() { client.requestCallInfo("fake", "audio", callback); diff --git a/browser/components/loop/test/standalone/webapp_test.js b/browser/components/loop/test/standalone/webapp_test.js index ac2aac33ac3f..c700da3ba8f4 100644 --- a/browser/components/loop/test/standalone/webapp_test.js +++ b/browser/components/loop/test/standalone/webapp_test.js @@ -140,6 +140,19 @@ describe("loop.webapp", function() { }); }); + describe("#expired", function() { + it("should load the CallUrlExpiredView view", function() { + router.expired(); + + sinon.assert.calledOnce(router.loadReactComponent); + sinon.assert.calledWith(router.loadReactComponent, + sinon.match(function(value) { + return React.addons.TestUtils.isComponentOfType( + value, loop.webapp.CallUrlExpiredView); + })); + }); + }); + describe("#initiate", function() { it("should set the token on the conversation model", function() { router.initiate("fakeToken"); @@ -251,6 +264,14 @@ describe("loop.webapp", function() { sinon.assert.calledOnce(router.navigate); sinon.assert.calledWithMatch(router.navigate, "call/fakeToken"); }); + + it("should navigate to call/expired when a session:expired event is " + + "received", function() { + conversation.trigger("session:expired"); + + sinon.assert.calledOnce(router.navigate); + sinon.assert.calledWith(router.navigate, "/call/expired"); + }); }); });