diff --git a/browser/components/loop/content/js/conversation.js b/browser/components/loop/content/js/conversation.js index 84f7f0a2c507..5471380523bd 100644 --- a/browser/components/loop/content/js/conversation.js +++ b/browser/components/loop/content/js/conversation.js @@ -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({ diff --git a/browser/components/loop/content/js/conversation.jsx b/browser/components/loop/content/js/conversation.jsx index 120411183466..d49fad8995e7 100644 --- a/browser/components/loop/content/js/conversation.jsx +++ b/browser/components/loop/content/js/conversation.jsx @@ -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 (); - } + // CallControllerView is used for both. + case "incoming": case "outgoing": { return (, document.querySelector('#main')); dispatcher.dispatch(new sharedActions.GetWindowData({ diff --git a/browser/components/loop/content/js/conversationViews.js b/browser/components/loop/content/js/conversationViews.js index 9c530c2f3d22..d391cf3522d6 100644 --- a/browser/components/loop/content/js/conversationViews.js +++ b/browser/components/loop/content/js/conversationViews.js @@ -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(); } } }, diff --git a/browser/components/loop/content/js/conversationViews.jsx b/browser/components/loop/content/js/conversationViews.jsx index df90fc2c5e96..2807031ca293 100644 --- a/browser/components/loop/content/js/conversationViews.jsx +++ b/browser/components/loop/content/js/conversationViews.jsx @@ -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 (
-
@@ -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 (); + } + + // For incoming calls that are in accepting state, display the + // accept call view. + if (this.state.callState === CALL_STATES.ALERTING) { + return (); + } + + // 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 (); + return this._renderViewFromCallType(); } } }, diff --git a/browser/components/loop/content/shared/js/conversationStore.js b/browser/components/loop/content/shared/js/conversationStore.js index 0cf590302604..3d364a2faa20 100644 --- a/browser/components/loop/content/shared/js/conversationStore.js +++ b/browser/components/loop/content/shared/js/conversationStore.js @@ -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); } }); })(); diff --git a/browser/components/loop/test/desktop-local/conversationViews_test.js b/browser/components/loop/test/desktop-local/conversationViews_test.js index 76d98f9c730c..1c53dd60e1c1 100644 --- a/browser/components/loop/test/desktop-local/conversationViews_test.js +++ b/browser/components/loop/test/desktop-local/conversationViews_test.js @@ -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(); diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index 4d7de1d2fccd..17d9c4243dcf 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -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() { diff --git a/browser/components/loop/test/shared/conversationStore_test.js b/browser/components/loop/test/shared/conversationStore_test.js index 665e28a92324..2b985c76a4c6 100644 --- a/browser/components/loop/test/shared/conversationStore_test.js +++ b/browser/components/loop/test/shared/conversationStore_test.js @@ -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});