Bug 1088672 - Part 4. Rewrite Loop's incoming call handling in the flux style. Put back alerts and make window unload be handled correctly. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Thu, 12 Mar 2015 14:01:38 +0000
changeset 233230 132acc4464e6ca66a36ffc3c8dadd28dad94d987
parent 233229 dc2dded44d2202b864fdab2babc05607c81fd209
child 233231 a009386fbb96577f6db09c5aac248908d7dc2c4b
push id11728
push usermbanner@mozilla.com
push dateThu, 12 Mar 2015 14:02:24 +0000
treeherderfx-team@c154c877d4e4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1088672
milestone39.0a1
Bug 1088672 - Part 4. Rewrite Loop's incoming call handling in the flux style. Put back alerts and make window unload be handled correctly. 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/shared/conversationStore_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -30,30 +30,32 @@ loop.conversation = (function(mozL10n) {
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationAppStore"),
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
+      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
+      mozLoop: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     render: function() {
       switch(this.state.windowType) {
         // CallControllerView is used for both.
         case "incoming":
         case "outgoing": {
           return (React.createElement(CallControllerView, {
-            dispatcher: this.props.dispatcher}
+            dispatcher: this.props.dispatcher, 
+            mozLoop: this.props.mozLoop}
           ));
         }
         case "room": {
           return (React.createElement(DesktopRoomConversationView, {
             dispatcher: this.props.dispatcher, 
             roomStore: this.props.roomStore}
           ));
         }
@@ -147,27 +149,23 @@ loop.conversation = (function(mozL10n) {
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       windowId = hash[1];
     }
 
     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, 
-      dispatcher: dispatcher}
+      dispatcher: dispatcher, 
+      mozLoop: navigator.mozLoop}
     ), 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
