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
authorMark Banner <standard8@mozilla.com>
Thu, 12 Mar 2015 14:01:37 +0000
changeset 233383 8119c5b062538430dafae8a289b418294225668d
parent 233382 624232e720c8a02ca1fce1e65d9a1dfeaa3de259
child 233384 dc2dded44d2202b864fdab2babc05607c81fd209
push id56829
push userryanvm@gmail.com
push dateThu, 12 Mar 2015 22:35:09 +0000
treeherdermozilla-inbound@42afc7ef5ccb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1088672
milestone39.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/conversationStore_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -29,42 +29,28 @@ loop.conversation = (function(mozL10n) {
   var AppControllerView = React.createClass({displayName: "AppControllerView",
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationAppStore"),
       sharedMixins.WindowCloseMixin
     ],
 
     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)
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     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}
           ));
         }
         case "room": {
           return (React.createElement(DesktopRoomConversationView, {
             dispatcher: this.props.dispatcher, 
@@ -151,49 +137,37 @@ loop.conversation = (function(mozL10n) {
     });
 
     loop.store.StoreMixin.register({
       conversationAppStore: conversationAppStore,
       conversationStore: conversationStore,
       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;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       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
       // incoming calls to flux (bug 1088672).
       navigator.mozLoop.calls.clearCallInProgress(windowId);
 
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     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({
       windowId: windowId
     }));
   }
 
   return {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -29,42 +29,28 @@ loop.conversation = (function(mozL10n) {
   var AppControllerView = React.createClass({
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationAppStore"),
       sharedMixins.WindowCloseMixin
     ],
 
     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)
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     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}
           />);
         }
         case "room": {
           return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
@@ -151,49 +137,37 @@ loop.conversation = (function(mozL10n) {
     });
 
     loop.store.StoreMixin.register({
       conversationAppStore: conversationAppStore,
       conversationStore: conversationStore,
       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;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       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
       // incoming calls to flux (bug 1088672).
       navigator.mozLoop.calls.clearCallInProgress(windowId);
 
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.render(<AppControllerView
       roomStore={roomStore}
-      client={client}
-      conversation={conversation}
       dispatcher={dispatcher}
-      sdk={window.OT}
     />, document.querySelector('#main'));
 
     dispatcher.dispatch(new sharedActions.GetWindowData({
       windowId: windowId
     }));
   }
 
   return {
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -139,24 +139,26 @@ loop.conversationViews = (function(mozL1
 
   // Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
   var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
 
   var AcceptCallView = React.createClass({displayName: "AcceptCallView",
     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
       };
     },
 
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
@@ -216,19 +218,18 @@ loop.conversationViews = (function(mozL1
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showMenu
       });
 
       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"}, 
 
             React.createElement("div", {className: "fx-embedded-call-button-spacer"}), 
 
             React.createElement("div", {className: "btn-chevron-menu-group"}, 
               React.createElement("div", {className: "btn-group-chevron"}, 
@@ -959,16 +960,43 @@ loop.conversationViews = (function(mozL1
 
       return (
         React.createElement(sharedViews.FeedbackView, {
           onAfterFeedbackReceived: this._closeWindow.bind(this)}
         )
       );
     },
 
+    _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: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
           return (React.createElement(CallFailedView, {
@@ -988,22 +1016,17 @@ loop.conversationViews = (function(mozL1
           this.play("terminated");
           return this._renderFeedbackView();
         }
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           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();
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -139,24 +139,26 @@ loop.conversationViews = (function(mozL1
 
   // Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
   var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
 
   var AcceptCallView = React.createClass({
     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
       };
     },
 
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
@@ -216,19 +218,18 @@ loop.conversationViews = (function(mozL1
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showMenu
       });
 
       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">
 
             <div className="fx-embedded-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
@@ -959,16 +960,43 @@ loop.conversationViews = (function(mozL1
 
       return (
         <sharedViews.FeedbackView
           onAfterFeedbackReceived={this._closeWindow.bind(this)}
         />
       );
     },
 
+    _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: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
           return (<CallFailedView
@@ -988,22 +1016,17 @@ loop.conversationViews = (function(mozL1
           this.play("terminated");
           return this._renderFeedbackView();
         }
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           return null;
         }
         default: {
-          return (<PendingConversationView
-            dispatcher={this.props.dispatcher}
-            callState={this.state.callState}
-            contact={this.state.contact}
-            enableCancelButton={this._isCancellable()}
-          />);
+          return this._renderViewFromCallType();
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -228,27 +228,36 @@ loop.store = loop.store || {};
         "retryCall",
         "mediaConnected",
         "setMute",
         "fetchRoomEmailLink",
         "windowUnload"
       ]);
 
       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();
+      }
     },
 
     /**
      * Handles the connect call action, this saves the appropriate
      * data and starts the connection for the websocket to notify the
      * server of progress.
      *
      * @param {sharedActions.ConnectCall} actionData The action data.
@@ -287,25 +296,29 @@ loop.store = loop.store || {};
         this.setStoreState({
           callState: CALL_STATES.TERMINATED,
           callStateReason: "peerNetworkDisconnected"
         });
       }
     },
 
     /**
-     * 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();
       this.setStoreState({callState: CALL_STATES.CLOSE});
     },
 
     /**
      * Retries a call
@@ -365,16 +378,25 @@ loop.store = loop.store || {};
      * explicitly closing it.  Expected to do any necessary housekeeping, such
      * as shutting down the call cleanly and adding any relevant telemetry data.
      */
     windowUnload: function() {
       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.
      */
     _setupOutgoingCall: function() {
       var contactAddresses = [];
       var contact = this.getStoreState("contact");
 
       this.mozLoop.calls.setCallInProgress(this.getStoreState("windowId"));
@@ -462,33 +484,58 @@ loop.store = loop.store || {};
         delete this._websocket;
       }
 
       this.mozLoop.calls.clearCallInProgress(
         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);
     }
   });
 })();
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -611,29 +611,42 @@ describe("loop.conversationViews", funct
         });
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           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();
 
         TestUtils.findRenderedComponentWithType(view,
           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});
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.OngoingConversationView);
@@ -664,17 +677,18 @@ describe("loop.conversationViews", funct
 
         sinon.assert.calledOnce(fakeAudio.play);
     });
 
     it("should update the rendered views when the state is changed.",
       function() {
         store.setStoreState({
           callState: CALL_STATES.GATHER,
-          contact: contact
+          contact: contact,
+          outgoing: true
         });
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
 
         store.setStoreState({callState: CALL_STATES.TERMINATED});
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -124,37 +124,30 @@ describe("loop.conversation", function()
       sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
         new loop.shared.actions.GetWindowData({
           windowId: "42"
         }));
     });
   });
 
   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, {
           client: client,
           mozLoop: navigator.mozLoop,
           sdkDriver: {}
         });
 
@@ -190,30 +183,23 @@ describe("loop.conversation", function()
       conversationAppStore.setStoreState({windowType: "outgoing"});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
         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() {
       conversationAppStore.setStoreState({windowType: "room"});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -294,16 +294,89 @@ describe("loop.store.ConversationStore",
       dispatcher.dispatch(
         new sharedActions.SetupWindowData(fakeSetupWindowData));
 
       expect(store.getStoreState("contact")).eql(contact);
       expect(store.getStoreState("callType"))
         .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(
           new sharedActions.SetupWindowData(fakeSetupWindowData));
 
         sinon.assert.calledOnce(client.setupOutgoingCall);
         sinon.assert.calledWith(client.setupOutgoingCall,
           ["fakeEmail"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
@@ -627,17 +700,19 @@ describe("loop.store.ConversationStore",
     });
 
     it("should disconnect the session", function() {
       store.cancelCall(new sharedActions.CancelCall());
 
       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);
     });
 
     it("should ensure the websocket is closed", function() {
       store.cancelCall(new sharedActions.CancelCall());
 
@@ -782,30 +857,84 @@ describe("loop.store.ConversationStore",
     describe("Websocket progress", function() {
       beforeEach(function() {
         store.connectCall(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         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);
         // 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_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});
 
         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,