Bug 972017 Part 3 - Finish the view flow transition for direct calling for Loop. r=nperriault

This commit is contained in:
Mark Banner 2014-10-02 19:55:22 +01:00
parent ad583709b0
commit 85b2d189ea
12 changed files with 888 additions and 78 deletions

View File

@ -483,7 +483,8 @@ loop.conversation = (function(mozL10n) {
sdk: React.PropTypes.object.isRequired, sdk: React.PropTypes.object.isRequired,
// XXX New types for OutgoingConversationView // XXX New types for OutgoingConversationView
store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
}, },
getInitialState: function() { getInitialState: function() {
@ -504,7 +505,8 @@ loop.conversation = (function(mozL10n) {
if (this.state.outgoing) { if (this.state.outgoing) {
return (OutgoingConversationView({ return (OutgoingConversationView({
store: this.props.store} store: this.props.store,
dispatcher: this.props.dispatcher}
)); ));
} }
@ -517,7 +519,7 @@ loop.conversation = (function(mozL10n) {
}); });
/** /**
* Panel initialisation. * Conversation initialisation.
*/ */
function init() { function init() {
// Do the initial L10n setup, we do this before anything // Do the initial L10n setup, we do this before anything
@ -574,6 +576,7 @@ loop.conversation = (function(mozL10n) {
store: conversationStore, store: conversationStore,
client: client, client: client,
conversation: conversation, conversation: conversation,
dispatcher: dispatcher,
sdk: window.OT} sdk: window.OT}
), document.querySelector('#main')); ), document.querySelector('#main'));

View File

