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");
+ });
});
});