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 208897 db93dd7269d2f490fc74fef3b4e5845aacf5675c
parent 208896 e2650b0b07d62b96926cb2ee33eeada448dd2160
child 208898 f215d413b48943e81e7de5784e8f56773025ce5e
push idunknown
push userunknown
push dateunknown
reviewersdmose
bugs1040901
milestone33.0a1
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.