Bug 1088672 - Part 2. Rewrite Loop's incoming call handling in the flux style. Switch incoming calls to use flux based conversation store and get them working as far as the accept view. r=mikedeboer

This commit is contained in:
Mark Banner 2015-03-12 14:01:37 +00:00
Родитель 3379e40e51
Коммит 99d6d0692a
8 изменённых файлов: 297 добавлений и 127 удалений

Просмотреть файл

@ -34,13 +34,6 @@ loop.conversation = (function(mozL10n) {
],
propTypes: {
// XXX Old types required for incoming call view.
client: React.PropTypes.instanceOf(loop.Client).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
sdk: React.PropTypes.object.isRequired,
// XXX New types for flux style
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
},
@ -51,15 +44,8 @@ loop.conversation = (function(mozL10n) {
render: function() {
switch(this.state.windowType) {
case "incoming": {
return (React.createElement(IncomingConversationView, {
client: this.props.client,
conversation: this.props.conversation,
sdk: this.props.sdk,
isDesktop: true,
conversationAppStore: this.getStore()}
));
}
// CallControllerView is used for both.
case "incoming":
case "outgoing": {
return (React.createElement(CallControllerView, {
dispatcher: this.props.dispatcher}
@ -156,13 +142,6 @@ loop.conversation = (function(mozL10n) {
feedbackStore: feedbackStore,
});
// XXX Old class creation for the incoming conversation view, whilst
// we transition across (bug 1072323).
var conversation = new sharedModels.ConversationModel({}, {
sdk: window.OT,
mozLoop: navigator.mozLoop
});
// Obtain the windowId and pass it through
var locationHash = loop.shared.utils.locationData().hash;
var windowId;
@ -172,8 +151,6 @@ loop.conversation = (function(mozL10n) {
windowId = hash[1];
}
conversation.set({windowId: windowId});
window.addEventListener("unload", function(event) {
// Handle direct close of dialog box via [x] control.
// XXX Move to the conversation models, when we transition
@ -185,10 +162,7 @@ loop.conversation = (function(mozL10n) {
React.render(React.createElement(AppControllerView, {
roomStore: roomStore,
client: client,
conversation: conversation,
dispatcher: dispatcher,
sdk: window.OT}
dispatcher: dispatcher}
), document.querySelector('#main'));
dispatcher.dispatch(new sharedActions.GetWindowData({

Просмотреть файл

@ -34,13 +34,6 @@ loop.conversation = (function(mozL10n) {
],
propTypes: {
// XXX Old types required for incoming call view.
client: React.PropTypes.instanceOf(loop.Client).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
sdk: React.PropTypes.object.isRequired,
// XXX New types for flux style
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
},
@ -51,15 +44,8 @@ loop.conversation = (function(mozL10n) {
render: function() {
switch(this.state.windowType) {
case "incoming": {
return (<IncomingConversationView
client={this.props.client}
conversation={this.props.conversation}
sdk={this.props.sdk}
isDesktop={true}
conversationAppStore={this.getStore()}
/>);
}
// CallControllerView is used for both.
case "incoming":
case "outgoing": {
return (<CallControllerView
dispatcher={this.props.dispatcher}
@ -156,13 +142,6 @@ loop.conversation = (function(mozL10n) {
feedbackStore: feedbackStore,
});
// XXX Old class creation for the incoming conversation view, whilst
// we transition across (bug 1072323).
var conversation = new sharedModels.ConversationModel({}, {
sdk: window.OT,
mozLoop: navigator.mozLoop
});
// Obtain the windowId and pass it through
var locationHash = loop.shared.utils.locationData().hash;
var windowId;
@ -172,8 +151,6 @@ loop.conversation = (function(mozL10n) {
windowId = hash[1];
}
conversation.set({windowId: windowId});
window.addEventListener("unload", function(event) {
// Handle direct close of dialog box via [x] control.
// XXX Move to the conversation models, when we transition
@ -185,10 +162,7 @@ loop.conversation = (function(mozL10n) {
React.render(<AppControllerView
roomStore={roomStore}
client={client}
conversation={conversation}
dispatcher={dispatcher}
sdk={window.OT}
/>, document.querySelector('#main'));
dispatcher.dispatch(new sharedActions.GetWindowData({

Просмотреть файл

@ -144,14 +144,16 @@ loop.conversationViews = (function(mozL10n) {
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
propTypes: {
model: React.PropTypes.object.isRequired,
video: React.PropTypes.bool.isRequired
callType: React.PropTypes.string.isRequired,
callerId: React.PropTypes.string.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
// Only for use by the ui-showcase
showMenu: React.PropTypes.bool
},
getDefaultProps: function() {
return {
showMenu: false,
video: true
};
},
@ -221,9 +223,8 @@ loop.conversationViews = (function(mozL10n) {
return (
React.createElement("div", {className: "call-window"},
React.createElement(CallIdentifierView, {video: this.props.video,
peerIdentifier: this.props.model.getCallIdentifier(),
urlCreationDate: this.props.model.get("urlCreationDate"),
React.createElement(CallIdentifierView, {video: this.props.callType === CALL_TYPES.AUDIO_VIDEO,
peerIdentifier: this.props.callerId,
showIcons: true}),
React.createElement("div", {className: "btn-group call-action-group"},
@ -964,6 +965,33 @@ loop.conversationViews = (function(mozL10n) {
);
},
_renderViewFromCallType: function() {
// For outgoing calls we can display the pending conversation view
// for any state that render() doesn't manage.
if (this.state.outgoing) {
return (React.createElement(PendingConversationView, {
dispatcher: this.props.dispatcher,
callState: this.state.callState,
contact: this.state.contact,
enableCancelButton: this._isCancellable()}
));
}
// For incoming calls that are in accepting state, display the
// accept call view.
if (this.state.callState === CALL_STATES.ALERTING) {
return (React.createElement(AcceptCallView, {
callType: this.state.callType,
callerId: this.state.callerId,
dispatcher: this.props.dispatcher}
));
}
// Otherwise we're still gathering or connecting, so
// don't display anything.
return null;
},
render: function() {
switch (this.state.callState) {
case CALL_STATES.CLOSE: {
@ -993,12 +1021,7 @@ loop.conversationViews = (function(mozL10n) {
return null;
}
default: {
return (React.createElement(PendingConversationView, {
dispatcher: this.props.dispatcher,
callState: this.state.callState,
contact: this.state.contact,
enableCancelButton: this._isCancellable()}
));
return this._renderViewFromCallType();
}
}
},

Просмотреть файл

@ -144,14 +144,16 @@ loop.conversationViews = (function(mozL10n) {
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
propTypes: {
model: React.PropTypes.object.isRequired,
video: React.PropTypes.bool.isRequired
callType: React.PropTypes.string.isRequired,
callerId: React.PropTypes.string.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
// Only for use by the ui-showcase
showMenu: React.PropTypes.bool
},
getDefaultProps: function() {
return {
showMenu: false,
video: true
};
},
@ -221,9 +223,8 @@ loop.conversationViews = (function(mozL10n) {
return (
<div className="call-window">
<CallIdentifierView video={this.props.video}
peerIdentifier={this.props.model.getCallIdentifier()}
urlCreationDate={this.props.model.get("urlCreationDate")}
<CallIdentifierView video={this.props.callType === CALL_TYPES.AUDIO_VIDEO}
peerIdentifier={this.props.callerId}
showIcons={true} />
<div className="btn-group call-action-group">
@ -964,6 +965,33 @@ loop.conversationViews = (function(mozL10n) {
);
},
_renderViewFromCallType: function() {
// For outgoing calls we can display the pending conversation view
// for any state that render() doesn't manage.
if (this.state.outgoing) {
return (<PendingConversationView
dispatcher={this.props.dispatcher}
callState={this.state.callState}
contact={this.state.contact}
enableCancelButton={this._isCancellable()}
/>);
}
// For incoming calls that are in accepting state, display the
// accept call view.
if (this.state.callState === CALL_STATES.ALERTING) {
return (<AcceptCallView
callType={this.state.callType}
callerId={this.state.callerId}
dispatcher={this.props.dispatcher}
/>);
}
// Otherwise we're still gathering or connecting, so
// don't display anything.
return null;
},
render: function() {
switch (this.state.callState) {
case CALL_STATES.CLOSE: {
@ -993,12 +1021,7 @@ loop.conversationViews = (function(mozL10n) {
return null;
}
default: {
return (<PendingConversationView
dispatcher={this.props.dispatcher}
callState={this.state.callState}
contact={this.state.contact}
enableCancelButton={this._isCancellable()}
/>);
return this._renderViewFromCallType();
}
}
},

Просмотреть файл

@ -233,17 +233,26 @@ loop.store = loop.store || {};
]);
this.setStoreState({
apiKey: actionData.apiKey,
callerId: actionData.callerId,
callId: actionData.callId,
callState: CALL_STATES.GATHER,
callType: actionData.callType,
contact: actionData.contact,
outgoing: windowType === "outgoing",
windowId: actionData.windowId,
callType: actionData.callType,
callState: CALL_STATES.GATHER,
videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY
progressURL: actionData.progressURL,
sessionId: actionData.sessionId,
sessionToken: actionData.sessionToken,
videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY,
websocketToken: actionData.websocketToken,
windowId: actionData.windowId
});
if (this.getStoreState("outgoing")) {
this._setupOutgoingCall();
} // XXX Else, other types aren't supported yet.
} else {
this._setupIncomingCall();
}
},
/**
@ -292,15 +301,19 @@ loop.store = loop.store || {};
},
/**
* Cancels a call
* Cancels a call. This can happen for incoming or outgoing calls.
* Although the user doesn't "cancel" an incoming call, it may be that
* the remote peer cancelled theirs before the incoming call was accepted.
*/
cancelCall: function() {
var callState = this.getStoreState("callState");
if (this._websocket &&
(callState === CALL_STATES.CONNECTING ||
callState === CALL_STATES.ALERTING)) {
// Let the server know the user has hung up.
this._websocket.cancel();
if (this.getStoreState("outgoing")) {
var callState = this.getStoreState("callState");
if (this._websocket &&
(callState === CALL_STATES.CONNECTING ||
callState === CALL_STATES.ALERTING)) {
// Let the server know the user has hung up.
this._websocket.cancel();
}
}
this._endSession();
@ -369,6 +382,15 @@ loop.store = loop.store || {};
this._endSession();
},
/**
* Sets up an incoming call. All we really need to do here is
* to connect the websocket, as we've already got all the information
* when the window opened.
*/
_setupIncomingCall: function() {
this._connectWebSocket();
},
/**
* Obtains the outgoing call data from the server and handles the
* result.
@ -466,29 +488,54 @@ loop.store = loop.store || {};
this.getStoreState("windowId"));
},
/**
* If we hit any of the termination reasons, and the user hasn't accepted
* then it seems reasonable to close the window/abort the incoming call.
*
* If the user has accepted the call, and something's happened, display
* the call failed view.
*
* https://wiki.mozilla.org/Loop/Architecture/MVP#Termination_Reasons
*
* For outgoing calls, we treat all terminations as failures.
*
* @param {Object} progressData The progress data received from the websocket.
* @param {String} previousState The previous state the websocket was in.
*/
_handleWebSocketStateTerminated: function(progressData, previousState) {
if (this.getStoreState("outgoing") ||
(previousState !== WS_STATES.INIT &&
previousState !== WS_STATES.ALERTING)) {
// For outgoing calls we can treat everything as connection failure.
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: progressData.reason
}));
return;
}
this.dispatcher.dispatch(new sharedActions.CancelCall());
},
/**
* Used to handle any progressed received from the websocket. This will
* dispatch new actions so that the data can be handled appropriately.
*
* @param {Object} progressData The progress data received from the websocket.
* @param {String} previousState The previous state the websocket was in.
*/
_handleWebSocketProgress: function(progressData) {
var action;
_handleWebSocketProgress: function(progressData, previousState) {
switch(progressData.state) {
case WS_STATES.TERMINATED: {
action = new sharedActions.ConnectionFailure({
reason: progressData.reason
});
this._handleWebSocketStateTerminated(progressData, previousState);
break;
}
default: {
action = new sharedActions.ConnectionProgress({
this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
wsState: progressData.state
});
}));
break;
}
}
this.dispatcher.dispatch(action);
}
});
})();

Просмотреть файл

@ -616,11 +616,12 @@ describe("loop.conversationViews", function () {
loop.conversationViews.CallFailedView);
});
it("should render the PendingConversationView when the call state is 'gather'",
it("should render the PendingConversationView for outgoing calls when the call state is 'gather'",
function() {
store.setStoreState({
callState: CALL_STATES.GATHER,
contact: contact
contact: contact,
outgoing: true
});
view = mountTestComponent();
@ -629,6 +630,18 @@ describe("loop.conversationViews", function () {
loop.conversationViews.PendingConversationView);
});
it("should render the AcceptCallView for incoming calls when the call state is 'alerting'", function() {
store.setStoreState({
callState: CALL_STATES.ALERTING,
outgoing: false
});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.conversationViews.AcceptCallView);
});
it("should render the OngoingConversationView when the call state is 'ongoing'",
function() {
store.setStoreState({callState: CALL_STATES.ONGOING});
@ -669,7 +682,8 @@ describe("loop.conversationViews", function () {
function() {
store.setStoreState({
callState: CALL_STATES.GATHER,
contact: contact
contact: contact,
outgoing: true
});
view = mountTestComponent();

Просмотреть файл

@ -129,27 +129,20 @@ describe("loop.conversation", function() {
});
describe("AppControllerView", function() {
var conversationStore, conversation, client, ccView, oldTitle, dispatcher;
var conversationStore, client, ccView, oldTitle, dispatcher;
var conversationAppStore, roomStore;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
React.createElement(loop.conversation.AppControllerView, {
client: client,
conversation: conversation,
roomStore: roomStore,
sdk: {},
dispatcher: dispatcher,
mozLoop: navigator.mozLoop
dispatcher: dispatcher
}));
}
beforeEach(function() {
oldTitle = document.title;
client = new loop.Client();
conversation = new loop.shared.models.ConversationModel({}, {
sdk: {}
});
dispatcher = new loop.Dispatcher();
conversationStore = new loop.store.ConversationStore(
dispatcher, {
@ -195,20 +188,13 @@ describe("loop.conversation", function() {
loop.conversationViews.CallControllerView);
});
it("should display the IncomingConversationView for incoming calls", function() {
sandbox.stub(conversation, "setIncomingSessionData");
sandbox.stub(loop, "CallConnectionWebSocket").returns({
promiseConnect: function() {
return new Promise(function() {});
},
on: sandbox.spy()
});
it("should display the CallControllerView for incoming calls", function() {
conversationAppStore.setStoreState({windowType: "incoming"});
ccView = mountTestComponent();
TestUtils.findRenderedComponentWithType(ccView,
loop.conversationViews.IncomingConversationView);
loop.conversationViews.CallControllerView);
});
it("should display the RoomView for rooms", function() {

Просмотреть файл

@ -299,6 +299,79 @@ describe("loop.store.ConversationStore", function () {
.eql(sharedUtils.CALL_TYPES.AUDIO_VIDEO);
});
describe("incoming calls", function() {
beforeEach(function() {
store.setStoreState({outgoing: false});
});
it("should initialize the websocket", function() {
sandbox.stub(loop, "CallConnectionWebSocket").returns({
promiseConnect: function() { return connectPromise; },
on: sinon.spy()
});
store.connectCall(
new sharedActions.ConnectCall({sessionData: fakeSessionData}));
sinon.assert.calledOnce(loop.CallConnectionWebSocket);
sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
url: "fakeURL",
callId: "142536",
websocketToken: "543216"
});
});
it("should connect the websocket to the server", function() {
store.connectCall(
new sharedActions.ConnectCall({sessionData: fakeSessionData}));
sinon.assert.calledOnce(store._websocket.promiseConnect);
});
describe("WebSocket connection result", function() {
beforeEach(function() {
store.connectCall(
new sharedActions.ConnectCall({sessionData: fakeSessionData}));
sandbox.stub(dispatcher, "dispatch");
});
it("should dispatch a connection progress action on success", function(done) {
resolveConnectPromise(WS_STATES.INIT);
connectPromise.then(function() {
checkFailures(done, function() {
sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionProgress"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("wsState", WS_STATES.INIT));
});
}, function() {
done(new Error("Promise should have been resolve, not rejected"));
});
});
it("should dispatch a connection failure action on failure", function(done) {
rejectConnectPromise();
connectPromise.then(function() {
done(new Error("Promise should have been rejected, not resolved"));
}, function() {
checkFailures(done, function() {
sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "websocket-setup"));
});
});
});
});
});
describe("outgoing calls", function() {
it("should request the outgoing call data", function() {
dispatcher.dispatch(
@ -632,7 +705,9 @@ describe("loop.store.ConversationStore", function () {
sinon.assert.calledOnce(sdkDriver.disconnectSession);
});
it("should send a cancel message to the websocket if it is open", function() {
it("should send a cancel message to the websocket if it is open for outgoing calls", function() {
store.setStoreState({outgoing: true});
store.cancelCall(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCancelSpy);
@ -787,10 +862,14 @@ describe("loop.store.ConversationStore", function () {
sandbox.stub(dispatcher, "dispatch");
});
it("should dispatch a connection failure action on 'terminate'", function() {
it("should dispatch a connection failure action on 'terminate' for outgoing calls", function() {
store.setStoreState({
outgoing: true
});
store._websocket.trigger("progress", {
state: WS_STATES.TERMINATED,
reason: WEBSOCKET_REASONS.REJECT
reason: WEBSOCKET_REASONS.REJECT,
});
sinon.assert.calledOnce(dispatcher.dispatch);
@ -801,6 +880,56 @@ describe("loop.store.ConversationStore", function () {
sinon.match.hasOwn("reason", WEBSOCKET_REASONS.REJECT));
});
it("should dispatch a connection failure action on 'terminate' for incoming calls if the previous state was not 'alerting' or 'init'", function() {
store.setStoreState({
outgoing: false
});
store._websocket.trigger("progress", {
state: WS_STATES.TERMINATED,
reason: WEBSOCKET_REASONS.CANCEL
}, WS_STATES.CONNECTING);
sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.ConnectionFailure({
reason: WEBSOCKET_REASONS.CANCEL
}));
});
it("should dispatch a cancel call action on 'terminate' for incoming calls if the previous state was 'init'", function() {
store.setStoreState({
outgoing: false
});
store._websocket.trigger("progress", {
state: WS_STATES.TERMINATED,
reason: WEBSOCKET_REASONS.CANCEL
}, WS_STATES.INIT);
sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.CancelCall({}));
});
it("should dispatch a cancel call action on 'terminate' for incoming calls if the previous state was 'alerting'", function() {
store.setStoreState({
outgoing: false
});
store._websocket.trigger("progress", {
state: WS_STATES.TERMINATED,
reason: WEBSOCKET_REASONS.CANCEL
}, WS_STATES.ALERTING);
sinon.assert.calledOnce(dispatcher.dispatch);
// Can't use instanceof here, as that matches any action
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.CancelCall({}));
});
it("should dispatch a connection progress action on 'alerting'", function() {
store._websocket.trigger("progress", {state: WS_STATES.ALERTING});