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
This commit is contained in:
Nicolas Perriault 2014-07-21 13:04:43 -07:00
parent 79f86ed8e2
commit 3e357e0dc1
8 changed files with 429 additions and 52 deletions

View File

@ -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.
*/

View File

@ -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);
},
/**

View File

@ -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('<p data-l10n-id="welcome"></p>')
});
/**
* 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);

View File

@ -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('<p data-l10n-id="welcome"></p>')
});
/**
* 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)
<div>{__("call_url_unavailable_notification")}</div>
);
/* jshint ignore:end */
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
*/
var ConversationFormView = sharedViews.BaseView.extend({
template: _.template([
'<form>',
' <p>',
' <button class="btn btn-success" data-l10n-id="start_call"></button>',
' </p>',
'</form>'
].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);

View File

@ -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.

View File

@ -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);

View File

@ -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);

View File

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