Bug 1000237 Standalone UI for link clickers needs "call being processed" visual notification. r=nperriault

This commit is contained in:
Mark Banner 2014-09-12 13:57:19 +01:00
parent 082dcacd46
commit a00a84e026
14 changed files with 465 additions and 202 deletions

View File

@ -135,6 +135,7 @@ p {
background-color: #f0ad4e;
}
.btn-cancel,
.btn-error,
.btn-hangup,
.btn-error + .btn-chevron {
@ -142,6 +143,7 @@ p {
border: 1px solid #d74345;
}
.btn-cancel:hover,
.btn-error:hover,
.btn-hangup:hover,
.btn-error + .btn-chevron:hover {
@ -149,6 +151,7 @@ p {
border: 1px solid #c53436;
}
.btn-cancel:active,
.btn-error:active,
.btn-hangup:active,
.btn-error + .btn-chevron:active {

View File

@ -51,18 +51,6 @@ loop.shared.models = (function(l10n) {
*/
session: undefined,
/**
* Pending call timeout value.
* @type {Number}
*/
pendingCallTimeout: undefined,
/**
* Pending call timer.
* @type {Number}
*/
_pendingCallTimer: undefined,
/**
* Constructor.
*
@ -71,10 +59,6 @@ loop.shared.models = (function(l10n) {
* 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.
*/
@ -84,10 +68,6 @@ loop.shared.models = (function(l10n) {
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);
},
/**
@ -112,20 +92,6 @@ loop.shared.models = (function(l10n) {
* server for the outgoing call.
*/
outgoing: function(sessionData) {
this._clearPendingCallTimer();
// Outgoing call has never reached destination, closing - see bug 1020448
function handleOutgoingCallTimeout() {
/*jshint validthis:true */
if (!this.get("ongoing")) {
this.trigger("timeout").endSession();
}
}
// Setup pending call timeout.
this._pendingCallTimer = setTimeout(
handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
this.setOutgoingSessionData(sessionData);
this.trigger("call:outgoing");
},
@ -278,15 +244,6 @@ loop.shared.models = (function(l10n) {
}
},
/**
* Clears current pending call timer, if any.
*/
_clearPendingCallTimer: function() {
if (this._pendingCallTimer) {
clearTimeout(this._pendingCallTimer);
}
},
/**
* Manages connection status
* triggers apropriate event for connection error/success

View File

@ -148,6 +148,18 @@ loop.CallConnectionWebSocket = (function() {
});
},
/**
* Notifies the server that the outgoing call is cancelled by the
* user.
*/
cancel: function() {
this._send({
messageType: "action",
event: "terminate",
reason: "cancel"
});
},
/**
* Sends data on the websocket.
*
@ -206,6 +218,7 @@ loop.CallConnectionWebSocket = (function() {
this._completeConnection();
break;
case "progress":
this.trigger("progress:" + msg.state);
this.trigger("progress", msg);
break;
}

View File

@ -3,7 +3,6 @@
# 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:
@ -53,4 +52,3 @@ config:
@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

View File

@ -21,9 +21,12 @@ body,
.standalone-header {
border-radius: 4px;
background: #fff;
padding: 1rem 5rem;
border: 1px solid #E7E7E7;
box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
}
.header-box {
padding: 1rem 5rem;
margin-top: 2rem;
}
@ -103,7 +106,7 @@ body,
}
.standalone-header-title,
.standalone-call-btn-label {
.standalone-btn-label {
font-weight: lighter;
}
@ -112,7 +115,7 @@ body,
line-height: 2.2rem;
}
.standalone-call-btn-label {
.standalone-btn-label {
font-size: 1.2rem;
}
@ -179,6 +182,10 @@ body,
}
}
.btn-pending-cancel-group > .btn-cancel {
flex: 2 1 auto;
}
.btn-large {
/* Dimensions from spec
* https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */

View File

@ -120,6 +120,16 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
render: function() {
return (
React.DOM.h1({className: "standalone-header-title"},
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
)
);
}
});
var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
render: function() {
var cx = React.addons.classSet;
@ -138,10 +148,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
return (
/* jshint ignore:start */
React.DOM.header({className: "standalone-header container-box"},
React.DOM.h1({className: "standalone-header-title"},
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
),
React.DOM.header({className: "standalone-header header-box container-box"},
ConversationBranding(null),
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
React.DOM.h3({className: "call-url"},
conversationUrl
@ -165,6 +173,68 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
getInitialState: function() {
return {
callState: this.props.callState || "connecting"
}
},
propTypes: {
websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
.isRequired
},
componentDidMount: function() {
this.props.websocket.listenTo(this.props.websocket, "progress:alerting",
this._handleRingingProgress);
},
_handleRingingProgress: function() {
this.setState({callState: "ringing"});
},
_cancelOutgoingCall: function() {
this.props.websocket.cancel();
},
render: function() {
var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
return (
/* jshint ignore:start */
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
React.DOM.header({className: "pending-header header-box"},
ConversationBranding(null)
),
React.DOM.div({id: "cameraPreview"}),
React.DOM.div({id: "messages"}),
React.DOM.p({className: "standalone-btn-label"},
callState
),
React.DOM.div({className: "btn-pending-cancel-group btn-group"},
React.DOM.div({className: "flex-padding-1"}),
React.DOM.button({className: "btn btn-large btn-cancel",
onClick: this._cancelOutgoingCall},
React.DOM.span({className: "standalone-call-btn-text"},
mozL10n.get("initiate_call_cancel_button")
)
),
React.DOM.div({className: "flex-padding-1"})
)
),
ConversationFooter(null)
)
/* jshint ignore:end */
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
@ -286,7 +356,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}),
React.DOM.p({className: "standalone-call-btn-label"},
React.DOM.p({className: "standalone-btn-label"},
mozL10n.get("initiate_call_button_label2")
),
@ -352,6 +422,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
"unsupportedDevice": "unsupportedDevice",
"unsupportedBrowser": "unsupportedBrowser",
"call/expired": "expired",
"call/pending/:token": "pendingConversation",
"call/ongoing/:token": "loadConversation",
"call/:token": "initiate"
},
@ -364,8 +435,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Load default view
this.loadReactComponent(HomeView(null));
this.listenTo(this._conversation, "timeout", this._onTimeout);
},
_onSessionExpired: function() {
@ -417,7 +486,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
this._notifications.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true});
} else {
this._setupWebSocketAndCallView(loopToken);
this.navigate("call/pending/" + loopToken, {
trigger: true
});
}
},
@ -427,16 +498,13 @@ loop.webapp = (function($, _, OT, mozL10n) {
*
* @param {string} loopToken The session token to use.
*/
_setupWebSocketAndCallView: function(loopToken) {
_setupWebSocketAndCallView: function() {
this._websocket = new loop.CallConnectionWebSocket({
url: this._conversation.get("progressURL"),
websocketToken: this._conversation.get("websocketToken"),
callId: this._conversation.get("callId"),
});
this._websocket.promiseConnect().then(function() {
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
}.bind(this), function() {
// XXX Not the ideal response, but bug 1047410 will be replacing
// this by better "call failed" UI.
@ -464,30 +532,48 @@ loop.webapp = (function($, _, OT, mozL10n) {
* it if appropraite.
*/
_handleWebSocketProgress: function(progressData) {
if (progressData.state === "terminated") {
// XXX Before adding more states here, the basic protocol messages to the
// server need implementing on both the standalone and desktop side.
// These are covered by bug 1045643, but also check the dependencies on
// bug 1034041.
//
// Failure to do this will break desktop - standalone call setup. We're
// ok to handle reject, as that is a specific message from the destkop via
// the server.
switch (progressData.reason) {
case "reject":
this._handleCallRejected();
switch(progressData.state) {
case "connecting": {
this._handleCallConnecting();
break;
}
case "terminated": {
// At the moment, we show the same text regardless
// of the terminated reason.
this._handleCallTerminated(progressData.reason);
break;
}
}
},
/**
* Handles call rejection.
* XXX This should really display the call failed view - bug 1046959
* will implement this.
* Handles a call moving to the connecting stage.
*/
_handleCallRejected: function() {
_handleCallConnecting: function() {
var loopToken = this._conversation.get("loopToken");
if (!loopToken) {
this._notifications.errorL10n("missing_conversation_info");
return;
}
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
},
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
*/
_handleCallTerminated: function(reason) {
this.endCall();
// For reasons other than cancel, display some notification text.
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this._notifications.errorL10n("call_timeout_notification_text");
}
},
/**
@ -501,10 +587,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.navigate(route, {trigger: true});
},
_onTimeout: function() {
this._notifications.errorL10n("call_timeout_notification_text");
},
/**
* Default entry point.
*/
@ -549,6 +631,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.loadReactComponent(startView);
},
pendingConversation: function(loopToken) {
if (!this._conversation.isSessionReady()) {
// User has loaded this url directly, actually setup the call.
return this.navigate("call/" + loopToken, {trigger: true});
}
this._setupWebSocketAndCallView();
this.loadReactComponent(PendingConversationView({
websocket: this._websocket
}));
},
/**
* Loads conversation establishment view.
*
@ -596,8 +689,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: new sharedModels.NotificationCollection(),
client: client,
conversation: new sharedModels.ConversationModel({}, {
sdk: OT,
pendingCallTimeout: loop.config.pendingCallTimeout
sdk: OT
})
});
@ -616,6 +708,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return {
baseServerUrl: baseServerUrl,
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,

View File

@ -120,6 +120,16 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var ConversationBranding = React.createClass({
render: function() {
return (
<h1 className="standalone-header-title">
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
</h1>
);
}
});
var ConversationHeader = React.createClass({
render: function() {
var cx = React.addons.classSet;
@ -138,10 +148,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
return (
/* jshint ignore:start */
<header className="standalone-header container-box">
<h1 className="standalone-header-title">
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
</h1>
<header className="standalone-header header-box container-box">
<ConversationBranding />
<div className="loop-logo" title="Firefox WebRTC! logo"></div>
<h3 className="call-url">
{conversationUrl}
@ -165,6 +173,68 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var PendingConversationView = React.createClass({
getInitialState: function() {
return {
callState: this.props.callState || "connecting"
}
},
propTypes: {
websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
.isRequired
},
componentDidMount: function() {
this.props.websocket.listenTo(this.props.websocket, "progress:alerting",
this._handleRingingProgress);
},
_handleRingingProgress: function() {
this.setState({callState: "ringing"});
},
_cancelOutgoingCall: function() {
this.props.websocket.cancel();
},
render: function() {
var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
return (
/* jshint ignore:start */
<div className="container">
<div className="container-box">
<header className="pending-header header-box">
<ConversationBranding />
</header>
<div id="cameraPreview"></div>
<div id="messages"></div>
<p className="standalone-btn-label">
{callState}
</p>
<div className="btn-pending-cancel-group btn-group">
<div className="flex-padding-1"></div>
<button className="btn btn-large btn-cancel"
onClick={this._cancelOutgoingCall} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_call_cancel_button")}
</span>
</button>
<div className="flex-padding-1"></div>
</div>
</div>
<ConversationFooter />
</div>
/* jshint ignore:end */
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
@ -286,7 +356,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
<ConversationHeader
urlCreationDateString={this.state.urlCreationDateString} />
<p className="standalone-call-btn-label">
<p className="standalone-btn-label">
{mozL10n.get("initiate_call_button_label2")}
</p>
@ -352,6 +422,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
"unsupportedDevice": "unsupportedDevice",
"unsupportedBrowser": "unsupportedBrowser",
"call/expired": "expired",
"call/pending/:token": "pendingConversation",
"call/ongoing/:token": "loadConversation",
"call/:token": "initiate"
},
@ -364,8 +435,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Load default view
this.loadReactComponent(<HomeView />);
this.listenTo(this._conversation, "timeout", this._onTimeout);
},
_onSessionExpired: function() {
@ -417,7 +486,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
this._notifications.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true});
} else {
this._setupWebSocketAndCallView(loopToken);
this.navigate("call/pending/" + loopToken, {
trigger: true
});
}
},
@ -427,16 +498,13 @@ loop.webapp = (function($, _, OT, mozL10n) {
*
* @param {string} loopToken The session token to use.
*/
_setupWebSocketAndCallView: function(loopToken) {
_setupWebSocketAndCallView: function() {
this._websocket = new loop.CallConnectionWebSocket({
url: this._conversation.get("progressURL"),
websocketToken: this._conversation.get("websocketToken"),
callId: this._conversation.get("callId"),
});
this._websocket.promiseConnect().then(function() {
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
}.bind(this), function() {
// XXX Not the ideal response, but bug 1047410 will be replacing
// this by better "call failed" UI.
@ -464,30 +532,48 @@ loop.webapp = (function($, _, OT, mozL10n) {
* it if appropraite.
*/
_handleWebSocketProgress: function(progressData) {
if (progressData.state === "terminated") {
// XXX Before adding more states here, the basic protocol messages to the
// server need implementing on both the standalone and desktop side.
// These are covered by bug 1045643, but also check the dependencies on
// bug 1034041.
//
// Failure to do this will break desktop - standalone call setup. We're
// ok to handle reject, as that is a specific message from the destkop via
// the server.
switch (progressData.reason) {
case "reject":
this._handleCallRejected();
switch(progressData.state) {
case "connecting": {
this._handleCallConnecting();
break;
}
case "terminated": {
// At the moment, we show the same text regardless
// of the terminated reason.
this._handleCallTerminated(progressData.reason);
break;
}
}
},
/**
* Handles call rejection.
* XXX This should really display the call failed view - bug 1046959
* will implement this.
* Handles a call moving to the connecting stage.
*/
_handleCallRejected: function() {
_handleCallConnecting: function() {
var loopToken = this._conversation.get("loopToken");
if (!loopToken) {
this._notifications.errorL10n("missing_conversation_info");
return;
}
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
},
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
*/
_handleCallTerminated: function(reason) {
this.endCall();
// For reasons other than cancel, display some notification text.
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this._notifications.errorL10n("call_timeout_notification_text");
}
},
/**
@ -501,10 +587,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.navigate(route, {trigger: true});
},
_onTimeout: function() {
this._notifications.errorL10n("call_timeout_notification_text");
},
/**
* Default entry point.
*/
@ -549,6 +631,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.loadReactComponent(startView);
},
pendingConversation: function(loopToken) {
if (!this._conversation.isSessionReady()) {
// User has loaded this url directly, actually setup the call.
return this.navigate("call/" + loopToken, {trigger: true});
}
this._setupWebSocketAndCallView();
this.loadReactComponent(PendingConversationView({
websocket: this._websocket
}));
},
/**
* Loads conversation establishment view.
*
@ -596,8 +689,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: new sharedModels.NotificationCollection(),
client: client,
conversation: new sharedModels.ConversationModel({}, {
sdk: OT,
pendingCallTimeout: loop.config.pendingCallTimeout
sdk: OT
})
});
@ -616,6 +708,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return {
baseServerUrl: baseServerUrl,
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,

View File

@ -36,7 +36,7 @@ initiate_call_button_label2=Ready to start your conversation?
initiate_audio_video_call_button2=Start
initiate_audio_video_call_tooltip2=Start a video conversation
initiate_audio_call_button2=Voice conversation
reject_incoming_call=Cancel
initiate_call_cancel_button=Cancel
legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}
terms_of_use_link_text=Terms of use
privacy_notice_link_text=Privacy notice

View File

@ -15,8 +15,7 @@ app.get('/content/config.js', function (req, res) {
res.send(
"var loop = loop || {};" +
"loop.config = loop.config || {};" +
"loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';" +
"loop.config.pendingCallTimeout = 20000;"
"loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';"
);
});

View File

@ -53,13 +53,6 @@ describe("loop.shared.models", function() {
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() {
@ -68,8 +61,7 @@ describe("loop.shared.models", function() {
beforeEach(function() {
conversation = new sharedModels.ConversationModel({}, {
sdk: fakeSDK,
pendingCallTimeout: 1000
sdk: fakeSDK
});
conversation.set("loopToken", "fakeToken");
fakeBaseServerUrl = "http://fakeBaseServerUrl";
@ -121,25 +113,6 @@ describe("loop.shared.models", function() {
conversation.outgoing();
});
it("should end the session on outgoing call timeout", function() {
conversation.outgoing();
sandbox.clock.tick(1001);
sinon.assert.calledOnce(conversation.endSession);
});
it("should trigger a `timeout` event on outgoing call timeout",
function(done) {
conversation.once("timeout", function() {
done();
});
conversation.outgoing();
sandbox.clock.tick(1001);
});
});
describe("#setSessionData", function() {
@ -168,11 +141,8 @@ describe("loop.shared.models", function() {
var model;
beforeEach(function() {
sandbox.stub(sharedModels.ConversationModel.prototype,
"_clearPendingCallTimer");
model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK,
pendingCallTimeout: 1000
sdk: fakeSDK
});
model.startSession();
});
@ -281,18 +251,6 @@ describe("loop.shared.models", function() {
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}};
@ -341,8 +299,7 @@ describe("loop.shared.models", function() {
beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK,
pendingCallTimeout: 1000
sdk: fakeSDK
});
model.startSession();
});
@ -381,8 +338,7 @@ describe("loop.shared.models", function() {
beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK,
pendingCallTimeout: 1000
sdk: fakeSDK
});
model.startSession();
});

View File

@ -176,6 +176,22 @@ describe("loop.CallConnectionWebSocket", function() {
});
});
describe("#cancel", function() {
it("should send a terminate message to the server with a reason of cancel",
function() {
callWebSocket.promiseConnect();
callWebSocket.cancel();
sinon.assert.calledOnce(dummySocket.send);
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
messageType: "action",
event: "terminate",
reason: "cancel"
}));
});
});
describe("Events", function() {
beforeEach(function() {
sandbox.stub(callWebSocket, "trigger");
@ -195,9 +211,24 @@ describe("loop.CallConnectionWebSocket", function() {
data: JSON.stringify(eventData)
});
sinon.assert.calledOnce(callWebSocket.trigger);
sinon.assert.called(callWebSocket.trigger);
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress", eventData);
});
it("should trigger a progress:<state> event on the callWebSocket", function() {
var eventData = {
messageType: "progress",
state: "terminate",
reason: "reject"
};
dummySocket.onmessage({
data: JSON.stringify(eventData)
});
sinon.assert.called(callWebSocket.trigger);
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress:terminate");
});
});
describe("Error", function() {

View File

@ -88,6 +88,14 @@ describe("loop.webapp", function() {
sandbox.stub(router, "navigate");
});
describe("#initialize", function() {
it("should require a conversation option", function() {
expect(function() {
new loop.webapp.WebappRouter();
}).to.Throw(Error, /missing required conversation/);
});
});
describe("#startCall", function() {
beforeEach(function() {
sandbox.stub(router, "_setupWebSocketAndCallView");
@ -109,13 +117,14 @@ describe("loop.webapp", function() {
"missing_conversation_info");
});
it("should setup the websocket if session token is available", function() {
it("should navigate to the pending view if session token is available",
function() {
conversation.set("loopToken", "fake");
router.startCall();
sinon.assert.calledOnce(router._setupWebSocketAndCallView);
sinon.assert.calledWithExactly(router._setupWebSocketAndCallView, "fake");
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWithMatch(router.navigate, "call/pending/fake");
});
});
@ -126,7 +135,7 @@ describe("loop.webapp", function() {
sessionToken: "sessionToken",
apiKey: "apiKey",
callId: "Hello",
progressURL: "http://progress.example.com",
progressURL: "http://invalid/url",
websocketToken: 123
});
});
@ -154,23 +163,13 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(loop.CallConnectionWebSocket);
sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
callId: "Hello",
url: "http://progress.example.com",
url: "http://invalid/url",
// The websocket token is converted to a hex string.
websocketToken: "7b"
});
done();
});
});
it("should navigate to call/ongoing/:token", function(done) {
router._setupWebSocketAndCallView("fake");
promise.then(function () {
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
done();
});
});
});
describe("Websocket connection failed", function() {
@ -226,6 +225,7 @@ describe("loop.webapp", function() {
describe("state: terminate, reason: reject", function() {
beforeEach(function() {
sandbox.stub(router, "endCall");
sandbox.stub(notifications, "errorL10n");
});
it("should end the call", function() {
@ -237,18 +237,40 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(router.endCall);
});
it("should display an error message", function() {
sandbox.stub(notifications, "errorL10n");
it("should display an error message if the reason is not 'cancel'",
function() {
router._websocket.trigger("progress", {
state: "terminated",
reason: "reject"
});
sinon.assert.calledOnce(router._notifications.errorL10n);
sinon.assert.calledWithExactly(router._notifications.errorL10n,
sinon.assert.calledOnce(notifications.errorL10n);
sinon.assert.calledWithExactly(notifications.errorL10n,
"call_timeout_notification_text");
});
it("should not display an error message if the reason is 'cancel'",
function() {
router._websocket.trigger("progress", {
state: "terminated",
reason: "cancel"
});
sinon.assert.notCalled(notifications.errorL10n);
});
});
describe("state: connecting", function() {
it("should navigate to the ongoing view", function() {
conversation.set({"loopToken": "fakeToken"});
router._websocket.trigger("progress", {
state: "connecting"
});
sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
});
});
});
});
@ -334,6 +356,38 @@ describe("loop.webapp", function() {
});
});
describe("#pendingConversation", function() {
beforeEach(function() {
sandbox.stub(router, "_setupWebSocketAndCallView");
conversation.setOutgoingSessionData({
sessionId: "sessionId",
sessionToken: "sessionToken",
apiKey: "apiKey",
callId: "Hello",
progressURL: "http://progress.example.com",
websocketToken: 123
});
});
it("should setup the websocket", function() {
router.pendingConversation();
sinon.assert.calledOnce(router._setupWebSocketAndCallView);
sinon.assert.calledWithExactly(router._setupWebSocketAndCallView);
});
it("should load the PendingConversationView", function() {
router.pendingConversation();
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isDescriptorOfType(
value, loop.webapp.PendingConversationView);
}));
});
});
describe("#loadConversation", function() {
it("should load the ConversationView if session is set", function() {
conversation.set("sessionId", "fakeSessionId");
@ -548,15 +602,46 @@ describe("loop.webapp", function() {
});
});
describe("StartConversationView", function() {
describe("#initialize", function() {
it("should require a conversation option", function() {
expect(function() {
new loop.webapp.WebappRouter();
}).to.Throw(Error, /missing required conversation/);
describe("PendingConversationView", function() {
var view, websocket;
beforeEach(function() {
websocket = new loop.CallConnectionWebSocket({
url: "wss://fake/",
callId: "callId",
websocketToken: "7b"
});
sinon.stub(websocket, "cancel");
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.PendingConversationView({
websocket: websocket
})
);
});
describe("#_cancelOutgoingCall", function() {
it("should inform the websocket to cancel the setup", function() {
var button = view.getDOMNode().querySelector(".btn-cancel");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(websocket.cancel);
});
});
describe("Events", function() {
describe("progress:alerting", function() {
it("should update the callstate to ringing", function () {
websocket.trigger("progress:alerting");
expect(view.state.callState).to.be.equal("ringing");
});
});
});
});
describe("StartConversationView", function() {
describe("#initiate", function() {
var conversation, setupOutgoingCall, view, fakeSubmitEvent,
requestCallUrlInfo;

View File

@ -21,6 +21,7 @@
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
// 3. Shared components
@ -195,6 +196,19 @@
)
),
Section({name: "PendingConversationView"},
Example({summary: "Pending conversation view (connecting)", dashed: "true"},
React.DOM.div({className: "standalone"},
PendingConversationView(null)
)
),
Example({summary: "Pending conversation view (ringing)", dashed: "true"},
React.DOM.div({className: "standalone"},
PendingConversationView({callState: "ringing"})
)
)
),
Section({name: "StartConversationView"},
Example({summary: "Start conversation view", dashed: "true"},
React.DOM.div({className: "standalone"},

View File

@ -21,6 +21,7 @@
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
// 3. Shared components
@ -195,6 +196,19 @@
</div>
</Section>
<Section name="PendingConversationView">
<Example summary="Pending conversation view (connecting)" dashed="true">
<div className="standalone">
<PendingConversationView />
</div>
</Example>
<Example summary="Pending conversation view (ringing)" dashed="true">
<div className="standalone">
<PendingConversationView callState="ringing"/>
</div>
</Example>
</Section>
<Section name="StartConversationView">
<Example summary="Start conversation view" dashed="true">
<div className="standalone">