@ -483,7 +483,8 @@ loop.conversation = (function(mozL10n) {
sdk: React.PropTypes.object.isRequired, sdk: React.PropTypes.object.isRequired,
// XXX New types for OutgoingConversationView // XXX New types for OutgoingConversationView
store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
}, },
getInitialState: function() { getInitialState: function() {
@ -505,6 +506,7 @@ loop.conversation = (function(mozL10n) {
if (this.state.outgoing) { if (this.state.outgoing) {
return (<OutgoingConversationView return (<OutgoingConversationView
store={this.props.store} store={this.props.store}
dispatcher={this.props.dispatcher}
/>); />);
} }
@ -517,7 +519,7 @@ loop.conversation = (function(mozL10n) {
}); });
/** /**
* Panel initialisation. * Conversation initialisation.
*/ */
function init() { function init() {
// Do the initial L10n setup, we do this before anything // Do the initial L10n setup, we do this before anything
@ -574,6 +576,7 @@ loop.conversation = (function(mozL10n) {
store={conversationStore} store={conversationStore}
client={client} client={client}
conversation={conversation} conversation={conversation}
dispatcher={dispatcher}
sdk={window.OT} sdk={window.OT}
/>, document.querySelector('#main')); />, document.querySelector('#main'));

View File

@ -10,6 +10,9 @@ var loop = loop || {};
loop.conversationViews = (function(mozL10n) { loop.conversationViews = (function(mozL10n) {
var CALL_STATES = loop.store.CALL_STATES; var CALL_STATES = loop.store.CALL_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views;
/** /**
* Displays details of the incoming/outgoing conversation * Displays details of the incoming/outgoing conversation
@ -41,11 +44,24 @@ loop.conversationViews = (function(mozL10n) {
*/ */
var PendingConversationView = React.createClass({displayName: 'PendingConversationView', var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
propTypes: { propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
callState: React.PropTypes.string, callState: React.PropTypes.string,
calleeId: React.PropTypes.string, calleeId: React.PropTypes.string,
enableCancelButton: React.PropTypes.bool
},
getDefaultProps: function() {
return {
enableCancelButton: false
};
},
cancelCall: function() {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
}, },
render: function() { render: function() {
var cx = React.addons.classSet;
var pendingStateString; var pendingStateString;
if (this.props.callState === CALL_STATES.ALERTING) { if (this.props.callState === CALL_STATES.ALERTING) {
pendingStateString = mozL10n.get("call_progress_ringing_description"); pendingStateString = mozL10n.get("call_progress_ringing_description");
@ -53,6 +69,12 @@ loop.conversationViews = (function(mozL10n) {
pendingStateString = mozL10n.get("call_progress_connecting_description"); pendingStateString = mozL10n.get("call_progress_connecting_description");
} }
var btnCancelStyles = cx({
"btn": true,
"btn-cancel": true,
"disabled": !this.props.enableCancelButton
});
return ( return (
ConversationDetailView({calleeId: this.props.calleeId}, ConversationDetailView({calleeId: this.props.calleeId},
@ -60,7 +82,8 @@ loop.conversationViews = (function(mozL10n) {
React.DOM.div({className: "btn-group call-action-group"}, React.DOM.div({className: "btn-group call-action-group"},
React.DOM.div({className: "fx-embedded-call-button-spacer"}), React.DOM.div({className: "fx-embedded-call-button-spacer"}),
React.DOM.button({className: "btn btn-cancel"}, React.DOM.button({className: btnCancelStyles,
onClick: this.cancelCall},
mozL10n.get("initiate_call_cancel_button") mozL10n.get("initiate_call_cancel_button")
), ),
React.DOM.div({className: "fx-embedded-call-button-spacer"}) React.DOM.div({className: "fx-embedded-call-button-spacer"})
@ -75,10 +98,115 @@ loop.conversationViews = (function(mozL10n) {
* Call failed view. Displayed when a call fails. * Call failed view. Displayed when a call fails.
*/ */
var CallFailedView = React.createClass({displayName: 'CallFailedView', var CallFailedView = React.createClass({displayName: 'CallFailedView',
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
retryCall: function() {
this.props.dispatcher.dispatch(new sharedActions.RetryCall());
},
cancelCall: function() {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
},
render: function() { render: function() {
return ( return (
React.DOM.div({className: "call-window"}, React.DOM.div({className: "call-window"},
React.DOM.h2(null, mozL10n.get("generic_failure_title")) React.DOM.h2(null, mozL10n.get("generic_failure_title")),
React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_no_reason2")),
React.DOM.div({className: "btn-group call-action-group"},
React.DOM.div({className: "fx-embedded-call-button-spacer"}),
React.DOM.button({className: "btn btn-accept btn-retry",
onClick: this.retryCall},
mozL10n.get("retry_call_button")
),
React.DOM.div({className: "fx-embedded-call-button-spacer"}),
React.DOM.button({className: "btn btn-cancel",
onClick: this.cancelCall},
mozL10n.get("cancel_button")
),
React.DOM.div({className: "fx-embedded-call-button-spacer"})
)
)
);
}
});
var OngoingConversationView = React.createClass({displayName: 'OngoingConversationView',
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
video: React.PropTypes.object,
audio: React.PropTypes.object
},
getDefaultProps: function() {
return {
video: {enabled: true, visible: true},
audio: {enabled: true, visible: true}
};
},
componentDidMount: function() {
/**
* OT inserts inline styles into the markup. Using a listener for
* resize events helps us trigger a full width/height on the element
* so that they update to the correct dimensions.
* XXX: this should be factored as a mixin.
*/
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
},
componentWillUnmount: function() {
window.removeEventListener('orientationchange', this.updateVideoContainer);
window.removeEventListener('resize', this.updateVideoContainer);
},
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
if (localStreamParent) {
localStreamParent.style.width = "100%";
}
if (remoteStreamParent) {
remoteStreamParent.style.height = "100%";
}
},
hangup: function() {
this.props.dispatcher.dispatch(
new sharedActions.HangupCall());
},
publishStream: function(type, enabled) {
// XXX Add this as part of bug 972017.
},
render: function() {
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": !this.props.video.enabled
});
return (
React.DOM.div({className: "video-layout-wrapper"},
React.DOM.div({className: "conversation"},
React.DOM.div({className: "media nested"},
React.DOM.div({className: "video_wrapper remote_wrapper"},
React.DOM.div({className: "video_inner remote"})
),
React.DOM.div({className: localStreamClasses})
),
loop.shared.views.ConversationToolbar({
video: this.props.video,
audio: this.props.audio,
publishStream: this.publishStream,
hangup: this.hangup})
)
) )
); );
} }
@ -90,6 +218,7 @@ loop.conversationViews = (function(mozL10n) {
*/ */
var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView', var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
propTypes: { propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf( store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired loop.store.ConversationStore).isRequired
}, },
@ -104,22 +233,83 @@ loop.conversationViews = (function(mozL10n) {
}, this); }, this);
}, },
render: function() { _closeWindow: function() {
if (this.state.callState === CALL_STATES.TERMINATED) { window.close();
return (CallFailedView(null)); },
}
/**
* Returns true if the call is in a cancellable state, during call setup.
*/
_isCancellable: function() {
return this.state.callState !== CALL_STATES.INIT &&
this.state.callState !== CALL_STATES.GATHER;
},
/**
* Used to setup and render the feedback view.
*/
_renderFeedbackView: function() {
document.title = mozL10n.get("conversation_has_ended");
// XXX Bug 1076754 Feedback view should be redone in the Flux style.
var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopCharPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
sharedViews.FeedbackView({
feedbackApiClient: feedbackClient,
onAfterFeedbackReceived: this._closeWindow.bind(this)}
)
);
},
render: function() {
switch (this.state.callState) {
case CALL_STATES.CLOSE: {
this._closeWindow();
return null;
}
case CALL_STATES.TERMINATED: {
return (CallFailedView({
dispatcher: this.props.dispatcher}
));
}
case CALL_STATES.ONGOING: {
return (OngoingConversationView({
dispatcher: this.props.dispatcher,
video: {enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
)
);
}
case CALL_STATES.FINISHED: {
return this._renderFeedbackView();
}
default: {
return (PendingConversationView({ return (PendingConversationView({
dispatcher: this.props.dispatcher,
callState: this.state.callState, callState: this.state.callState,
calleeId: this.state.calleeId} calleeId: this.state.calleeId,
enableCancelButton: this._isCancellable()}
)) ))
} }
}
},
}); });
return { return {
PendingConversationView: PendingConversationView, PendingConversationView: PendingConversationView,
ConversationDetailView: ConversationDetailView, ConversationDetailView: ConversationDetailView,
CallFailedView: CallFailedView, CallFailedView: CallFailedView,
OngoingConversationView: OngoingConversationView,
OutgoingConversationView: OutgoingConversationView OutgoingConversationView: OutgoingConversationView
}; };

View File

@ -10,6 +10,9 @@ var loop = loop || {};
loop.conversationViews = (function(mozL10n) { loop.conversationViews = (function(mozL10n) {
var CALL_STATES = loop.store.CALL_STATES; var CALL_STATES = loop.store.CALL_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views;
/** /**
* Displays details of the incoming/outgoing conversation * Displays details of the incoming/outgoing conversation
@ -41,11 +44,24 @@ loop.conversationViews = (function(mozL10n) {
*/ */
var PendingConversationView = React.createClass({ var PendingConversationView = React.createClass({
propTypes: { propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
callState: React.PropTypes.string, callState: React.PropTypes.string,
calleeId: React.PropTypes.string, calleeId: React.PropTypes.string,
enableCancelButton: React.PropTypes.bool
},
getDefaultProps: function() {
return {
enableCancelButton: false
};
},
cancelCall: function() {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
}, },
render: function() { render: function() {
var cx = React.addons.classSet;
var pendingStateString; var pendingStateString;
if (this.props.callState === CALL_STATES.ALERTING) { if (this.props.callState === CALL_STATES.ALERTING) {
pendingStateString = mozL10n.get("call_progress_ringing_description"); pendingStateString = mozL10n.get("call_progress_ringing_description");
@ -53,6 +69,12 @@ loop.conversationViews = (function(mozL10n) {
pendingStateString = mozL10n.get("call_progress_connecting_description"); pendingStateString = mozL10n.get("call_progress_connecting_description");
} }
var btnCancelStyles = cx({
"btn": true,
"btn-cancel": true,
"disabled": !this.props.enableCancelButton
});
return ( return (
<ConversationDetailView calleeId={this.props.calleeId}> <ConversationDetailView calleeId={this.props.calleeId}>
@ -60,7 +82,8 @@ loop.conversationViews = (function(mozL10n) {
<div className="btn-group call-action-group"> <div className="btn-group call-action-group">
<div className="fx-embedded-call-button-spacer"></div> <div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-cancel"> <button className={btnCancelStyles}
onClick={this.cancelCall}>
{mozL10n.get("initiate_call_cancel_button")} {mozL10n.get("initiate_call_cancel_button")}
</button> </button>
<div className="fx-embedded-call-button-spacer"></div> <div className="fx-embedded-call-button-spacer"></div>
@ -75,10 +98,115 @@ loop.conversationViews = (function(mozL10n) {
* Call failed view. Displayed when a call fails. * Call failed view. Displayed when a call fails.
*/ */
var CallFailedView = React.createClass({ var CallFailedView = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
retryCall: function() {
this.props.dispatcher.dispatch(new sharedActions.RetryCall());
},
cancelCall: function() {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
},
render: function() { render: function() {
return ( return (
<div className="call-window"> <div className="call-window">
<h2>{mozL10n.get("generic_failure_title")}</h2> <h2>{mozL10n.get("generic_failure_title")}</h2>
<p className="btn-label">{mozL10n.get("generic_failure_no_reason2")}</p>
<div className="btn-group call-action-group">
<div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-accept btn-retry"
onClick={this.retryCall}>
{mozL10n.get("retry_call_button")}
</button>
<div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-cancel"
onClick={this.cancelCall}>
{mozL10n.get("cancel_button")}
</button>
<div className="fx-embedded-call-button-spacer"></div>
</div>
</div>
);
}
});
var OngoingConversationView = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
video: React.PropTypes.object,
audio: React.PropTypes.object
},
getDefaultProps: function() {
return {
video: {enabled: true, visible: true},
audio: {enabled: true, visible: true}
};
},
componentDidMount: function() {
/**
* OT inserts inline styles into the markup. Using a listener for
* resize events helps us trigger a full width/height on the element
* so that they update to the correct dimensions.
* XXX: this should be factored as a mixin.
*/
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
},
componentWillUnmount: function() {
window.removeEventListener('orientationchange', this.updateVideoContainer);
window.removeEventListener('resize', this.updateVideoContainer);
},
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
if (localStreamParent) {
localStreamParent.style.width = "100%";
}
if (remoteStreamParent) {
remoteStreamParent.style.height = "100%";
}
},
hangup: function() {
this.props.dispatcher.dispatch(
new sharedActions.HangupCall());
},
publishStream: function(type, enabled) {
// XXX Add this as part of bug 972017.
},
render: function() {
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": !this.props.video.enabled
});
return (
<div className="video-layout-wrapper">
<div className="conversation">
<div className="media nested">
<div className="video_wrapper remote_wrapper">
<div className="video_inner remote"></div>
</div>
<div className={localStreamClasses}></div>
</div>
<loop.shared.views.ConversationToolbar
video={this.props.video}
audio={this.props.audio}
publishStream={this.publishStream}
hangup={this.hangup} />
</div>
</div> </div>
); );
} }
@ -90,6 +218,7 @@ loop.conversationViews = (function(mozL10n) {
*/ */
var OutgoingConversationView = React.createClass({ var OutgoingConversationView = React.createClass({
propTypes: { propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf( store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired loop.store.ConversationStore).isRequired
}, },
@ -104,22 +233,83 @@ loop.conversationViews = (function(mozL10n) {
}, this); }, this);
}, },
render: function() { _closeWindow: function() {
if (this.state.callState === CALL_STATES.TERMINATED) { window.close();
return (<CallFailedView />); },
}
/**
* Returns true if the call is in a cancellable state, during call setup.
*/
_isCancellable: function() {
return this.state.callState !== CALL_STATES.INIT &&
this.state.callState !== CALL_STATES.GATHER;
},
/**
* Used to setup and render the feedback view.
*/
_renderFeedbackView: function() {
document.title = mozL10n.get("conversation_has_ended");
// XXX Bug 1076754 Feedback view should be redone in the Flux style.
var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
"feedback.baseUrl");
var appVersionInfo = navigator.mozLoop.appVersionInfo;
var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
product: navigator.mozLoop.getLoopCharPref("feedback.product"),
platform: appVersionInfo.OS,
channel: appVersionInfo.channel,
version: appVersionInfo.version
});
return (
<sharedViews.FeedbackView
feedbackApiClient={feedbackClient}
onAfterFeedbackReceived={this._closeWindow.bind(this)}
/>
);
},
render: function() {
switch (this.state.callState) {
case CALL_STATES.CLOSE: {
this._closeWindow();
return null;
}
case CALL_STATES.TERMINATED: {
return (<CallFailedView
dispatcher={this.props.dispatcher}
/>);
}
case CALL_STATES.ONGOING: {
return (<OngoingConversationView
dispatcher={this.props.dispatcher}
video={{enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
/>
);
}
case CALL_STATES.FINISHED: {
return this._renderFeedbackView();
}
default: {
return (<PendingConversationView return (<PendingConversationView
dispatcher={this.props.dispatcher}
callState={this.state.callState} callState={this.state.callState}
calleeId={this.state.calleeId} calleeId={this.state.calleeId}
enableCancelButton={this._isCancellable()}
/>) />)
} }
}
},
}); });
return { return {
PendingConversationView: PendingConversationView, PendingConversationView: PendingConversationView,
ConversationDetailView: ConversationDetailView, ConversationDetailView: ConversationDetailView,
CallFailedView: CallFailedView, CallFailedView: CallFailedView,
OngoingConversationView: OngoingConversationView,
OutgoingConversationView: OutgoingConversationView OutgoingConversationView: OutgoingConversationView
}; };

View File

@ -47,6 +47,12 @@ loop.shared.actions = (function() {
CancelCall: Action.define("cancelCall", { CancelCall: Action.define("cancelCall", {
}), }),
/**
* Used to retry a failed call.
*/
RetryCall: Action.define("retryCall", {
}),
/** /**
* Used to initiate connecting of a call with the relevant * Used to initiate connecting of a call with the relevant
* sessionData. * sessionData.
@ -57,14 +63,20 @@ loop.shared.actions = (function() {
sessionData: Object sessionData: Object
}), }),
/**
* Used for hanging up the call at the end of a successful call.
*/
HangupCall: Action.define("hangupCall", {
}),
/** /**
* Used for notifying of connection progress state changes. * Used for notifying of connection progress state changes.
* The connection refers to the overall connection flow as indicated * The connection refers to the overall connection flow as indicated
* on the websocket. * on the websocket.
*/ */
ConnectionProgress: Action.define("connectionProgress", { ConnectionProgress: Action.define("connectionProgress", {
// The new connection state // The connection state from the websocket.
state: String wsState: String
}), }),
/** /**

View File

@ -10,19 +10,46 @@ loop.store = (function() {
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils; var sharedUtils = loop.shared.utils;
/**
* Websocket states taken from:
* https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
*/
var WS_STATES = {
// The call is starting, and the remote party is not yet being alerted.
INIT: "init",
// The called party is being alerted.
ALERTING: "alerting",
// The call is no longer being set up and has been aborted for some reason.
TERMINATED: "terminated",
// The called party has indicated that he has answered the call,
// but the media is not yet confirmed.
CONNECTING: "connecting",
// One of the two parties has indicated successful media set up,
// but the other has not yet.
HALF_CONNECTED: "half-connected",
// Both endpoints have reported successfully establishing media.
CONNECTED: "connected"
};
var CALL_STATES = { var CALL_STATES = {
// The initial state of the view. // The initial state of the view.
INIT: "init", INIT: "cs-init",
// The store is gathering the call data from the server. // The store is gathering the call data from the server.
GATHER: "gather", GATHER: "cs-gather",
// The websocket has connected to the server and is waiting // The initial data has been gathered, the websocket is connecting, or has
// for the other peer to connect to the websocket. // connected, and waiting for the other side to connect to the server.
CONNECTING: "connecting", CONNECTING: "cs-connecting",
// The websocket has received information that we're now alerting // The websocket has received information that we're now alerting
// the peer. // the peer.
ALERTING: "alerting", ALERTING: "cs-alerting",
// The call is ongoing.
ONGOING: "cs-ongoing",
// The call ended successfully.
FINISHED: "cs-finished",
// The user has finished with the window.
CLOSE: "cs-close",
// The call was terminated due to an issue during connection. // The call was terminated due to an issue during connection.
TERMINATED: "terminated" TERMINATED: "cs-terminated"
}; };
@ -85,7 +112,10 @@ loop.store = (function() {
"connectionFailure", "connectionFailure",
"connectionProgress", "connectionProgress",
"gatherCallData", "gatherCallData",
"connectCall" "connectCall",
"hangupCall",
"cancelCall",
"retryCall"
]); ]);
}, },
@ -109,19 +139,29 @@ loop.store = (function() {
* @param {sharedActions.ConnectionProgress} actionData The action data. * @param {sharedActions.ConnectionProgress} actionData The action data.
*/ */
connectionProgress: function(actionData) { connectionProgress: function(actionData) {
// XXX Turn this into a state machine? var callState = this.get("callState");
if (actionData.state === "alerting" &&
(this.get("callState") === CALL_STATES.CONNECTING || switch(actionData.wsState) {
this.get("callState") === CALL_STATES.GATHER)) { case WS_STATES.INIT: {
this.set({ if (callState === CALL_STATES.GATHER) {
callState: CALL_STATES.ALERTING this.set({callState: CALL_STATES.CONNECTING});
}); }
break;
}
case WS_STATES.ALERTING: {
this.set({callState: CALL_STATES.ALERTING});
break;
}
case WS_STATES.CONNECTING:
case WS_STATES.HALF_CONNECTED:
case WS_STATES.CONNECTED: {
this.set({callState: CALL_STATES.ONGOING});
break;
}
default: {
console.error("Unexpected websocket state passed to connectionProgress:",
actionData.wsState);
} }
if (actionData.state === "connecting" &&
this.get("callState") === CALL_STATES.GATHER) {
this.set({
callState: CALL_STATES.CONNECTING
});
} }
}, },
@ -156,6 +196,63 @@ loop.store = (function() {
this._connectWebSocket(); this._connectWebSocket();
}, },
/**
* Hangs up an ongoing call.
*/
hangupCall: function() {
// XXX Stop the SDK once we add it.
// Ensure the websocket has been disconnected.
if (this._websocket) {
// Let the server know the user has hung up.
this._websocket.mediaFail();
this._ensureWebSocketDisconnected();
}
this.set({callState: CALL_STATES.FINISHED});
},
/**
* Cancels a call
*/
cancelCall: function() {
var callState = this.get("callState");
if (callState === CALL_STATES.TERMINATED) {
// All we need to do is close the window.
this.set({callState: CALL_STATES.CLOSE});
return;
}
if (callState === CALL_STATES.CONNECTING ||
callState === CALL_STATES.ALERTING) {
if (this._websocket) {
// Let the server know the user has hung up.
this._websocket.cancel();
this._ensureWebSocketDisconnected();
}
this.set({callState: CALL_STATES.CLOSE});
return;
}
console.log("Unsupported cancel in state", callState);
},
/**
* Retries a call
*/
retryCall: function() {
var callState = this.get("callState");
if (callState !== CALL_STATES.TERMINATED) {
console.error("Unexpected retry in state", callState);
return;
}
this.set({callState: CALL_STATES.GATHER});
if (this.get("outgoing")) {
this._setupOutgoingCall();
}
},
/** /**
* Obtains the outgoing call data from the server and handles the * Obtains the outgoing call data from the server and handles the
* result. * result.
@ -192,11 +289,11 @@ loop.store = (function() {
}); });
this._websocket.promiseConnect().then( this._websocket.promiseConnect().then(
function() { function(progressState) {
this.dispatcher.dispatch(new sharedActions.ConnectionProgress({ this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
// This is the websocket call state, i.e. waiting for the // This is the websocket call state, i.e. waiting for the
// other end to connect to the server. // other end to connect to the server.
state: "connecting" wsState: progressState
})); }));
}.bind(this), }.bind(this),
function(error) { function(error) {
@ -207,7 +304,18 @@ loop.store = (function() {
}.bind(this) }.bind(this)
); );
this._websocket.on("progress", this._handleWebSocketProgress, this); this.listenTo(this._websocket, "progress", this._handleWebSocketProgress);
},
/**
* Ensures the websocket gets disconnected.
*/
_ensureWebSocketDisconnected: function() {
this.stopListening(this._websocket);
// Now close the websocket.
this._websocket.close();
delete this._websocket;
}, },
/** /**
@ -218,19 +326,18 @@ loop.store = (function() {
var action; var action;
switch(progressData.state) { switch(progressData.state) {
case "terminated": case WS_STATES.TERMINATED: {
action = new sharedActions.ConnectionFailure({ action = new sharedActions.ConnectionFailure({
reason: progressData.reason reason: progressData.reason
}); });
break; break;
case "alerting": }
default: {
action = new sharedActions.ConnectionProgress({ action = new sharedActions.ConnectionProgress({
state: progressData.state wsState: progressData.state
}); });
break; break;
default: }
console.warn("Received unexpected state in _handleWebSocketProgress", progressData.state);
return;
} }
this.dispatcher.dispatch(action); this.dispatcher.dispatch(action);
@ -239,6 +346,7 @@ loop.store = (function() {
return { return {
CALL_STATES: CALL_STATES, CALL_STATES: CALL_STATES,
ConversationStore: ConversationStore ConversationStore: ConversationStore,
WS_STATES: WS_STATES
}; };
})(); })();

View File

@ -174,6 +174,17 @@ loop.CallConnectionWebSocket = (function() {
}); });
}, },
/**
* Notifies the server that something failed during setup.
*/
mediaFail: function() {
this._send({
messageType: "action",
event: "terminate",
reason: "media-fail"
});
},
/** /**
* Sends data on the websocket. * Sends data on the websocket.
* *

View File

@ -4,7 +4,7 @@
var expect = chai.expect; var expect = chai.expect;
describe("loop.conversationViews", function () { describe("loop.conversationViews", function () {
var sandbox, oldTitle, view; var sandbox, oldTitle, view, dispatcher;
var CALL_STATES = loop.store.CALL_STATES; var CALL_STATES = loop.store.CALL_STATES;
@ -15,6 +15,9 @@ describe("loop.conversationViews", function () {
sandbox.stub(document.mozL10n, "get", function(x) { sandbox.stub(document.mozL10n, "get", function(x) {
return x; return x;
}); });
dispatcher = new loop.Dispatcher();
sandbox.stub(dispatcher, "dispatch");
}); });
afterEach(function() { afterEach(function() {
@ -53,7 +56,8 @@ describe("loop.conversationViews", function () {
function() { function() {
view = mountTestComponent({ view = mountTestComponent({
callState: CALL_STATES.CONNECTING, callState: CALL_STATES.CONNECTING,
calleeId: "mrsmith" calleeId: "mrsmith",
dispatcher: dispatcher
}); });
var label = TestUtils.findRenderedDOMComponentWithClass( var label = TestUtils.findRenderedDOMComponentWithClass(
@ -66,7 +70,8 @@ describe("loop.conversationViews", function () {
function() { function() {
view = mountTestComponent({ view = mountTestComponent({
callState: CALL_STATES.ALERTING, callState: CALL_STATES.ALERTING,
calleeId: "mrsmith" calleeId: "mrsmith",
dispatcher: dispatcher
}); });
var label = TestUtils.findRenderedDOMComponentWithClass( var label = TestUtils.findRenderedDOMComponentWithClass(
@ -74,6 +79,86 @@ describe("loop.conversationViews", function () {
expect(label).to.have.string("ringing"); expect(label).to.have.string("ringing");
}); });
it("should disable the cancel button if enableCancelButton is false",
function() {
view = mountTestComponent({
callState: CALL_STATES.CONNECTING,
calleeId: "mrsmith",
dispatcher: dispatcher,
enableCancelButton: false
});
var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
expect(cancelBtn.classList.contains("disabled")).eql(true);
});
it("should enable the cancel button if enableCancelButton is false",
function() {
view = mountTestComponent({
callState: CALL_STATES.CONNECTING,
calleeId: "mrsmith",
dispatcher: dispatcher,
enableCancelButton: true
});
var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
expect(cancelBtn.classList.contains("disabled")).eql(false);
});
it("should dispatch a cancelCall action when the cancel button is pressed",
function() {
view = mountTestComponent({
callState: CALL_STATES.CONNECTING,
calleeId: "mrsmith",
dispatcher: dispatcher
});
var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
React.addons.TestUtils.Simulate.click(cancelBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "cancelCall"));
});
});
describe("CallFailedView", function() {
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
loop.conversationViews.CallFailedView({
dispatcher: dispatcher
}));
}
it("should dispatch a retryCall action when the retry button is pressed",
function() {
view = mountTestComponent();
var retryBtn = view.getDOMNode().querySelector('.btn-retry');
React.addons.TestUtils.Simulate.click(retryBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "retryCall"));
});
it("should dispatch a cancelCall action when the cancel button is pressed",
function() {
view = mountTestComponent();
var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
React.addons.TestUtils.Simulate.click(cancelBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "cancelCall"));
});
}); });
describe("OutgoingConversationView", function() { describe("OutgoingConversationView", function() {
@ -88,9 +173,18 @@ describe("loop.conversationViews", function () {
beforeEach(function() { beforeEach(function() {
store = new loop.store.ConversationStore({}, { store = new loop.store.ConversationStore({}, {
dispatcher: new loop.Dispatcher(), dispatcher: dispatcher,
client: {} client: {}
}); });
navigator.mozLoop = {
getLoopCharPref: function() { return "fake"; },
appVersionInfo: sinon.spy()
};
});
afterEach(function() {
delete navigator.mozLoop;
}); });
it("should render the CallFailedView when the call state is 'terminated'", it("should render the CallFailedView when the call state is 'terminated'",
@ -103,9 +197,9 @@ describe("loop.conversationViews", function () {
loop.conversationViews.CallFailedView); loop.conversationViews.CallFailedView);
}); });
it("should render the PendingConversationView when the call state is connecting", it("should render the PendingConversationView when the call state is 'init'",
function() { function() {
store.set({callState: CALL_STATES.CONNECTING}); store.set({callState: CALL_STATES.INIT});
view = mountTestComponent(); view = mountTestComponent();
@ -113,9 +207,29 @@ describe("loop.conversationViews", function () {
loop.conversationViews.PendingConversationView); loop.conversationViews.PendingConversationView);
}); });
it("should render the OngoingConversationView when the call state is 'ongoing'",
function() {
store.set({callState: CALL_STATES.ONGOING});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.conversationViews.OngoingConversationView);
});
it("should render the FeedbackView when the call state is 'finished'",
function() {
store.set({callState: CALL_STATES.FINISHED});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.shared.views.FeedbackView);
});
it("should update the rendered views when the state is changed.", it("should update the rendered views when the state is changed.",
function() { function() {
store.set({callState: CALL_STATES.CONNECTING}); store.set({callState: CALL_STATES.INIT});
view = mountTestComponent(); view = mountTestComponent();

View File

@ -7,6 +7,7 @@ describe("loop.ConversationStore", function () {
"use strict"; "use strict";
var CALL_STATES = loop.store.CALL_STATES; var CALL_STATES = loop.store.CALL_STATES;
var WS_STATES = loop.store.WS_STATES;
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils; var sharedUtils = loop.shared.utils;
var sandbox, dispatcher, client, store, fakeSessionData; var sandbox, dispatcher, client, store, fakeSessionData;
@ -86,36 +87,69 @@ describe("loop.ConversationStore", function () {
}); });
describe("#connectionProgress", function() { describe("#connectionProgress", function() {
describe("progress: connecting", function() { describe("progress: init", function() {
it("should change the state from 'gather' to 'connecting'", function() { it("should change the state from 'gather' to 'connecting'", function() {
store.set({callState: CALL_STATES.GATHER}); store.set({callState: CALL_STATES.GATHER});
dispatcher.dispatch( dispatcher.dispatch(
new sharedActions.ConnectionProgress({state: "connecting"})); new sharedActions.ConnectionProgress({wsState: WS_STATES.INIT}));
expect(store.get("callState")).eql(CALL_STATES.CONNECTING); expect(store.get("callState")).eql(CALL_STATES.CONNECTING);
}); });
}); });
describe("progress: alerting", function() { describe("progress: alerting", function() {
it("should set the state from 'gather' to 'alerting'", function() { it("should change the state from 'gather' to 'alerting'", function() {
store.set({callState: CALL_STATES.GATHER}); store.set({callState: CALL_STATES.GATHER});
dispatcher.dispatch( dispatcher.dispatch(
new sharedActions.ConnectionProgress({state: "alerting"})); new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
expect(store.get("callState")).eql(CALL_STATES.ALERTING); expect(store.get("callState")).eql(CALL_STATES.ALERTING);
}); });
it("should set the state from 'connecting' to 'alerting'", function() { it("should change the state from 'init' to 'alerting'", function() {
store.set({callState: CALL_STATES.CONNECTING}); store.set({callState: CALL_STATES.INIT});
dispatcher.dispatch( dispatcher.dispatch(
new sharedActions.ConnectionProgress({state: "alerting"})); new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
expect(store.get("callState")).eql(CALL_STATES.ALERTING); expect(store.get("callState")).eql(CALL_STATES.ALERTING);
}); });
}); });
describe("progress: connecting", function() {
it("should change the state to 'ongoing'", function() {
store.set({callState: CALL_STATES.ALERTING});
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
});
});
describe("progress: half-connected", function() {
it("should change the state to 'ongoing'", function() {
store.set({callState: CALL_STATES.ALERTING});
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.HALF_CONNECTED}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
});
});
describe("progress: connecting", function() {
it("should change the state to 'ongoing'", function() {
store.set({callState: CALL_STATES.ALERTING});
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTED}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
});
});
}); });
describe("#gatherCallData", function() { describe("#gatherCallData", function() {
@ -250,7 +284,7 @@ describe("loop.ConversationStore", function () {
}); });
it("should dispatch a connection progress action on success", function(done) { it("should dispatch a connection progress action on success", function(done) {
resolveConnectPromise(); resolveConnectPromise(WS_STATES.INIT);
connectPromise.then(function() { connectPromise.then(function() {
checkFailures(done, function() { checkFailures(done, function() {
@ -259,7 +293,7 @@ describe("loop.ConversationStore", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionProgress")); sinon.match.hasOwn("name", "connectionProgress"));
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("state", "connecting")); sinon.match.hasOwn("wsState", WS_STATES.INIT));
}); });
}, function() { }, function() {
done(new Error("Promise should have been resolve, not rejected")); done(new Error("Promise should have been resolve, not rejected"));
@ -282,7 +316,105 @@ describe("loop.ConversationStore", function () {
}); });
}); });
}); });
});
});
describe("#hangupCall", function() {
var wsMediaFailSpy, wsCloseSpy;
beforeEach(function() {
wsMediaFailSpy = sinon.spy();
wsCloseSpy = sinon.spy();
store._websocket = {
mediaFail: wsMediaFailSpy,
close: wsCloseSpy
};
store.set({callState: CALL_STATES.ONGOING});
});
it("should send a media-fail message to the websocket if it is open", function() {
dispatcher.dispatch(new sharedActions.HangupCall());
sinon.assert.calledOnce(wsMediaFailSpy);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(new sharedActions.HangupCall());
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the callState to finished", function() {
dispatcher.dispatch(new sharedActions.HangupCall());
expect(store.get("callState")).eql(CALL_STATES.FINISHED);
});
});
describe("#cancelCall", function() {
it("should set the state to close if the call has terminated already", function() {
store.set({callState: CALL_STATES.TERMINATED});
dispatcher.dispatch(new sharedActions.CancelCall());
expect(store.get("callState")).eql(CALL_STATES.CLOSE);
});
describe("whilst connecting", function() {
var wsCancelSpy, wsCloseSpy;
beforeEach(function() {
wsCancelSpy = sinon.spy();
wsCloseSpy = sinon.spy();
store._websocket = {
cancel: wsCancelSpy,
close: wsCloseSpy
};
store.set({callState: CALL_STATES.CONNECTING});
});
it("should send a cancel message to the websocket if it is open", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCancelSpy);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the state to close if the call is connecting", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
expect(store.get("callState")).eql(CALL_STATES.CLOSE);
});
});
});
describe("#retryCall", function() {
it("should set the state to gather", function() {
store.set({callState: CALL_STATES.TERMINATED});
dispatcher.dispatch(new sharedActions.RetryCall());
expect(store.get("callState")).eql(CALL_STATES.GATHER);
});
it("should request the outgoing call data", function() {
store.set({
callState: CALL_STATES.TERMINATED,
outgoing: true,
callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
calleeId: "fake"
});
dispatcher.dispatch(new sharedActions.RetryCall());
sinon.assert.calledOnce(client.setupOutgoingCall);
sinon.assert.calledWith(client.setupOutgoingCall,
["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
}); });
}); });
@ -296,7 +428,10 @@ describe("loop.ConversationStore", function () {
}); });
it("should dispatch a connection failure action on 'terminate'", function() { it("should dispatch a connection failure action on 'terminate'", function() {
store._websocket.trigger("progress", {state: "terminated", reason: "reject"}); store._websocket.trigger("progress", {
state: WS_STATES.TERMINATED,
reason: "reject"
});
sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action // Can't use instanceof here, as that matches any action
@ -307,14 +442,14 @@ describe("loop.ConversationStore", function () {
}); });
it("should dispatch a connection progress action on 'alerting'", function() { it("should dispatch a connection progress action on 'alerting'", function() {
store._websocket.trigger("progress", {state: "alerting"}); store._websocket.trigger("progress", {state: WS_STATES.ALERTING});
sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action // Can't use instanceof here, as that matches any action
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionProgress")); sinon.match.hasOwn("name", "connectionProgress"));
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("state", "alerting")); sinon.match.hasOwn("wsState", WS_STATES.ALERTING));
}); });
}); });
}); });

View File

@ -206,6 +206,22 @@ describe("loop.CallConnectionWebSocket", function() {
}); });
}); });
describe("#mediaFail", function() {
it("should send a terminate message to the server with a reason of media-fail",
function() {
callWebSocket.promiseConnect();
callWebSocket.mediaFail();
sinon.assert.calledOnce(dummySocket.send);
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
messageType: "action",
event: "terminate",
reason: "media-fail"
}));
});
});
describe("Events", function() { describe("Events", function() {
beforeEach(function() { beforeEach(function() {
sandbox.stub(callWebSocket, "trigger"); sandbox.stub(callWebSocket, "trigger");

View File

@ -10,12 +10,17 @@
(function() { (function() {
"use strict"; "use strict";
// Stop the default init functions running to avoid conflicts.
document.removeEventListener('DOMContentLoaded', loop.panel.init);
document.removeEventListener('DOMContentLoaded', loop.conversation.init);
// 1. Desktop components // 1. Desktop components
// 1.1 Panel // 1.1 Panel
var PanelView = loop.panel.PanelView; var PanelView = loop.panel.PanelView;
// 1.2. Conversation Window // 1.2. Conversation Window
var IncomingCallView = loop.conversation.IncomingCallView; var IncomingCallView = loop.conversation.IncomingCallView;
var DesktopPendingConversationView = loop.conversationViews.PendingConversationView; var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
var CallFailedView = loop.conversationViews.CallFailedView;
// 2. Standalone webapp // 2. Standalone webapp
var HomeView = loop.webapp.HomeView; var HomeView = loop.webapp.HomeView;
@ -249,6 +254,15 @@
) )
), ),
Section({name: "CallFailedView"},
Example({summary: "Call Failed", dashed: "true",
style: {width: "260px", height: "265px"}},
React.DOM.div({className: "fx-embedded"},
CallFailedView(null)
)
)
),
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

@ -10,12 +10,17 @@
(function() { (function() {
"use strict"; "use strict";
// Stop the default init functions running to avoid conflicts.
document.removeEventListener('DOMContentLoaded', loop.panel.init);
document.removeEventListener('DOMContentLoaded', loop.conversation.init);
// 1. Desktop components // 1. Desktop components
// 1.1 Panel // 1.1 Panel
var PanelView = loop.panel.PanelView; var PanelView = loop.panel.PanelView;
// 1.2. Conversation Window // 1.2. Conversation Window
var IncomingCallView = loop.conversation.IncomingCallView; var IncomingCallView = loop.conversation.IncomingCallView;
var DesktopPendingConversationView = loop.conversationViews.PendingConversationView; var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
var CallFailedView = loop.conversationViews.CallFailedView;
// 2. Standalone webapp // 2. Standalone webapp
var HomeView = loop.webapp.HomeView; var HomeView = loop.webapp.HomeView;
@ -249,6 +254,15 @@
</Example> </Example>
</Section> </Section>
<Section name="CallFailedView">
<Example summary="Call Failed" dashed="true"
style={{width: "260px", height: "265px"}}>
<div className="fx-embedded">
<CallFailedView />
</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">