Bug 1040901 - Make Loop incoming call view match MVP spec, r=dmose
authorAndrei Oprea <andrei.br92@gmail.com>
Fri, 18 Jul 2014 15:50:10 -0700
changeset 195105 db93dd7269d2f490fc74fef3b4e5845aacf5675c
parent 195104 e2650b0b07d62b96926cb2ee33eeada448dd2160
child 195106 f215d413b48943e81e7de5784e8f56773025ce5e
push id27169
push userryanvm@gmail.com
push dateMon, 21 Jul 2014 01:13:29 +0000
treeherdermozilla-central@4bafe35cfb65 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdmose
bugs1040901
milestone33.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 1040901 - Make Loop incoming call view match MVP spec, r=dmose
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/test/desktop-local/conversation_test.js
browser/locales/en-US/chrome/browser/loop/loop.properties
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -1,65 +1,84 @@
+/** @jsx React.DOM */
+
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false, esnext:true */
-/* global loop:true */
+/* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(OT, mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views;
+  var sharedViews = loop.shared.views,
+      // aliasing translation function as __ for concision
+      __ = mozL10n.get;
 
   /**
    * App router.
    * @type {loop.desktopRouter.DesktopConversationRouter}
    */
   var router;
 
-  /**
-   * Incoming call view.
-   * @type {loop.shared.views.BaseView}
-   */
-  var IncomingCallView = sharedViews.BaseView.extend({
-    template: _.template([
-      '<h2 data-l10n-id="incoming_call"></h2>',
-      '<p>',
-      '  <button class="btn btn-success btn-accept"',
-      '           data-l10n-id="accept_button"></button>',
-      '  <button class="btn btn-error btn-decline"',
-      '           data-l10n-id="decline_button"></button>',
-      '</p>'
-    ].join("")),
+  var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
 
-    className: "incoming-call",
-
-    events: {
-      "click .btn-accept": "handleAccept",
-      "click .btn-decline": "handleDecline"
+    propTypes: {
+      model: React.PropTypes.func.isRequired
     },
 
     /**
-     * User clicked on the "accept" button.
-     * @param  {MouseEvent} event
-     */
-    handleAccept: function(event) {
-      event.preventDefault();
-      this.model.trigger("accept");
+     * Used for adding different styles to the panel
+     * @returns {String} Corresponds to the client platform
+     * */
+    _getTargetPlatform: function() {
+      var platform="unknown_platform";
+
+      if (navigator.platform.indexOf("Win") !== -1) {
+        platform = "windows";
+      }
+      if (navigator.platform.indexOf("Mac") !== -1) {
+        platform = "mac";
+      }
+      if (navigator.platform.indexOf("Linux") !== -1) {
+        platform = "linux";
+      }
+
+      return platform;
+    },
+
+    _handleAccept: function() {
+      this.props.model.trigger("accept");
     },
 
-    /**
-     * User clicked on the "decline" button.
-     * @param  {MouseEvent} event
-     */
-    handleDecline: function(event) {
-      event.preventDefault();
-      this.model.trigger("decline");
+    _handleDecline: function() {
+      this.props.model.trigger("decline");
+    },
+
+    render: function() {
+      /* jshint ignore:start */
+      var btnClassAccept = "btn btn-error btn-decline";
+      var btnClassDecline = "btn btn-success btn-accept";
+      var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
+      return (
+        React.DOM.div( {className:conversationPanelClass}, 
+          React.DOM.h2(null, __("incoming_call")),
+          React.DOM.div( {className:"button-group"}, 
+            React.DOM.button( {className:btnClassAccept, onClick:this._handleDecline}, 
+              __("incoming_call_decline_button")
+            ),
+            React.DOM.button( {className:btnClassDecline, onClick:this._handleAccept}, 
+              __("incoming_call_answer_button")
+            )
+          )
+        )
+      );
+      /* jshint ignore:end */
     }
   });
 
   /**
    * Call ended view.
    * @type {loop.shared.views.BaseView}
    */
   var EndedCallView = sharedViews.BaseView.extend({
@@ -124,17 +143,19 @@ loop.conversation = (function(OT, mozL10
       window.navigator.mozLoop.startAlerting();
       this._conversation.set({loopVersion: loopVersion});
       this._conversation.once("accept", function() {
         this.navigate("call/accept", {trigger: true});
       }.bind(this));
       this._conversation.once("decline", function() {
         this.navigate("call/decline", {trigger: true});
       }.bind(this));
-      this.loadView(new IncomingCallView({model: this._conversation}));
+      this.loadReactComponent(loop.conversation.IncomingCallView({
+        model: this._conversation
+      }));
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
       this._conversation.initiate({
copy from browser/components/loop/content/js/conversation.js
copy to browser/components/loop/content/js/conversation.jsx
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -1,65 +1,84 @@
+/** @jsx React.DOM */
+
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false, esnext:true */
-/* global loop:true */
+/* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(OT, mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views;
+  var sharedViews = loop.shared.views,
+      // aliasing translation function as __ for concision
+      __ = mozL10n.get;
 
   /**
    * App router.
    * @type {loop.desktopRouter.DesktopConversationRouter}
    */
   var router;
 
-  /**
-   * Incoming call view.
-   * @type {loop.shared.views.BaseView}
-   */
-  var IncomingCallView = sharedViews.BaseView.extend({
-    template: _.template([
-      '<h2 data-l10n-id="incoming_call"></h2>',
-      '<p>',
-      '  <button class="btn btn-success btn-accept"',
-      '           data-l10n-id="accept_button"></button>',
-      '  <button class="btn btn-error btn-decline"',
-      '           data-l10n-id="decline_button"></button>',
-      '</p>'
-    ].join("")),
+  var IncomingCallView = React.createClass({
 
-    className: "incoming-call",
-
-    events: {
-      "click .btn-accept": "handleAccept",
-      "click .btn-decline": "handleDecline"
+    propTypes: {
+      model: React.PropTypes.func.isRequired
     },
 
     /**
-     * User clicked on the "accept" button.
-     * @param  {MouseEvent} event
-     */
-    handleAccept: function(event) {
-      event.preventDefault();
-      this.model.trigger("accept");
+     * Used for adding different styles to the panel
+     * @returns {String} Corresponds to the client platform
+     * */
+    _getTargetPlatform: function() {
+      var platform="unknown_platform";
+
+      if (navigator.platform.indexOf("Win") !== -1) {
+        platform = "windows";
+      }
+      if (navigator.platform.indexOf("Mac") !== -1) {
+        platform = "mac";
+      }
+      if (navigator.platform.indexOf("Linux") !== -1) {
+        platform = "linux";
+      }
+
+      return platform;
+    },
+
+    _handleAccept: function() {
+      this.props.model.trigger("accept");
     },
 
-    /**
-     * User clicked on the "decline" button.
-     * @param  {MouseEvent} event
-     */
-    handleDecline: function(event) {
-      event.preventDefault();
-      this.model.trigger("decline");
+    _handleDecline: function() {
+      this.props.model.trigger("decline");
+    },
+
+    render: function() {
+      /* jshint ignore:start */
+      var btnClassAccept = "btn btn-error btn-decline";
+      var btnClassDecline = "btn btn-success btn-accept";
+      var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
+      return (
+        <div className={conversationPanelClass}>
+          <h2>{__("incoming_call")}</h2>
+          <div className="button-group">
+            <button className={btnClassAccept} onClick={this._handleDecline}>
+              {__("incoming_call_decline_button")}
+            </button>
+            <button className={btnClassDecline} onClick={this._handleAccept}>
+              {__("incoming_call_answer_button")}
+            </button>
+          </div>
+        </div>
+      );
+      /* jshint ignore:end */
     }
   });
 
   /**
    * Call ended view.
    * @type {loop.shared.views.BaseView}
    */
   var EndedCallView = sharedViews.BaseView.extend({
@@ -124,17 +143,19 @@ loop.conversation = (function(OT, mozL10
       window.navigator.mozLoop.startAlerting();
       this._conversation.set({loopVersion: loopVersion});
       this._conversation.once("accept", function() {
         this.navigate("call/accept", {trigger: true});
       }.bind(this));
       this._conversation.once("decline", function() {
         this.navigate("call/decline", {trigger: true});
       }.bind(this));
-      this.loadView(new IncomingCallView({model: this._conversation}));
+      this.loadReactComponent(loop.conversation.IncomingCallView({
+        model: this._conversation
+      }));
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
       this._conversation.initiate({
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -162,16 +162,21 @@ loop.panel = (function(_, mozL10n) {
 
     componentDidMount: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
+      // XXX this initializer is a bug, as it will cause
+      // setState to set the callUrl to false if one is not returned.
+      // Should decide on an implement correct behavior and state
+      // (eg set widget as disabled, state.callUrl == '')
+      //
       var callUrl = false;
 
       this.props.notifier.clear();
 
       if (err) {
         this.props.notifier.errorL10n("unable_retrieve_url");
       } else {
         callUrl = callUrlData.callUrl || callUrlData.call_url;
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -162,16 +162,21 @@ loop.panel = (function(_, mozL10n) {
 
     componentDidMount: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
+      // XXX this initializer is a bug, as it will cause
+      // setState to set the callUrl to false if one is not returned.
+      // Should decide on an implement correct behavior and state
+      // (eg set widget as disabled, state.callUrl == '')
+      //
       var callUrl = false;
 
       this.props.notifier.clear();
 
       if (err) {
         this.props.notifier.errorL10n("unable_retrieve_url");
       } else {
         callUrl = callUrlData.callUrl || callUrlData.call_url;
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -52,56 +52,98 @@ img {
 .hide {
   display: none;
 }
 
 .tc {
   text-align: center;
 }
 
+.full-width {
+  width: 100%;
+}
+
 /* Buttons */
 
 .btn {
   display: inline-block;
   background: #a5a;
   border: none;
   color: #fff;
   text-decoration: none;
-  padding: .25em .5em .3em;
-  border-radius: .2em;
+  height: 26px;
+  padding: 0 0.5em;
+  border-radius: 2px;
+  cursor: pointer;
 }
 
 .btn-info {
-  background: #0095dd;
-}
-
-.btn-info:hover {
-  background: #008acb;
+  background: #0096dd;
+  border: 1px solid #0095dd;
 }
 
-.btn-info:active {
-  background: #006b9d;
+  .btn-info:hover {
+    background: #008acb;
+    border: 1px solid #008acb;
+  }
+
+  .btn-info:active {
+    background: #006b9d;
+    border: 1px solid #006b9d;
+  }
+
+.btn-success {
+  background: #74bf43;
+  border: 1px solid #74bf43;
 }
 
-.btn-success {
-  background: #5cb85c;
-}
+  .btn-success:hover {
+    background: #6cb23e;
+    border: 1px solid #6cb23e;
+  }
+
+  .btn-success:active {
+    background: #64a43a;
+    border: 1px solid #64a43a;
+  }
 
 .btn-warning {
   background: #f0ad4e;
 }
 
 .btn-error {
-  background: #d9534f;
+  background: #d74345;
+  border: 1px solid #d74345;
 }
 
+  .btn-error:hover {
+    background: #c53436;
+    border: 1px solid #c53436;
+  }
+
+  .btn-error:active {
+    background: #ae2325;
+    border: 1px solid #ae2325;
+  }
+
 .disabled, button[disabled] {
-    cursor: not-allowed;
-    pointer-events: none;
-    opacity: 0.65;
+  cursor: not-allowed;
+  pointer-events: none;
+  opacity: 0.65;
+}
+
+.button-group {
+  display: flex;
+  width: 100%;
+  padding: 1em;
+}
+
+.button-group .btn {
+  flex: 1;
+  margin: 0 .3em;
 }
 
 /* Alerts */
 .alert {
   background: #eee;
   padding: .2em 1em;
   margin-bottom: 1em;
 }
@@ -138,19 +180,77 @@ img {
   opacity: .2;
 }
 
 .close:before {
   /* \2716 is unicode representation of the close button icon */
   content: '\2716';
 }
 
-button.close {
+.btn.close {
   background: none;
   border: none;
   cursor: pointer;
 }
 
 /* Transitions */
 .fade-out {
   transition: opacity 0.5s ease-in;
   opacity: 0;
 }
+
+/*
+ * Platform specific styles
+ * The UI should match the user OS
+ * Specific font sizes for text paragraphs to match
+ * the interface on that platform.
+ */
+
+.inverse {
+  color: #fff;
+}
+
+.light {
+  color: rgba(51, 51, 51, .5);
+}
+
+.mac p,
+.windows p,
+.linux p {
+  line-height: 16px;
+}
+
+.windows {
+  font-family: 'Segoe';
+}
+
+  .windows p {
+    font-size: 12px;
+  }
+
+  .windows h1 {
+    font-family: 'Segoe Bold';
+  }
+
+.mac {
+  font-family: 'Lucida Grande';
+}
+
+  .mac p {
+    font-size: 11.5px;
+  }
+
+  .mac h1 {
+    font-family: 'Lucida Grande Bold';
+  }
+
+.linux {
+  /* XXX requires fallbacks */
+  font-family: 'Ubuntu', sans-serif;
+}
+
+  .linux p {
+    font-size: 12px;
+  }
+
+  .linux h1 {
+    font-family: 'Ubuntu Bold';
+  }
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -149,22 +149,27 @@
 }
 
 /* Call ended view */
 .call-ended p {
   text-align: center;
 }
 
 /* Incoming call */
+
+/*
+ * Height matches the height of the docked window
+ * but the UI breaks when you pop out
+ * Bug 1040985
+ */
 .incoming-call {
-  text-align: center;
-  min-height: 200px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 264px;
 }
 
 .incoming-call h2 {
   font-size: 1.5em;
   font-weight: normal;
-  margin-top: 3em;
 }
 
-.incoming-call button {
-  margin-right: .2em;
-}
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -1,13 +1,13 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-/* global loop, sinon */
+/* global loop, sinon, React, TestUtils */
 
 var expect = chai.expect;
 
 describe("loop.conversation", function() {
   "use strict";
 
   var ConversationRouter = loop.conversation.ConversationRouter,
       sandbox,
@@ -18,36 +18,45 @@ describe("loop.conversation", function()
     notifier = {
       notify: sandbox.spy(),
       warn: sandbox.spy(),
       warnL10n: sandbox.spy(),
       error: sandbox.spy(),
       errorL10n: sandbox.spy()
     };
 
-    window.navigator.mozLoop = {
+    navigator.mozLoop = {
+      doNotDisturb: true,
       get serverUrl() {
         return "http://example.com";
       },
-
-      startAlerting: function() {
+      getStrings: function() {
+        return JSON.stringify({textContent: "fakeText"});
+      },
+      get locale() {
+        return "en-US";
       },
+      setLoopCharPref: sandbox.stub(),
+      getLoopCharPref: sandbox.stub(),
+      startAlerting: function() {},
+      stopAlerting: function() {}
+    };
 
-      stopAlerting: function() {
-      }
-    };
+    // XXX These stubs should be hoisted in a common file
+    // Bug 1040968
+    document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
     delete window.navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("#init", function() {
-    var conversation, oldTitle;
+    var oldTitle;
 
     beforeEach(function() {
       oldTitle = document.title;
 
       sandbox.stub(document.mozL10n, "initialize");
       sandbox.stub(document.mozL10n, "get").returns("Fake title");
 
       sandbox.stub(loop.conversation.ConversationRouter.prototype,
@@ -110,28 +119,53 @@ describe("loop.conversation", function()
         router = new ConversationRouter({
           conversation: conversation,
           notifier: notifier
         });
         sandbox.stub(router, "loadView");
       });
 
       describe("#incoming", function() {
+
+        // XXX refactor to Just Work with "sandbox.stubComponent" or else
+        // just pass in the sandbox and put somewhere generally usable
+
+        function stubComponent(obj, component, mockTagName){
+          var reactClass = React.createClass({
+            render: function() {
+              var mockTagName = mockTagName || "div";
+              return React.DOM[mockTagName](null, this.props.children);
+            }
+          });
+          return sandbox.stub(obj, component, reactClass);
+        }
+
+        beforeEach(function() {
+          sandbox.stub(router, "loadReactComponent");
+          stubComponent(loop.conversation, "IncomingCallView");
+        });
+
         it("should set the loopVersion on the conversation model", function() {
           router.incoming("fakeVersion");
 
           expect(conversation.get("loopVersion")).to.equal("fakeVersion");
         });
 
         it("should display the incoming call view", function() {
-          router.incoming("fakeVersion");
+            router.incoming("fakeVersion");
 
-          sinon.assert.calledOnce(router.loadView);
-          sinon.assert.calledWithExactly(router.loadView,
-            sinon.match.instanceOf(loop.conversation.IncomingCallView));
+            sinon.assert.calledOnce(loop.conversation.IncomingCallView);
+            sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
+                                           {model: conversation});
+            sinon.assert.calledOnce(router.loadReactComponent);
+            sinon.assert.calledWith(router.loadReactComponent,
+              sinon.match(function(value) {
+                return TestUtils.isComponentOfType(value,
+                  loop.conversation.IncomingCallView);
+              }));
         });
 
         it("should start alerting", function() {
           sandbox.stub(window.navigator.mozLoop, "startAlerting");
           router.incoming("fakeVersion");
 
           sinon.assert.calledOnce(window.navigator.mozLoop.startAlerting);
         });
@@ -167,18 +201,18 @@ describe("loop.conversation", function()
         it("should load the ConversationView if session is set", function() {
           conversation.set("sessionId", "fakeSessionId");
 
           router.conversation();
 
           sinon.assert.calledOnce(router.loadReactComponent);
           sinon.assert.calledWith(router.loadReactComponent,
             sinon.match(function(value) {
-              return React.addons.TestUtils.isComponentOfType(
-                value, loop.shared.views.ConversationView);
+              return TestUtils.isComponentOfType(value,
+                loop.shared.views.ConversationView);
             }));
         });
 
         it("should not load the ConversationView if session is not set",
           function() {
             router.conversation();
 
             sinon.assert.notCalled(router.loadReactComponent);
@@ -280,41 +314,42 @@ describe("loop.conversation", function()
         view.closeWindow({preventDefault: sandbox.spy()});
 
         sinon.assert.calledOnce(window.close);
       });
     });
   });
 
   describe("IncomingCallView", function() {
-    var conversation, view;
+    var view, model;
 
     beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel({}, {
-        sdk: {},
-        pendingCallTimeout: 1000
-      });
-      view = new loop.conversation.IncomingCallView({model: conversation});
+      var Model = Backbone.Model.extend({});
+      model = new Model();
+      sandbox.spy(model, "trigger");
+      view = TestUtils.renderIntoDocument(loop.conversation.IncomingCallView({
+        model: model
+      }));
     });
 
-    describe("#handleAccept", function() {
-      it("should trigger an 'accept' conversation model event" ,
-        function(done) {
-          conversation.once("accept", function() {
-            done();
-          });
+    describe("click event on .btn-accept", function() {
+      it("should trigger an 'accept' conversation model event", function() {
+        var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
 
-          view.handleAccept({preventDefault: sandbox.spy()});
+        TestUtils.Simulate.click(buttonAccept);
+
+        sinon.assert.calledOnce(model.trigger);
+        sinon.assert.calledWith(model.trigger, "accept");
         });
     });
 
-    describe("#handleDecline", function() {
-      it("should trigger an 'decline' conversation model event" ,
-        function(done) {
-          conversation.once("decline", function() {
-            done();
-          });
+    describe("click event on .btn-decline", function() {
+      it("should trigger an 'decline' conversation model event", function() {
+        var buttonDecline = view.getDOMNode().querySelector(".btn-decline");
 
-          view.handleDecline({preventDefault: sandbox.spy()});
+        TestUtils.Simulate.click(buttonDecline);
+
+        sinon.assert.calledOnce(model.trigger);
+        sinon.assert.calledWith(model.trigger, "decline");
         });
     });
   });
 });
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -11,18 +11,20 @@ display_name_dnd_status=Do Not Disturb
 display_name_available_status=Available
 
 unable_retrieve_url=Sorry, we were unable to retrieve a call url.
 
 # Conversation Window Strings
 
 incoming_call_title=Incoming Call…
 incoming_call=Incoming call
-accept_button=Accept
-decline_button=Decline
+incoming_call_answer_button=Answer
+incoming_call_decline_button=Decline
+incoming_call_ignore_button=Ignore
+incoming_call_block_button=Block
 hangup_button_title=Hangup
 mute_local_audio_button_title=Mute your audio
 unmute_local_audio_button_title=Unmute your audio
 mute_local_video_button_title=Mute your video
 unmute_local_video_button_title=Unmute your video
 
 peer_ended_conversation=Your peer ended the conversation.
 call_has_ended=Your call has ended.