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

View File

@ -51,18 +51,6 @@ loop.shared.models = (function(l10n) {
*/ */
session: undefined, session: undefined,
/**
* Pending call timeout value.
* @type {Number}
*/
pendingCallTimeout: undefined,
/**
* Pending call timer.
* @type {Number}
*/
_pendingCallTimer: undefined,
/** /**
* Constructor. * Constructor.
* *
@ -71,10 +59,6 @@ loop.shared.models = (function(l10n) {
* Required: * Required:
* - {OT} sdk: OT SDK object. * - {OT} sdk: OT SDK object.
* *
* Optional:
* - {Number} pendingCallTimeout: Pending call timeout in milliseconds
* (default: 20000).
*
* @param {Object} attributes Attributes object. * @param {Object} attributes Attributes object.
* @param {Object} options Options object. * @param {Object} options Options object.
*/ */
@ -84,10 +68,6 @@ loop.shared.models = (function(l10n) {
throw new Error("missing required sdk"); throw new Error("missing required sdk");
} }
this.sdk = options.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. * server for the outgoing call.
*/ */
outgoing: function(sessionData) { 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.setOutgoingSessionData(sessionData);
this.trigger("call:outgoing"); 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 * Manages connection status
* triggers apropriate event for connection error/success * 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. * Sends data on the websocket.
* *
@ -206,6 +218,7 @@ loop.CallConnectionWebSocket = (function() {
this._completeConnection(); this._completeConnection();
break; break;
case "progress": case "progress":
this.trigger("progress:" + msg.state);
this.trigger("progress", msg); this.trigger("progress", msg);
break; break;
} }

View File

@ -3,7 +3,6 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000}) 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 NODE_LOCAL_BIN=./node_modules/.bin
install: install:
@ -53,4 +52,3 @@ config:
@echo "var loop = loop || {};" > content/config.js @echo "var loop = loop || {};" > content/config.js
@echo "loop.config = loop.config || {};" >> 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.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 { .standalone-header {
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
padding: 1rem 5rem;
border: 1px solid #E7E7E7; border: 1px solid #E7E7E7;
box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03); box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
}
.header-box {
padding: 1rem 5rem;
margin-top: 2rem; margin-top: 2rem;
} }
@ -103,7 +106,7 @@ body,
} }
.standalone-header-title, .standalone-header-title,
.standalone-call-btn-label { .standalone-btn-label {
font-weight: lighter; font-weight: lighter;
} }
@ -112,7 +115,7 @@ body,
line-height: 2.2rem; line-height: 2.2rem;
} }
.standalone-call-btn-label { .standalone-btn-label {
font-size: 1.2rem; font-size: 1.2rem;
} }
@ -179,6 +182,10 @@ body,
} }
} }
.btn-pending-cancel-group > .btn-cancel {
flex: 2 1 auto;
}
.btn-large { .btn-large {
/* Dimensions from spec /* Dimensions from spec
* https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */ * 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', var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
render: function() { render: function() {
var cx = React.addons.classSet; var cx = React.addons.classSet;
@ -138,10 +148,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
return ( return (
/* jshint ignore:start */ /* jshint ignore:start */
React.DOM.header({className: "standalone-header container-box"}, React.DOM.header({className: "standalone-header header-box container-box"},
React.DOM.h1({className: "standalone-header-title"}, ConversationBranding(null),
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
),
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}), React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
React.DOM.h3({className: "call-url"}, React.DOM.h3({className: "call-url"},
conversationUrl 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 * Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property. * as a `model` property.
@ -286,7 +356,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
ConversationHeader({ ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}), 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") mozL10n.get("initiate_call_button_label2")
), ),
@ -352,6 +422,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
"unsupportedDevice": "unsupportedDevice", "unsupportedDevice": "unsupportedDevice",
"unsupportedBrowser": "unsupportedBrowser", "unsupportedBrowser": "unsupportedBrowser",
"call/expired": "expired", "call/expired": "expired",
"call/pending/:token": "pendingConversation",
"call/ongoing/:token": "loadConversation", "call/ongoing/:token": "loadConversation",
"call/:token": "initiate" "call/:token": "initiate"
}, },
@ -364,8 +435,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Load default view // Load default view
this.loadReactComponent(HomeView(null)); this.loadReactComponent(HomeView(null));
this.listenTo(this._conversation, "timeout", this._onTimeout);
}, },
_onSessionExpired: function() { _onSessionExpired: function() {
@ -417,7 +486,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
this._notifications.errorL10n("missing_conversation_info"); this._notifications.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true}); this.navigate("home", {trigger: true});
} else { } 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. * @param {string} loopToken The session token to use.
*/ */
_setupWebSocketAndCallView: function(loopToken) { _setupWebSocketAndCallView: function() {
this._websocket = new loop.CallConnectionWebSocket({ this._websocket = new loop.CallConnectionWebSocket({
url: this._conversation.get("progressURL"), url: this._conversation.get("progressURL"),
websocketToken: this._conversation.get("websocketToken"), websocketToken: this._conversation.get("websocketToken"),
callId: this._conversation.get("callId"), callId: this._conversation.get("callId"),
}); });
this._websocket.promiseConnect().then(function() { this._websocket.promiseConnect().then(function() {
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
}.bind(this), function() { }.bind(this), function() {
// XXX Not the ideal response, but bug 1047410 will be replacing // XXX Not the ideal response, but bug 1047410 will be replacing
// this by better "call failed" UI. // this by better "call failed" UI.
@ -464,30 +532,48 @@ loop.webapp = (function($, _, OT, mozL10n) {
* it if appropraite. * it if appropraite.
*/ */
_handleWebSocketProgress: function(progressData) { _handleWebSocketProgress: function(progressData) {
if (progressData.state === "terminated") { switch(progressData.state) {
// XXX Before adding more states here, the basic protocol messages to the case "connecting": {
// server need implementing on both the standalone and desktop side. this._handleCallConnecting();
// These are covered by bug 1045643, but also check the dependencies on break;
// bug 1034041. }
// case "terminated": {
// Failure to do this will break desktop - standalone call setup. We're // At the moment, we show the same text regardless
// ok to handle reject, as that is a specific message from the destkop via // of the terminated reason.
// the server. this._handleCallTerminated(progressData.reason);
switch (progressData.reason) { break;
case "reject":
this._handleCallRejected();
} }
} }
}, },
/** /**
* Handles call rejection. * Handles a call moving to the connecting stage.
* XXX This should really display the call failed view - bug 1046959
* will implement this.
*/ */
_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(); 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"); this._notifications.errorL10n("call_timeout_notification_text");
}
}, },
/** /**
@ -501,10 +587,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.navigate(route, {trigger: true}); this.navigate(route, {trigger: true});
}, },
_onTimeout: function() {
this._notifications.errorL10n("call_timeout_notification_text");
},
/** /**
* Default entry point. * Default entry point.
*/ */
@ -549,6 +631,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.loadReactComponent(startView); 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. * Loads conversation establishment view.
* *
@ -596,8 +689,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: new sharedModels.NotificationCollection(), notifications: new sharedModels.NotificationCollection(),
client: client, client: client,
conversation: new sharedModels.ConversationModel({}, { conversation: new sharedModels.ConversationModel({}, {
sdk: OT, sdk: OT
pendingCallTimeout: loop.config.pendingCallTimeout
}) })
}); });
@ -616,6 +708,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return { return {
baseServerUrl: baseServerUrl, baseServerUrl: baseServerUrl,
CallUrlExpiredView: CallUrlExpiredView, CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView, StartConversationView: StartConversationView,
HomeView: HomeView, HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView, 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({ var ConversationHeader = React.createClass({
render: function() { render: function() {
var cx = React.addons.classSet; var cx = React.addons.classSet;
@ -138,10 +148,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
return ( return (
/* jshint ignore:start */ /* jshint ignore:start */
<header className="standalone-header container-box"> <header className="standalone-header header-box container-box">
<h1 className="standalone-header-title"> <ConversationBranding />
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
</h1>
<div className="loop-logo" title="Firefox WebRTC! logo"></div> <div className="loop-logo" title="Firefox WebRTC! logo"></div>
<h3 className="call-url"> <h3 className="call-url">
{conversationUrl} {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 * Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property. * as a `model` property.
@ -286,7 +356,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
<ConversationHeader <ConversationHeader
urlCreationDateString={this.state.urlCreationDateString} /> urlCreationDateString={this.state.urlCreationDateString} />
<p className="standalone-call-btn-label"> <p className="standalone-btn-label">
{mozL10n.get("initiate_call_button_label2")} {mozL10n.get("initiate_call_button_label2")}
</p> </p>
@ -352,6 +422,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
"unsupportedDevice": "unsupportedDevice", "unsupportedDevice": "unsupportedDevice",
"unsupportedBrowser": "unsupportedBrowser", "unsupportedBrowser": "unsupportedBrowser",
"call/expired": "expired", "call/expired": "expired",
"call/pending/:token": "pendingConversation",
"call/ongoing/:token": "loadConversation", "call/ongoing/:token": "loadConversation",
"call/:token": "initiate" "call/:token": "initiate"
}, },
@ -364,8 +435,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Load default view // Load default view
this.loadReactComponent(<HomeView />); this.loadReactComponent(<HomeView />);
this.listenTo(this._conversation, "timeout", this._onTimeout);
}, },
_onSessionExpired: function() { _onSessionExpired: function() {
@ -417,7 +486,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
this._notifications.errorL10n("missing_conversation_info"); this._notifications.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true}); this.navigate("home", {trigger: true});
} else { } 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. * @param {string} loopToken The session token to use.
*/ */
_setupWebSocketAndCallView: function(loopToken) { _setupWebSocketAndCallView: function() {
this._websocket = new loop.CallConnectionWebSocket({ this._websocket = new loop.CallConnectionWebSocket({
url: this._conversation.get("progressURL"), url: this._conversation.get("progressURL"),
websocketToken: this._conversation.get("websocketToken"), websocketToken: this._conversation.get("websocketToken"),
callId: this._conversation.get("callId"), callId: this._conversation.get("callId"),
}); });
this._websocket.promiseConnect().then(function() { this._websocket.promiseConnect().then(function() {
this.navigate("call/ongoing/" + loopToken, {
trigger: true
});
}.bind(this), function() { }.bind(this), function() {
// XXX Not the ideal response, but bug 1047410 will be replacing // XXX Not the ideal response, but bug 1047410 will be replacing
// this by better "call failed" UI. // this by better "call failed" UI.
@ -464,30 +532,48 @@ loop.webapp = (function($, _, OT, mozL10n) {
* it if appropraite. * it if appropraite.
*/ */
_handleWebSocketProgress: function(progressData) { _handleWebSocketProgress: function(progressData) {
if (progressData.state === "terminated") { switch(progressData.state) {
// XXX Before adding more states here, the basic protocol messages to the case "connecting": {
// server need implementing on both the standalone and desktop side. this._handleCallConnecting();
// These are covered by bug 1045643, but also check the dependencies on break;
// bug 1034041. }
// case "terminated": {
// Failure to do this will break desktop - standalone call setup. We're // At the moment, we show the same text regardless
// ok to handle reject, as that is a specific message from the destkop via // of the terminated reason.
// the server. this._handleCallTerminated(progressData.reason);
switch (progressData.reason) { break;
case "reject":
this._handleCallRejected();
} }
} }
}, },
/** /**
* Handles call rejection. * Handles a call moving to the connecting stage.
* XXX This should really display the call failed view - bug 1046959
* will implement this.
*/ */
_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(); 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"); this._notifications.errorL10n("call_timeout_notification_text");
}
}, },
/** /**
@ -501,10 +587,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.navigate(route, {trigger: true}); this.navigate(route, {trigger: true});
}, },
_onTimeout: function() {
this._notifications.errorL10n("call_timeout_notification_text");
},
/** /**
* Default entry point. * Default entry point.
*/ */
@ -549,6 +631,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
this.loadReactComponent(startView); 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. * Loads conversation establishment view.
* *
@ -596,8 +689,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
notifications: new sharedModels.NotificationCollection(), notifications: new sharedModels.NotificationCollection(),
client: client, client: client,
conversation: new sharedModels.ConversationModel({}, { conversation: new sharedModels.ConversationModel({}, {
sdk: OT, sdk: OT
pendingCallTimeout: loop.config.pendingCallTimeout
}) })
}); });
@ -616,6 +708,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
return { return {
baseServerUrl: baseServerUrl, baseServerUrl: baseServerUrl,
CallUrlExpiredView: CallUrlExpiredView, CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView, StartConversationView: StartConversationView,
HomeView: HomeView, HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView, 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_button2=Start
initiate_audio_video_call_tooltip2=Start a video conversation initiate_audio_video_call_tooltip2=Start a video conversation
initiate_audio_call_button2=Voice 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}} 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 terms_of_use_link_text=Terms of use
privacy_notice_link_text=Privacy notice privacy_notice_link_text=Privacy notice

View File

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

View File

@ -53,13 +53,6 @@ describe("loop.shared.models", function() {
new sharedModels.ConversationModel({}, {}); new sharedModels.ConversationModel({}, {});
}).to.Throw(Error, /missing required sdk/); }).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() { describe("constructed", function() {
@ -68,8 +61,7 @@ describe("loop.shared.models", function() {
beforeEach(function() { beforeEach(function() {
conversation = new sharedModels.ConversationModel({}, { conversation = new sharedModels.ConversationModel({}, {
sdk: fakeSDK, sdk: fakeSDK
pendingCallTimeout: 1000
}); });
conversation.set("loopToken", "fakeToken"); conversation.set("loopToken", "fakeToken");
fakeBaseServerUrl = "http://fakeBaseServerUrl"; fakeBaseServerUrl = "http://fakeBaseServerUrl";
@ -121,25 +113,6 @@ describe("loop.shared.models", function() {
conversation.outgoing(); 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() { describe("#setSessionData", function() {
@ -168,11 +141,8 @@ describe("loop.shared.models", function() {
var model; var model;
beforeEach(function() { beforeEach(function() {
sandbox.stub(sharedModels.ConversationModel.prototype,
"_clearPendingCallTimer");
model = new sharedModels.ConversationModel(fakeSessionData, { model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK, sdk: fakeSDK
pendingCallTimeout: 1000
}); });
model.startSession(); model.startSession();
}); });
@ -281,18 +251,6 @@ describe("loop.shared.models", function() {
expect(model.get("ongoing")).eql(false); 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() { describe("connectionDestroyed event received", function() {
var fakeEvent = {reason: "ko", connection: {connectionId: 42}}; var fakeEvent = {reason: "ko", connection: {connectionId: 42}};
@ -341,8 +299,7 @@ describe("loop.shared.models", function() {
beforeEach(function() { beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, { model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK, sdk: fakeSDK
pendingCallTimeout: 1000
}); });
model.startSession(); model.startSession();
}); });
@ -381,8 +338,7 @@ describe("loop.shared.models", function() {
beforeEach(function() { beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, { model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK, sdk: fakeSDK
pendingCallTimeout: 1000
}); });
model.startSession(); 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() { describe("Events", function() {
beforeEach(function() { beforeEach(function() {
sandbox.stub(callWebSocket, "trigger"); sandbox.stub(callWebSocket, "trigger");
@ -195,9 +211,24 @@ describe("loop.CallConnectionWebSocket", function() {
data: JSON.stringify(eventData) data: JSON.stringify(eventData)
}); });
sinon.assert.calledOnce(callWebSocket.trigger); sinon.assert.called(callWebSocket.trigger);
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress", eventData); 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() { describe("Error", function() {

View File

@ -88,6 +88,14 @@ describe("loop.webapp", function() {
sandbox.stub(router, "navigate"); 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() { describe("#startCall", function() {
beforeEach(function() { beforeEach(function() {
sandbox.stub(router, "_setupWebSocketAndCallView"); sandbox.stub(router, "_setupWebSocketAndCallView");
@ -109,13 +117,14 @@ describe("loop.webapp", function() {
"missing_conversation_info"); "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"); conversation.set("loopToken", "fake");
router.startCall(); router.startCall();
sinon.assert.calledOnce(router._setupWebSocketAndCallView); sinon.assert.calledOnce(router.navigate);
sinon.assert.calledWithExactly(router._setupWebSocketAndCallView, "fake"); sinon.assert.calledWithMatch(router.navigate, "call/pending/fake");
}); });
}); });
@ -126,7 +135,7 @@ describe("loop.webapp", function() {
sessionToken: "sessionToken", sessionToken: "sessionToken",
apiKey: "apiKey", apiKey: "apiKey",
callId: "Hello", callId: "Hello",
progressURL: "http://progress.example.com", progressURL: "http://invalid/url",
websocketToken: 123 websocketToken: 123
}); });
}); });
@ -154,23 +163,13 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(loop.CallConnectionWebSocket); sinon.assert.calledOnce(loop.CallConnectionWebSocket);
sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, { sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
callId: "Hello", callId: "Hello",
url: "http://progress.example.com", url: "http://invalid/url",
// The websocket token is converted to a hex string. // The websocket token is converted to a hex string.
websocketToken: "7b" websocketToken: "7b"
}); });
done(); 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() { describe("Websocket connection failed", function() {
@ -226,6 +225,7 @@ describe("loop.webapp", function() {
describe("state: terminate, reason: reject", function() { describe("state: terminate, reason: reject", function() {
beforeEach(function() { beforeEach(function() {
sandbox.stub(router, "endCall"); sandbox.stub(router, "endCall");
sandbox.stub(notifications, "errorL10n");
}); });
it("should end the call", function() { it("should end the call", function() {
@ -237,18 +237,40 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(router.endCall); sinon.assert.calledOnce(router.endCall);
}); });
it("should display an error message", function() { it("should display an error message if the reason is not 'cancel'",
sandbox.stub(notifications, "errorL10n"); function() {
router._websocket.trigger("progress", { router._websocket.trigger("progress", {
state: "terminated", state: "terminated",
reason: "reject" reason: "reject"
}); });
sinon.assert.calledOnce(router._notifications.errorL10n); sinon.assert.calledOnce(notifications.errorL10n);
sinon.assert.calledWithExactly(router._notifications.errorL10n, sinon.assert.calledWithExactly(notifications.errorL10n,
"call_timeout_notification_text"); "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() { describe("#loadConversation", function() {
it("should load the ConversationView if session is set", function() { it("should load the ConversationView if session is set", function() {
conversation.set("sessionId", "fakeSessionId"); conversation.set("sessionId", "fakeSessionId");
@ -548,15 +602,46 @@ describe("loop.webapp", function() {
}); });
}); });
describe("StartConversationView", function() { describe("PendingConversationView", function() {
describe("#initialize", function() { var view, websocket;
it("should require a conversation option", function() {
expect(function() { beforeEach(function() {
new loop.webapp.WebappRouter(); websocket = new loop.CallConnectionWebSocket({
}).to.Throw(Error, /missing required conversation/); 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() { describe("#initiate", function() {
var conversation, setupOutgoingCall, view, fakeSubmitEvent, var conversation, setupOutgoingCall, view, fakeSubmitEvent,
requestCallUrlInfo; requestCallUrlInfo;

View File

@ -21,6 +21,7 @@
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView; var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView; var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView; var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView; var StartConversationView = loop.webapp.StartConversationView;
// 3. Shared components // 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"}, Section({name: "StartConversationView"},
Example({summary: "Start conversation view", dashed: "true"}, Example({summary: "Start conversation view", dashed: "true"},
React.DOM.div({className: "standalone"}, React.DOM.div({className: "standalone"},

View File

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