Bug 972017 Part 1 - Add a new controller view for selecting between incoming and outgoing calls in the Loop Conversation window. Also, set up a bare-bones outgoing pending conversation view. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Tue, 30 Sep 2014 20:44:04 +0100
changeset 208107 326b3f37e469dd8a6b477e8b137d3ea9b95c6683
parent 208106 407a8fb5579dcbccdcb1545c01622cd2e4a0981d
child 208108 5d2c69ebb3211c97adcd6f90476ea1e37da1cd66
push id27576
push usercbook@mozilla.com
push dateWed, 01 Oct 2014 13:00:38 +0000
treeherderautoland@1a550125928f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs972017
milestone35.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 972017 Part 1 - Add a new controller view for selecting between incoming and outgoing calls in the Loop Conversation window. Also, set up a bare-bones outgoing pending conversation view. r=mikedeboer
browser/components/loop/content/conversation.html
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/css/conversation.css
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/index.html
browser/components/loop/ui/index.html
browser/components/loop/ui/ui-showcase.css
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -25,13 +25,16 @@
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
+    <script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
+    <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
+    <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -6,18 +6,19 @@
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedModels = loop.shared.models;
+  var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
@@ -104,36 +105,33 @@ loop.conversation = (function(mozL10n) {
         props.secondary = videoButton;
       }
 
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-accept";
-      var btnClassDecline = "btn btn-error btn-decline";
-      var conversationPanelClass = "incoming-call";
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showDeclineMenu
       });
       return (
-        React.DOM.div({className: conversationPanelClass}, 
+        React.DOM.div({className: "call-window"}, 
           React.DOM.h2(null, mozL10n.get("incoming_call_title2")), 
-          React.DOM.div({className: "btn-group incoming-call-action-group"}, 
+          React.DOM.div({className: "btn-group call-action-group"}, 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"}), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             React.DOM.div({className: "btn-chevron-menu-group"}, 
               React.DOM.div({className: "btn-group-chevron"}, 
                 React.DOM.div({className: "btn-group"}, 
 
-                  React.DOM.button({className: btnClassDecline, 
+                  React.DOM.button({className: "btn btn-error btn-decline", 
                           onClick: this._handleDecline}, 
                     mozL10n.get("incoming_call_cancel_button")
                   ), 
                   React.DOM.div({className: "btn-chevron", 
                        onClick: this._toggleDeclineMenu}
                   )
                 ), 
 
@@ -141,21 +139,21 @@ loop.conversation = (function(mozL10n) {
                   React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock}, 
                     mozL10n.get("incoming_call_cancel_and_block_button")
                   )
                 )
 
               )
             ), 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"}), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             AcceptCallButton({mode: this._answerModeProps()}), 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"})
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
 
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
@@ -485,16 +483,54 @@ loop.conversation = (function(mozL10n) {
     _handleSessionError: function() {
       // XXX Not the ideal response, but bug 1047410 will be replacing
       // this by better "call failed" UI.
       this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
     },
   });
 
   /**
+   * Master controller view for handling if incoming or outgoing calls are
+   * in progress, and hence, which view to display.
+   */
+  var ConversationControllerView = React.createClass({displayName: 'ConversationControllerView',
+    propTypes: {
+      // XXX Old types required for incoming call view.
+      client: React.PropTypes.instanceOf(loop.Client).isRequired,
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
+                          .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+
+      // XXX New types for OutgoingConversationView
+      store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    render: function() {
+      if (this.state.outgoing) {
+        return (OutgoingConversationView({
+          store: this.props.store}
+        ));
+      }
+
+      return (IncomingConversationView({
+        client: this.props.client, 
+        conversation: this.props.conversation, 
+        notifications: this.props.notifications, 
+        sdk: this.props.sdk}
+      ));
+    }
+  });
+
+  /**
    * Panel initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
@@ -504,45 +540,59 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+    var conversationStore = new loop.ConversationStore();
 
+    // XXX For now key this on the pref, but this should really be
+    // set by the information from the mozLoop API when we can get it (bug 1072323).
+    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
+    if (outgoingEmail) {
+      conversationStore.set("outgoing", true);
+      conversationStore.set("calleeId", outgoingEmail);
+    }
+
+    // XXX Old class creation for the incoming conversation view, whilst
+    // we transition across (bug 1072323).
     var client = new loop.Client();
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
     var notifications = new sharedModels.NotificationCollection();
 
-    window.addEventListener("unload", function(event) {
-      // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
-    });
-
-    // Obtain the callId and pass it to the conversation
+    // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
     }
 
-    React.renderComponent(IncomingConversationView({
+    window.addEventListener("unload", function(event) {
+      // Handle direct close of dialog box via [x] control.
+      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+    });
+
+    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+
+    React.renderComponent(ConversationControllerView({
+      store: conversationStore, 
       client: client, 
       conversation: conversation, 
       notifications: notifications, 
       sdk: window.OT}
     ), document.querySelector('#main'));
   }
 
   return {
+    ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -6,18 +6,19 @@
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedModels = loop.shared.models;
+  var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
@@ -104,36 +105,33 @@ loop.conversation = (function(mozL10n) {
         props.secondary = videoButton;
       }
 
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-accept";
-      var btnClassDecline = "btn btn-error btn-decline";
-      var conversationPanelClass = "incoming-call";
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showDeclineMenu
       });
       return (
-        <div className={conversationPanelClass}>
+        <div className="call-window">
           <h2>{mozL10n.get("incoming_call_title2")}</h2>
-          <div className="btn-group incoming-call-action-group">
+          <div className="btn-group call-action-group">
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
                 <div className="btn-group">
 
-                  <button className={btnClassDecline}
+                  <button className="btn btn-error btn-decline"
                           onClick={this._handleDecline}>
                     {mozL10n.get("incoming_call_cancel_button")}
                   </button>
                   <div className="btn-chevron"
                        onClick={this._toggleDeclineMenu}>
                   </div>
                 </div>
 
@@ -141,21 +139,21 @@ loop.conversation = (function(mozL10n) {
                   <li className="btn-block" onClick={this._handleDeclineBlock}>
                     {mozL10n.get("incoming_call_cancel_and_block_button")}
                   </li>
                 </ul>
 
               </div>
             </div>
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
             <AcceptCallButton mode={this._answerModeProps()} />
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
@@ -485,16 +483,54 @@ loop.conversation = (function(mozL10n) {
     _handleSessionError: function() {
       // XXX Not the ideal response, but bug 1047410 will be replacing
       // this by better "call failed" UI.
       this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
     },
   });
 
   /**
+   * Master controller view for handling if incoming or outgoing calls are
+   * in progress, and hence, which view to display.
+   */
+  var ConversationControllerView = React.createClass({
+    propTypes: {
+      // XXX Old types required for incoming call view.
+      client: React.PropTypes.instanceOf(loop.Client).isRequired,
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
+                          .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+
+      // XXX New types for OutgoingConversationView
+      store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    render: function() {
+      if (this.state.outgoing) {
+        return (<OutgoingConversationView
+          store={this.props.store}
+        />);
+      }
+
+      return (<IncomingConversationView
+        client={this.props.client}
+        conversation={this.props.conversation}
+        notifications={this.props.notifications}
+        sdk={this.props.sdk}
+      />);
+    }
+  });
+
+  /**
    * Panel initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
@@ -504,45 +540,59 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+    var conversationStore = new loop.ConversationStore();
 
+    // XXX For now key this on the pref, but this should really be
+    // set by the information from the mozLoop API when we can get it (bug 1072323).
+    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
+    if (outgoingEmail) {
+      conversationStore.set("outgoing", true);
+      conversationStore.set("calleeId", outgoingEmail);
+    }
+
+    // XXX Old class creation for the incoming conversation view, whilst
+    // we transition across (bug 1072323).
     var client = new loop.Client();
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
     var notifications = new sharedModels.NotificationCollection();
 
-    window.addEventListener("unload", function(event) {
-      // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
-    });
-
-    // Obtain the callId and pass it to the conversation
+    // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
     }
 
-    React.renderComponent(<IncomingConversationView
+    window.addEventListener("unload", function(event) {
+      // Handle direct close of dialog box via [x] control.
+      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+    });
+
+    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+
+    React.renderComponent(<ConversationControllerView
+      store={conversationStore}
       client={client}
       conversation={conversation}
       notifications={notifications}
       sdk={window.OT}
     />, document.querySelector('#main'));
   }
 
   return {
+    ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -0,0 +1,100 @@
+/** @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/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.conversationViews = (function(mozL10n) {
+
+  /**
+   * Displays details of the incoming/outgoing conversation
+   * (name, link, audio/video type etc).
+   *
+   * Allows the view to be extended with different buttons and progress
+   * via children properties.
+   */
+  var ConversationDetailView = React.createClass({displayName: 'ConversationDetailView',
+    propTypes: {
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      document.title = this.props.calleeId;
+
+      return (
+        React.DOM.div({className: "call-window"}, 
+          React.DOM.h2(null, this.props.calleeId), 
+          React.DOM.div(null, this.props.children)
+        )
+      );
+    }
+  });
+
+  /**
+   * View for pending conversations. Displays a cancel button and appropriate
+   * pending/ringing strings.
+   */
+  var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
+    propTypes: {
+      callState: React.PropTypes.string,
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      var pendingStateString;
+      if (this.props.callState === "ringing") {
+        pendingStateString = mozL10n.get("call_progress_pending_description");
+      } else {
+        pendingStateString = mozL10n.get("call_progress_connecting_description");
+      }
+
+      return (
+        ConversationDetailView({calleeId: this.props.calleeId}, 
+
+          React.DOM.p({className: "btn-label"}, pendingStateString), 
+
+          React.DOM.div({className: "btn-group call-action-group"}, 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: "btn btn-cancel"}, 
+                mozL10n.get("initiate_call_cancel_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
+          )
+
+        )
+      );
+    }
+  });
+
+  /**
+   * Master View Controller for outgoing calls. This manages
+   * the different views that need displaying.
+   */
+  var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
+    propTypes: {
+      store: React.PropTypes.instanceOf(
+        loop.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    render: function() {
+      return (PendingConversationView({
+        callState: this.state.callState, 
+        calleeId: this.state.calleeId}
+      ))
+    }
+  });
+
+  return {
+    PendingConversationView: PendingConversationView,
+    ConversationDetailView: ConversationDetailView,
+    OutgoingConversationView: OutgoingConversationView
+  };
+
+})(document.mozL10n || navigator.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -0,0 +1,100 @@
+/** @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/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.conversationViews = (function(mozL10n) {
+
+  /**
+   * Displays details of the incoming/outgoing conversation
+   * (name, link, audio/video type etc).
+   *
+   * Allows the view to be extended with different buttons and progress
+   * via children properties.
+   */
+  var ConversationDetailView = React.createClass({
+    propTypes: {
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      document.title = this.props.calleeId;
+
+      return (
+        <div className="call-window">
+          <h2>{this.props.calleeId}</h2>
+          <div>{this.props.children}</div>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * View for pending conversations. Displays a cancel button and appropriate
+   * pending/ringing strings.
+   */
+  var PendingConversationView = React.createClass({
+    propTypes: {
+      callState: React.PropTypes.string,
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      var pendingStateString;
+      if (this.props.callState === "ringing") {
+        pendingStateString = mozL10n.get("call_progress_pending_description");
+      } else {
+        pendingStateString = mozL10n.get("call_progress_connecting_description");
+      }
+
+      return (
+        <ConversationDetailView calleeId={this.props.calleeId}>
+
+          <p className="btn-label">{pendingStateString}</p>
+
+          <div className="btn-group call-action-group">
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className="btn btn-cancel">
+                {mozL10n.get("initiate_call_cancel_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+          </div>
+
+        </ConversationDetailView>
+      );
+    }
+  });
+
+  /**
+   * Master View Controller for outgoing calls. This manages
+   * the different views that need displaying.
+   */
+  var OutgoingConversationView = React.createClass({
+    propTypes: {
+      store: React.PropTypes.instanceOf(
+        loop.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    render: function() {
+      return (<PendingConversationView
+        callState={this.state.callState}
+        calleeId={this.state.calleeId}
+      />)
+    }
+  });
+
+  return {
+    PendingConversationView: PendingConversationView,
+    ConversationDetailView: ConversationDetailView,
+    OutgoingConversationView: OutgoingConversationView
+  };
+
+})(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -218,60 +218,61 @@
   width: 50%;
 }
 
 /* Call ended view */
 .call-ended p {
   text-align: center;
 }
 
-/* Incoming call */
+/* General Call (incoming or outgoing). */
 
 /*
  * Height matches the height of the docked window
  * but the UI breaks when you pop out
  * Bug 1040985
  */
-.incoming-call {
+.call-window {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: space-between;
   min-height: 230px;
 }
 
-.incoming-call-action-group {
+.call-action-group {
   display: flex;
   padding: 2.5em 0 0 0;
   width: 100%;
   justify-content: space-around;
 }
 
-.incoming-call-action-group > .btn {
+.call-action-group > .btn {
   margin-left: .5em;
+  height: 26px;
 }
 
-.incoming-call-action-group .btn-group-chevron,
-.incoming-call-action-group .btn-group {
+.call-action-group .btn-group-chevron,
+.call-action-group .btn-group {
   width: 100%;
 }
 
 /* XXX Once we get the incoming call avatar, bug 1047435, the H2 should
  * disappear from our markup, and we should remove this rule entirely.
  */
-.incoming-call h2 {
+.call-window h2 {
   font-size: 1.5em;
   font-weight: normal;
 
   /* compensate for reset.css overriding this; values borrowed from
      Firefox Mac html.css */
   margin: 0.83em 0;
 }
 
-.fx-embedded-incoming-call-button-spacer {
+.fx-embedded-call-button-spacer {
   display: flex;
   flex: 1;
 }
 
 /* Expired call url page */
 
 .expired-url-info {
   width: 400px;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -0,0 +1,19 @@
+/* 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:true */
+
+var loop = loop || {};
+loop.ConversationStore = (function() {
+
+  var ConversationStore = Backbone.Model.extend({
+    defaults: {
+      outgoing: false,
+      calleeId: undefined,
+      callState: "gather"
+    },
+  });
+
+  return ConversationStore;
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -11,16 +11,17 @@ browser.jar:
   content/browser/loop/libs/l10n.js                 (content/libs/l10n.js)
 
   # Desktop script
   content/browser/loop/js/client.js                 (content/js/client.js)
   content/browser/loop/js/conversation.js           (content/js/conversation.js)
   content/browser/loop/js/otconfig.js               (content/js/otconfig.js)
   content/browser/loop/js/panel.js                  (content/js/panel.js)
   content/browser/loop/js/contacts.js               (content/js/contacts.js)
+  content/browser/loop/js/conversationViews.js      (content/js/conversationViews.js)
 
   # Shared styles
   content/browser/loop/shared/css/reset.css         (content/shared/css/reset.css)
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/panel.css         (content/shared/css/panel.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
   content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
@@ -53,16 +54,17 @@ browser.jar:
 
   # Shared scripts
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
   content/browser/loop/shared/js/websocket.js         (content/shared/js/websocket.js)
+  content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
 
   # Shared libs
 #ifdef DEBUG
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1.js)
 #else
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1-prod.js)
 #endif
   content/browser/loop/shared/libs/lodash-2.4.1.js    (content/shared/libs/lodash-2.4.1.js)
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -63,18 +63,16 @@ describe("loop.conversation", function()
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("#init", function() {
-    var oldTitle;
-
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(document.mozL10n, "initialize");
 
       sandbox.stub(loop.shared.models.ConversationModel.prototype,
         "initialize");
 
       window.OT = {
@@ -89,27 +87,73 @@ describe("loop.conversation", function()
     it("should initalize L10n", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(document.mozL10n.initialize);
       sinon.assert.calledWithExactly(document.mozL10n.initialize,
         navigator.mozLoop);
     });
 
-    it("should create the IncomingConversationView", function() {
+    it("should create the ConversationControllerView", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(React.renderComponent);
       sinon.assert.calledWith(React.renderComponent,
         sinon.match(function(value) {
           return TestUtils.isDescriptorOfType(value,
-            loop.conversation.IncomingConversationView);
+            loop.conversation.ConversationControllerView);
       }));
     });
+  });
 
+  describe("ConversationControllerView", function() {
+    var store, conversation, client, ccView, oldTitle;
+
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        loop.conversation.ConversationControllerView({
+          client: client,
+          conversation: conversation,
+          notifications: notifications,
+          sdk: {},
+          store: store
+        }));
+    }
+
+    beforeEach(function() {
+      oldTitle = document.title;
+      client = new loop.Client();
+      conversation = new loop.shared.models.ConversationModel({}, {
+        sdk: {}
+      });
+      store = new loop.ConversationStore();
+    });
+
+    afterEach(function() {
+      ccView = undefined;
+      document.title = oldTitle;
+    });
+
+    it("should display the OutgoingConversationView for outgoing calls", function() {
+      store.set({outgoing: true});
+
+      ccView = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(ccView,
+        loop.conversationViews.OutgoingConversationView);
+    });
+
+    it("should display the IncomingConversationView for incoming calls", function() {
+      store.set({outgoing: false});
+
+      ccView = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(ccView,
+        loop.conversation.IncomingConversationView);
+    });
   });
 
   describe("IncomingConversationView", function() {
     var conversation, client, icView, oldTitle;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.IncomingConversationView({
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -29,21 +29,23 @@
     /*global chai,mocha */
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
+  <script src="../../content/shared/js/conversationStore.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/js/client.js"></script>
+  <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
   <script src="panel_test.js"></script>
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -27,20 +27,23 @@
       window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
     </script>
     <script src="../content/shared/libs/sdk.js"></script>
     <script src="../content/shared/libs/react-0.11.1.js"></script>
     <script src="../content/shared/libs/jquery-2.1.0.js"></script>
     <script src="../content/shared/libs/lodash-2.4.1.js"></script>
     <script src="../content/shared/libs/backbone-1.1.2.js"></script>
     <script src="../content/shared/js/feedbackApiClient.js"></script>
+    <script src="../content/shared/js/conversationStore.js"></script>
     <script src="../content/shared/js/utils.js"></script>
     <script src="../content/shared/js/models.js"></script>
     <script src="../content/shared/js/mixins.js"></script>
     <script src="../content/shared/js/views.js"></script>
+    <script src="../content/shared/js/websocket.js"></script>
+    <script src="../content/js/conversationViews.js"></script>
     <script src="../content/js/client.js"></script>
     <script src="../standalone/content/js/webapp.js"></script>
     <script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
     <script>
       if (!loop.contacts) {
         // For browsers that don't support ES6 without special flags (all but Fx
         // at the moment), we shim the contacts namespace with its most barebone
         // implementation.
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -133,18 +133,18 @@
   background-image: url("sample-img/video-screen-local.png");
   background-repeat: no-repeat;
 }
 
   .local-stream.local:not(.local-stream-audio) {
     background-size: cover;
   }
 
-.incoming-call-action-group .btn-group-chevron,
-.incoming-call-action-group .btn-group {
+.call-action-group .btn-group-chevron,
+.call-action-group .btn-group {
   /* Prevent box overflow due to long string */
   max-width: 120px;
 }
 
 .conversation .media.nested .remote {
   /* Height of obsolute box covers media control buttons. UI showcase only.
    * When tokbox inserts the markup into the page the problem goes away */
   bottom: auto;
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -10,16 +10,17 @@
 (function() {
   "use strict";
 
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
+  var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
   var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView = loop.webapp.StartConversationView;
@@ -58,16 +59,22 @@
 
   var mockSDK = {};
 
   var mockConversationModel = new loop.shared.models.ConversationModel({}, {
     sdk: mockSDK
   });
   mockConversationModel.startSession = noop;
 
+  var mockWebSocket = new loop.CallConnectionWebSocket({
+    url: "fake",
+    callId: "fakeId",
+    websocketToken: "fakeToken"
+  });
+
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
   errNotifications.error("Error!");
 
   var Example = React.createClass({displayName: 'Example',
     render: function() {
       var cx = React.addons.classSet;
       return (
@@ -218,22 +225,31 @@
                                      publishStream: noop})
               )
             )
           ), 
 
           Section({name: "PendingConversationView"}, 
             Example({summary: "Pending conversation view (connecting)", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView(null)
+                PendingConversationView({websocket: mockWebSocket})
               )
             ), 
             Example({summary: "Pending conversation view (ringing)", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView({callState: "ringing"})
+                PendingConversationView({websocket: mockWebSocket, callState: "ringing"})
+              )
+            )
+          ), 
+
+          Section({name: "PendingConversationView (Desktop)"}, 
+            Example({summary: "Connecting", dashed: "true", 
+                     style: {width: "260px", height: "265px"}}, 
+              React.DOM.div({className: "fx-embedded"}, 
+                DesktopPendingConversationView({callState: "gather", calleeId: "Mr Smith"})
               )
             )
           ), 
 
           Section({name: "StartConversationView"}, 
             Example({summary: "Start conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
                 StartConversationView({model: mockConversationModel, 
@@ -441,11 +457,14 @@
 
   window.addEventListener("DOMContentLoaded", function() {
     var body = document.body;
     body.className = loop.shared.utils.getTargetPlatform();
 
     React.renderComponent(App(null), body);
 
     _renderComponentsInIframes();
+
+    // Put the title back, in case views changed it.
+    document.title = "Loop UI Components Showcase";
   });
 
 })();
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -10,16 +10,17 @@
 (function() {
   "use strict";
 
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
+  var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
   var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView = loop.webapp.StartConversationView;
@@ -58,16 +59,22 @@
 
   var mockSDK = {};
 
   var mockConversationModel = new loop.shared.models.ConversationModel({}, {
     sdk: mockSDK
   });
   mockConversationModel.startSession = noop;
 
+  var mockWebSocket = new loop.CallConnectionWebSocket({
+    url: "fake",
+    callId: "fakeId",
+    websocketToken: "fakeToken"
+  });
+
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
   errNotifications.error("Error!");
 
   var Example = React.createClass({
     render: function() {
       var cx = React.addons.classSet;
       return (
@@ -218,22 +225,31 @@
                                      publishStream={noop} />
               </Example>
             </div>
           </Section>
 
           <Section name="PendingConversationView">
             <Example summary="Pending conversation view (connecting)" dashed="true">
               <div className="standalone">
-                <PendingConversationView />
+                <PendingConversationView websocket={mockWebSocket}/>
               </div>
             </Example>
             <Example summary="Pending conversation view (ringing)" dashed="true">
               <div className="standalone">
-                <PendingConversationView callState="ringing"/>
+                <PendingConversationView websocket={mockWebSocket} callState="ringing"/>
+              </div>
+            </Example>
+          </Section>
+
+          <Section name="PendingConversationView (Desktop)">
+            <Example summary="Connecting" dashed="true"
+                     style={{width: "260px", height: "265px"}}>
+              <div className="fx-embedded">
+                <DesktopPendingConversationView callState={"gather"} calleeId="Mr Smith" />
               </div>
             </Example>
           </Section>
 
           <Section name="StartConversationView">
             <Example summary="Start conversation view" dashed="true">
               <div className="standalone">
                 <StartConversationView model={mockConversationModel}
@@ -441,11 +457,14 @@
 
   window.addEventListener("DOMContentLoaded", function() {
     var body = document.body;
     body.className = loop.shared.utils.getTargetPlatform();
 
     React.renderComponent(<App />, body);
 
     _renderComponentsInIframes();
+
+    // Put the title back, in case views changed it.
+    document.title = "Loop UI Components Showcase";
   });
 
 })();