@@ -30,30 +30,32 @@ loop.conversation = (function(mozL10n) {
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationAppStore"),
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
+      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
+      mozLoop: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     render: function() {
       switch(this.state.windowType) {
         // CallControllerView is used for both.
         case "incoming":
         case "outgoing": {
           return (<CallControllerView
             dispatcher={this.props.dispatcher}
+            mozLoop={this.props.mozLoop}
           />);
         }
         case "room": {
           return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
             roomStore={this.props.roomStore}
           />);
         }
@@ -147,27 +149,23 @@ loop.conversation = (function(mozL10n) {
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       windowId = hash[1];
     }
 
     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}
       dispatcher={dispatcher}
+      mozLoop={navigator.mozLoop}
     />, 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
@@ -142,26 +142,35 @@ loop.conversationViews = (function(mozL1
 
   var AcceptCallView = React.createClass({displayName: "AcceptCallView",
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       callType: React.PropTypes.string.isRequired,
       callerId: React.PropTypes.string.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      mozLoop: React.PropTypes.object.isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
         showMenu: false,
       };
     },
 
+    componentDidMount: function() {
+      this.props.mozLoop.startAlerting();
+    },
+
+    componentWillUnmount: function() {
+      this.props.mozLoop.stopAlerting();
+    },
+
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -934,17 +943,18 @@ loop.conversationViews = (function(mozL1
   var CallControllerView = React.createClass({displayName: "CallControllerView",
     mixins: [
       sharedMixins.AudioMixin,
       loop.store.StoreMixin("conversationStore"),
       Backbone.Events
     ],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      mozLoop: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     _closeWindow: function() {
       window.close();
@@ -984,17 +994,18 @@ loop.conversationViews = (function(mozL1
       }
 
       // 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}
+          dispatcher: this.props.dispatcher, 
+          mozLoop: this.props.mozLoop}
         ));
       }
 
       // Otherwise we're still gathering or connecting, so
       // don't display anything.
       return null;
     },
 
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -142,26 +142,35 @@ loop.conversationViews = (function(mozL1
 
   var AcceptCallView = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       callType: React.PropTypes.string.isRequired,
       callerId: React.PropTypes.string.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      mozLoop: React.PropTypes.object.isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
         showMenu: false,
       };
     },
 
+    componentDidMount: function() {
+      this.props.mozLoop.startAlerting();
+    },
+
+    componentWillUnmount: function() {
+      this.props.mozLoop.stopAlerting();
+    },
+
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -934,17 +943,18 @@ loop.conversationViews = (function(mozL1
   var CallControllerView = React.createClass({
     mixins: [
       sharedMixins.AudioMixin,
       loop.store.StoreMixin("conversationStore"),
       Backbone.Events
     ],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      mozLoop: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
 
     _closeWindow: function() {
       window.close();
@@ -985,16 +995,17 @@ loop.conversationViews = (function(mozL1
 
       // 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}
+          mozLoop={this.props.mozLoop}
         />);
       }
 
       // Otherwise we're still gathering or connecting, so
       // don't display anything.
       return null;
     },
 
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -420,16 +420,22 @@ loop.store = loop.store || {};
     },
 
     /**
      * Called when the window is unloaded, either by code, or by the user
      * explicitly closing it.  Expected to do any necessary housekeeping, such
      * as shutting down the call cleanly and adding any relevant telemetry data.
      */
     windowUnload: function() {
+      if (!this.getStoreState("outgoing") &&
+          this.getStoreState("callState") === CALL_STATES.ALERTING &&
+          this._websocket) {
+        this._websocket.decline();
+      }
+
       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.
      */
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -581,16 +581,17 @@ describe("loop.conversationViews", funct
 
   describe("CallControllerView", function() {
     var store, feedbackStore;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         React.createElement(loop.conversationViews.CallControllerView, {
           dispatcher: dispatcher,
+          mozLoop: fakeMozLoop
         }));
     }
 
     beforeEach(function() {
       store = new loop.store.ConversationStore(dispatcher, {
         client: {},
         mozLoop: fakeMozLoop,
         sdkDriver: {}
@@ -1219,57 +1220,75 @@ describe("loop.conversationViews", funct
         });
       });
     });
   });
 
   describe("AcceptCallView", function() {
     var view;
 
-    function mountTestComponent(props) {
+    function mountTestComponent(extraProps) {
+      var props = _.extend({dispatcher: dispatcher, mozLoop: fakeMozLoop}, extraProps);
       return TestUtils.renderIntoDocument(
         React.createElement(loop.conversationViews.AcceptCallView, props));
     }
 
     afterEach(function() {
       view = null;
     });
 
+    it("should start alerting on display", function() {
+      view = mountTestComponent({
+        callType: CALL_TYPES.AUDIO_VIDEO,
+        callerId: "fake@invalid.com"
+      });
+
+      sinon.assert.calledOnce(fakeMozLoop.startAlerting);
+    });
+
+    it("should stop alerting when removed from the display", function() {
+      view = mountTestComponent({
+        callType: CALL_TYPES.AUDIO_VIDEO,
+        callerId: "fake@invalid.com"
+      });
+
+      view.componentWillUnmount();
+
+      sinon.assert.calledOnce(fakeMozLoop.stopAlerting);
+    });
+
     describe("default answer mode", function() {
       it("should display video as primary answer mode", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_VIDEO,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-video');
 
         expect(primaryBtn).not.to.eql(null);
       });
 
       it("should display audio as primary answer mode", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_ONLY,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-audio');
 
         expect(primaryBtn).not.to.eql(null);
       });
 
       it("should accept call with video", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_VIDEO,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-video');
 
         React.addons.TestUtils.Simulate.click(primaryBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
@@ -1277,18 +1296,17 @@ describe("loop.conversationViews", funct
           new sharedActions.AcceptCall({
             callType: CALL_TYPES.AUDIO_VIDEO
           }));
       });
 
       it("should accept call with audio", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_ONLY,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-audio');
 
         React.addons.TestUtils.Simulate.click(primaryBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
@@ -1297,18 +1315,17 @@ describe("loop.conversationViews", funct
             callType: CALL_TYPES.AUDIO_ONLY
           }));
       });
 
       it("should accept call with video when clicking on secondary btn",
         function() {
           view = mountTestComponent({
             callType: CALL_TYPES.AUDIO_ONLY,
-            callerId: "fake@invalid.com",
-            dispatcher: dispatcher
+            callerId: "fake@invalid.com"
           });
 
           var secondaryBtn = view.getDOMNode()
           .querySelector('.fx-embedded-btn-video-small');
 
           React.addons.TestUtils.Simulate.click(secondaryBtn);
 
           sinon.assert.calledOnce(dispatcher.dispatch);
@@ -1317,18 +1334,17 @@ describe("loop.conversationViews", funct
               callType: CALL_TYPES.AUDIO_VIDEO
             }));
         });
 
       it("should accept call with audio when clicking on secondary btn",
         function() {
           view = mountTestComponent({
             callType: CALL_TYPES.AUDIO_VIDEO,
-            callerId: "fake@invalid.com",
-            dispatcher: dispatcher
+            callerId: "fake@invalid.com"
           });
 
           var secondaryBtn = view.getDOMNode()
           .querySelector('.fx-embedded-btn-audio-small');
 
           React.addons.TestUtils.Simulate.click(secondaryBtn);
 
           sinon.assert.calledOnce(dispatcher.dispatch);
@@ -1338,36 +1354,34 @@ describe("loop.conversationViews", funct
             }));
         });
     });
 
     describe("click event on .btn-decline", function() {
       it("should dispatch a DeclineCall action", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_VIDEO,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var buttonDecline = view.getDOMNode().querySelector(".btn-decline");
 
         TestUtils.Simulate.click(buttonDecline);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.DeclineCall({blockCaller: false}));
       });
     });
 
     describe("click event on .btn-block", function() {
       it("should dispatch a DeclineCall action with blockCaller true", function() {
         view = mountTestComponent({
           callType: CALL_TYPES.AUDIO_VIDEO,
-          callerId: "fake@invalid.com",
-          dispatcher: dispatcher
+          callerId: "fake@invalid.com"
         });
 
         var buttonBlock = view.getDOMNode().querySelector(".btn-block");
 
         TestUtils.Simulate.click(buttonBlock);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -910,21 +910,56 @@ describe("loop.store.ConversationStore",
         }));
 
         sinon.assert.calledOnce(trigger);
         sinon.assert.calledWithExactly(trigger, "error:emailLink");
       });
   });
 
   describe("#windowUnload", function() {
-    it("should disconnect from the servers via the sdk", function() {
+    var fakeWebsocket;
+
+    beforeEach(function() {
+      fakeWebsocket = store._websocket = {
+        close: sinon.stub(),
+        decline: sinon.stub()
+      };
+
+      store.setStoreState({windowId: 42});
+    });
+
+    it("should decline the connection on the websocket for incoming calls if the state is alerting", function() {
+      store.setStoreState({
+        callState: CALL_STATES.ALERTING,
+        outgoing: false
+      });
+
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeWebsocket.decline);
+    });
+
+    it("should disconnect the sdk session", function() {
       store.windowUnload();
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
+
+    it("should close the websocket", function() {
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeWebsocket.close);
+    });
+
+    it("should clear the call in progress for the backend", function() {
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeMozLoop.calls.clearCallInProgress);
+      sinon.assert.calledWithExactly(fakeMozLoop.calls.clearCallInProgress, 42);
+    });
   });
 
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
         store.connectCall(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));