Bug 1000112 - Desktop client needs the ability to add a contact locally. r=mikedeboer
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 26 Sep 2014 15:38:17 +0100
changeset 225401 3e7ef7666582db9eff82182207c56bbb9e6272e3
parent 225400 4471ec778e7c6899e5c97290e1971f4ca5db7cc1
child 225402 1244193877db101ec2de6c3ea65897a8d1d5b191
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1000112
milestone34.0a2
Bug 1000112 - Desktop client needs the ability to add a contact locally. r=mikedeboer
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/contacts.js
browser/components/loop/content/js/contacts.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/contacts.css
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/ui/index.html
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -539,16 +539,27 @@ function injectLoopAPI(targetWindow) {
       enumerable: true,
       writable: true,
       value: function(histogramId, value) {
         Services.telemetry.getHistogramById(histogramId).add(value);
       }
     },
 
     /**
+     * Returns a new GUID (UUID) in curly braces format.
+     */
+    generateUUID: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        return MozLoopService.generateUUID();
+      }
+    },
+
+    /**
      * Compose a URL pointing to the location of an avatar by email address.
      * At the moment we use the Gravatar service to match email addresses with
      * avatars. This might change in the future as avatars might come from another
      * source.
      *
      * @param {String} emailAddress Users' email address
      * @param {Number} size         Size of the avatar image to return in pixels.
      *                              Optional. Default value: 40.
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1159,16 +1159,23 @@ this.MozLoopService = {
         Cu.reportError('No string for key: ' + key + 'found');
         return "";
       }
 
       return JSON.stringify(stringData[key]);
   },
 
   /**
+   * Returns a new GUID (UUID) in curly braces format.
+   */
+  generateUUID: function() {
+    return uuidgen.generateUUID().toString();
+  },
+
+  /**
    * Retrieves MozLoopService "do not disturb" value.
    *
    * @return {Boolean}
    */
   get doNotDisturb() {
     return MozLoopServiceInternal.doNotDisturb;
   },
 
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -6,16 +6,19 @@
 
 /*jshint newcap:false*/
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.contacts = (function(_, mozL10n) {
   "use strict";
 
+  const Button = loop.shared.views.Button;
+  const ButtonGroup = loop.shared.views.ButtonGroup;
+
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
   const ContactDetail = React.createClass({displayName: 'ContactDetail',
     propTypes: {
       handleContactClick: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
@@ -143,16 +146,23 @@ loop.contacts = (function(_, mozL10n) {
       delete contacts[guid];
       this.setState({});
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
+    handleImportButtonClick: function() {
+    },
+
+    handleAddContactButtonClick: function() {
+      this.props.startForm("contacts_add");
+    },
+
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
       if (comp !== 0) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
@@ -162,34 +172,133 @@ loop.contacts = (function(_, mozL10n) {
       let viewForItem = item => {
         return ContactDetail({key: item._guid, contact: item})
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
+      // Buttons are temporarily hidden using "style".
       return (
-        React.DOM.div({className: "listWrapper"}, 
-          React.DOM.div({ref: "listSlider", className: "listPanels"}, 
-            React.DOM.div({className: "faded"}, 
-              React.DOM.ul(null, 
-                shownContacts.available ?
-                  shownContacts.available.sort(this.sortContacts).map(viewForItem) :
-                  null, 
-                shownContacts.blocked ?
-                  React.DOM.h3({className: "header"}, mozL10n.get("contacts_blocked_contacts")) :
-                  null, 
-                shownContacts.blocked ?
-                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
-                  null
-              )
+        React.DOM.div(null, 
+          React.DOM.div({className: "content-area", style: {display: "none"}}, 
+            ButtonGroup(null, 
+              Button({caption: mozL10n.get("import_contacts_button"), 
+                      disabled: true, 
+                      onClick: this.handleImportButtonClick}), 
+              Button({caption: mozL10n.get("new_contact_button"), 
+                      onClick: this.handleAddContactButtonClick})
             )
+          ), 
+          React.DOM.ul({className: "contact-list"}, 
+            shownContacts.available ?
+              shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+              null, 
+            shownContacts.blocked ?
+              React.DOM.div({className: "contact-separator"}, mozL10n.get("contacts_blocked_contacts")) :
+              null, 
+            shownContacts.blocked ?
+              shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+              null
+          )
+        )
+      );
+    }
+  });
+
+  const ContactDetailsForm = React.createClass({displayName: 'ContactDetailsForm',
+    mixins: [React.addons.LinkedStateMixin],
+
+    propTypes: {
+      mode: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      return {
+        contact: null,
+        pristine: true,
+        name: "",
+        email: "",
+      };
+    },
+
+    initForm: function(contact) {
+      let state = this.getInitialState();
+      state.contact = contact || null;
+      this.setState(state);
+    },
+
+    handleAcceptButtonClick: function() {
+      // Allow validity error indicators to be displayed.
+      this.setState({
+        pristine: false,
+      });
+
+      if (!this.refs.name.getDOMNode().checkValidity() ||
+          !this.refs.email.getDOMNode().checkValidity()) {
+        return;
+      }
+
+      this.props.selectTab("contacts");
+
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      switch (this.props.mode) {
+        case "edit":
+          this.setState({
+            contact: null,
+          });
+          break;
+        case "add":
+          contactsAPI.add({
+            id: navigator.mozLoop.generateUUID(),
+            name: [this.state.name.trim()],
+            email: [{
+              pref: true,
+              type: ["home"],
+              value: this.state.email.trim()
+            }],
+            category: ["local"]
+          }, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+      }
+    },
+
+    handleCancelButtonClick: function() {
+      this.props.selectTab("contacts");
+    },
+
+    render: function() {
+      let cx = React.addons.classSet;
+      return (
+        React.DOM.div({className: "content-area contact-form"}, 
+          React.DOM.header(null, mozL10n.get("add_contact_button")), 
+          React.DOM.label(null, mozL10n.get("edit_contact_name_label")), 
+          React.DOM.input({ref: "name", required: true, pattern: "\\s*\\S.*", 
+                 className: cx({pristine: this.state.pristine}), 
+                 valueLink: this.linkState("name")}), 
+          React.DOM.label(null, mozL10n.get("edit_contact_email_label")), 
+          React.DOM.input({ref: "email", required: true, type: "email", 
+                 className: cx({pristine: this.state.pristine}), 
+                 valueLink: this.linkState("email")}), 
+          ButtonGroup(null, 
+            Button({additionalClass: "button-cancel", 
+                    caption: mozL10n.get("cancel_button"), 
+                    onClick: this.handleCancelButtonClick}), 
+            Button({additionalClass: "button-accept", 
+                    caption: mozL10n.get("add_contact_button"), 
+                    onClick: this.handleAcceptButtonClick})
           )
         )
       );
     }
   });
 
   return {
-    ContactsList: ContactsList
+    ContactsList: ContactsList,
+    ContactDetailsForm: ContactDetailsForm,
   };
 })(_, document.mozL10n);
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -6,16 +6,19 @@
 
 /*jshint newcap:false*/
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.contacts = (function(_, mozL10n) {
   "use strict";
 
+  const Button = loop.shared.views.Button;
+  const ButtonGroup = loop.shared.views.ButtonGroup;
+
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
   const ContactDetail = React.createClass({
     propTypes: {
       handleContactClick: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
@@ -143,16 +146,23 @@ loop.contacts = (function(_, mozL10n) {
       delete contacts[guid];
       this.setState({});
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
+    handleImportButtonClick: function() {
+    },
+
+    handleAddContactButtonClick: function() {
+      this.props.startForm("contacts_add");
+    },
+
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
       if (comp !== 0) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
@@ -162,34 +172,133 @@ loop.contacts = (function(_, mozL10n) {
       let viewForItem = item => {
         return <ContactDetail key={item._guid} contact={item} />
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
+      // Buttons are temporarily hidden using "style".
       return (
-        <div className="listWrapper">
-          <div ref="listSlider" className="listPanels">
-            <div className="faded">
-              <ul>
-                {shownContacts.available ?
-                  shownContacts.available.sort(this.sortContacts).map(viewForItem) :
-                  null}
-                {shownContacts.blocked ?
-                  <h3 className="header">{mozL10n.get("contacts_blocked_contacts")}</h3> :
-                  null}
-                {shownContacts.blocked ?
-                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
-                  null}
-              </ul>
-            </div>
+        <div>
+          <div className="content-area" style={{display: "none"}}>
+            <ButtonGroup>
+              <Button caption={mozL10n.get("import_contacts_button")}
+                      disabled
+                      onClick={this.handleImportButtonClick} />
+              <Button caption={mozL10n.get("new_contact_button")}
+                      onClick={this.handleAddContactButtonClick} />
+            </ButtonGroup>
           </div>
+          <ul className="contact-list">
+            {shownContacts.available ?
+              shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+              null}
+            {shownContacts.blocked ?
+              <div className="contact-separator">{mozL10n.get("contacts_blocked_contacts")}</div> :
+              null}
+            {shownContacts.blocked ?
+              shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+              null}
+          </ul>
+        </div>
+      );
+    }
+  });
+
+  const ContactDetailsForm = React.createClass({
+    mixins: [React.addons.LinkedStateMixin],
+
+    propTypes: {
+      mode: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      return {
+        contact: null,
+        pristine: true,
+        name: "",
+        email: "",
+      };
+    },
+
+    initForm: function(contact) {
+      let state = this.getInitialState();
+      state.contact = contact || null;
+      this.setState(state);
+    },
+
+    handleAcceptButtonClick: function() {
+      // Allow validity error indicators to be displayed.
+      this.setState({
+        pristine: false,
+      });
+
+      if (!this.refs.name.getDOMNode().checkValidity() ||
+          !this.refs.email.getDOMNode().checkValidity()) {
+        return;
+      }
+
+      this.props.selectTab("contacts");
+
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      switch (this.props.mode) {
+        case "edit":
+          this.setState({
+            contact: null,
+          });
+          break;
+        case "add":
+          contactsAPI.add({
+            id: navigator.mozLoop.generateUUID(),
+            name: [this.state.name.trim()],
+            email: [{
+              pref: true,
+              type: ["home"],
+              value: this.state.email.trim()
+            }],
+            category: ["local"]
+          }, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+      }
+    },
+
+    handleCancelButtonClick: function() {
+      this.props.selectTab("contacts");
+    },
+
+    render: function() {
+      let cx = React.addons.classSet;
+      return (
+        <div className="content-area contact-form">
+          <header>{mozL10n.get("add_contact_button")}</header>
+          <label>{mozL10n.get("edit_contact_name_label")}</label>
+          <input ref="name" required pattern="\s*\S.*"
+                 className={cx({pristine: this.state.pristine})}
+                 valueLink={this.linkState("name")} />
+          <label>{mozL10n.get("edit_contact_email_label")}</label>
+          <input ref="email" required type="email"
+                 className={cx({pristine: this.state.pristine})}
+                 valueLink={this.linkState("email")} />
+          <ButtonGroup>
+            <Button additionalClass="button-cancel"
+                    caption={mozL10n.get("cancel_button")}
+                    onClick={this.handleCancelButtonClick} />
+            <Button additionalClass="button-accept"
+                    caption={mozL10n.get("add_contact_button")}
+                    onClick={this.handleAcceptButtonClick} />
+          </ButtonGroup>
         </div>
       );
     }
   });
 
   return {
-    ContactsList: ContactsList
+    ContactsList: ContactsList,
+    ContactDetailsForm: ContactDetailsForm,
   };
 })(_, document.mozL10n);
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -9,49 +9,49 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var Button = sharedViews.Button;
+  var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
+  var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({displayName: 'TabView',
     getInitialState: function() {
       return {
         selectedTab: "call"
       };
     },
 
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
-
-      if (this.props.onSelect) {
-        this.props.onSelect(tabName);
-      }
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var tabButtons = [];
       var tabs = [];
       React.Children.forEach(this.props.children, function(tab, i) {
         var tabName = tab.props.name;
         var isSelected = (this.state.selectedTab == tabName);
-        tabButtons.push(
-          React.DOM.li({className: cx({selected: isSelected}), 
-              key: i, 
-              'data-tab-name': tabName, 
-              onClick: this.handleSelectTab}
-          )
-        );
+        if (!tab.props.hidden) {
+          tabButtons.push(
+            React.DOM.li({className: cx({selected: isSelected}), 
+                key: i, 
+                'data-tab-name': tabName, 
+                onClick: this.handleSelectTab})
+          );
+        }
         tabs.push(
           React.DOM.div({key: i, className: cx({tab: true, selected: isSelected})}, 
             tab.props.children
           )
         );
       }, this);
       return (
         React.DOM.div({className: "tab-view-container"}, 
@@ -225,17 +225,17 @@ loop.panel = (function(_, mozL10n) {
     _isSignedIn: function() {
       return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "settings-menu dropdown"}, 
-          React.DOM.a({className: "btn btn-settings", onClick: this.showDropdownMenu, 
+          React.DOM.a({className: "button-settings", onClick: this.showDropdownMenu, 
              title: __("settings_menu_button_tooltip")}), 
           React.DOM.ul({className: cx({"dropdown-menu": true, hide: !this.state.showMenu}), 
               onMouseLeave: this.hideDropdownMenu}, 
             SettingsDropdownEntry({label: __("settings_menu_item_settings"), 
                                    onClick: this.handleClickSettingsEntry, 
                                    displayed: false, 
                                    icon: "settings"}), 
             SettingsDropdownEntry({label: __("settings_menu_item_account"), 
@@ -249,36 +249,16 @@ loop.panel = (function(_, mozL10n) {
                                    icon: this._isSignedIn() ? "signout" : "signin"})
           )
         )
       );
     }
   });
 
   /**
-   * Panel layout.
-   */
-  var PanelLayout = React.createClass({displayName: 'PanelLayout',
-    propTypes: {
-      summary: React.PropTypes.string.isRequired
-    },
-
-    render: function() {
-      return (
-        React.DOM.div({className: "share generate-url"}, 
-          React.DOM.div({className: "description"}, this.props.summary), 
-          React.DOM.div({className: "action"}, 
-            this.props.children
-          )
-        )
-      );
-    }
-  });
-
-  /**
    * Call url result view.
    */
   var CallUrlResult = React.createClass({displayName: 'CallUrlResult',
     mixins: [sharedMixins.DocumentVisibilityMixin],
 
     propTypes: {
       callUrl:        React.PropTypes.string,
       callUrlExpiry:  React.PropTypes.number,
@@ -383,35 +363,34 @@ loop.panel = (function(_, mozL10n) {
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       var inputCSSClass = cx({
         "pending": this.state.pending,
         // Used in functional testing, signals that
         // call url was received from loop server
-         "callUrl": !this.state.pending
+        "callUrl": !this.state.pending
       });
       return (
-        PanelLayout({summary: __("share_link_header_text")}, 
-          React.DOM.div({className: "invite"}, 
-            React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
-                   onCopy: this.handleLinkExfiltration, 
-                   className: inputCSSClass}), 
-            React.DOM.p({className: "btn-group url-actions"}, 
-              React.DOM.button({className: "btn btn-email", disabled: !this.state.callUrl, 
-                onClick: this.handleEmailButtonClick}, 
-                __("share_button")
-              ), 
-              React.DOM.button({className: "btn btn-copy", disabled: !this.state.callUrl, 
-                onClick: this.handleCopyButtonClick}, 
-                this.state.copied ? __("copied_url_button") :
-                                     __("copy_url_button")
-              )
-            )
+        React.DOM.div({className: "generate-url"}, 
+          React.DOM.header(null, __("share_link_header_text")), 
+          React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
+                 onCopy: this.handleLinkExfiltration, 
+                 className: inputCSSClass}), 
+          ButtonGroup({additionalClass: "url-actions"}, 
+            Button({additionalClass: "button-email", 
+                    disabled: !this.state.callUrl, 
+                    onClick: this.handleEmailButtonClick, 
+                    caption: mozL10n.get("share_button")}), 
+            Button({additionalClass: "button-copy", 
+                    disabled: !this.state.callUrl, 
+                    onClick: this.handleCopyButtonClick, 
+                    caption: this.state.copied ? mozL10n.get("copied_url_button") :
+                                                 mozL10n.get("copy_url_button")})
           )
         )
       );
     }
   });
 
   /**
    * FxA sign in/up link component.
@@ -465,41 +444,65 @@ loop.panel = (function(_, mozL10n) {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
     _onAuthStatusChange: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
     },
 
+    startForm: function(name, contact) {
+      this.refs[name].initForm(contact);
+      this.selectTab(name);
+    },
+
+    selectTab: function(name) {
+      this.refs.tabView.setState({ selectedTab: name });
+    },
+
     componentDidMount: function() {
       window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
     componentWillUnmount: function() {
       window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         React.DOM.div(null, 
           NotificationListView({notifications: this.props.notifications, 
                                 clearOnDocumentHidden: true}), 
-          TabView({onSelect: this.selectTab}, 
+          TabView({ref: "tabView"}, 
             Tab({name: "call"}, 
-              CallUrlResult({client: this.props.client, 
-                             notifications: this.props.notifications, 
-                             callUrl: this.props.callUrl}), 
-              ToSView(null)
+              React.DOM.div({className: "content-area"}, 
+                CallUrlResult({client: this.props.client, 
+                               notifications: this.props.notifications, 
+                               callUrl: this.props.callUrl}), 
+                ToSView(null)
+              )
             ), 
             Tab({name: "contacts"}, 
-              ContactsList(null)
+              ContactsList({selectTab: this.selectTab, 
+                            startForm: this.startForm})
+            ), 
+            Tab({name: "contacts_add", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_add", mode: "add", 
+                                  selectTab: this.selectTab})
+            ), 
+            Tab({name: "contacts_edit", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_edit", mode: "edit", 
+                                  selectTab: this.selectTab})
+            ), 
+            Tab({name: "contacts_import", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_import", mode: "import", 
+                                  selectTab: this.selectTab})
             )
           ), 
           React.DOM.div({className: "footer"}, 
             React.DOM.div({className: "user-details"}, 
               UserIdentity({displayName: displayName}), 
               AvailabilityDropdown(null)
             ), 
             AuthLink(null), 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -9,49 +9,49 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var Button = sharedViews.Button;
+  var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
+  var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({
     getInitialState: function() {
       return {
         selectedTab: "call"
       };
     },
 
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
-
-      if (this.props.onSelect) {
-        this.props.onSelect(tabName);
-      }
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var tabButtons = [];
       var tabs = [];
       React.Children.forEach(this.props.children, function(tab, i) {
         var tabName = tab.props.name;
         var isSelected = (this.state.selectedTab == tabName);
-        tabButtons.push(
-          <li className={cx({selected: isSelected})}
-              key={i}
-              data-tab-name={tabName}
-              onClick={this.handleSelectTab}>
-          </li>
-        );
+        if (!tab.props.hidden) {
+          tabButtons.push(
+            <li className={cx({selected: isSelected})}
+                key={i}
+                data-tab-name={tabName}
+                onClick={this.handleSelectTab} />
+          );
+        }
         tabs.push(
           <div key={i} className={cx({tab: true, selected: isSelected})}>
             {tab.props.children}
           </div>
         );
       }, this);
       return (
         <div className="tab-view-container">
@@ -225,17 +225,17 @@ loop.panel = (function(_, mozL10n) {
     _isSignedIn: function() {
       return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
       return (
         <div className="settings-menu dropdown">
-          <a className="btn btn-settings" onClick={this.showDropdownMenu}
+          <a className="button-settings" onClick={this.showDropdownMenu}
              title={__("settings_menu_button_tooltip")} />
           <ul className={cx({"dropdown-menu": true, hide: !this.state.showMenu})}
               onMouseLeave={this.hideDropdownMenu}>
             <SettingsDropdownEntry label={__("settings_menu_item_settings")}
                                    onClick={this.handleClickSettingsEntry}
                                    displayed={false}
                                    icon="settings" />
             <SettingsDropdownEntry label={__("settings_menu_item_account")}
@@ -249,36 +249,16 @@ loop.panel = (function(_, mozL10n) {
                                    icon={this._isSignedIn() ? "signout" : "signin"} />
           </ul>
         </div>
       );
     }
   });
 
   /**
-   * Panel layout.
-   */
-  var PanelLayout = React.createClass({
-    propTypes: {
-      summary: React.PropTypes.string.isRequired
-    },
-
-    render: function() {
-      return (
-        <div className="share generate-url">
-          <div className="description">{this.props.summary}</div>
-          <div className="action">
-            {this.props.children}
-          </div>
-        </div>
-      );
-    }
-  });
-
-  /**
    * Call url result view.
    */
   var CallUrlResult = React.createClass({
     mixins: [sharedMixins.DocumentVisibilityMixin],
 
     propTypes: {
       callUrl:        React.PropTypes.string,
       callUrlExpiry:  React.PropTypes.number,
@@ -383,37 +363,36 @@ loop.panel = (function(_, mozL10n) {
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       var inputCSSClass = cx({
         "pending": this.state.pending,
         // Used in functional testing, signals that
         // call url was received from loop server
-         "callUrl": !this.state.pending
+        "callUrl": !this.state.pending
       });
       return (
-        <PanelLayout summary={__("share_link_header_text")}>
-          <div className="invite">
-            <input type="url" value={this.state.callUrl} readOnly="true"
-                   onCopy={this.handleLinkExfiltration}
-                   className={inputCSSClass} />
-            <p className="btn-group url-actions">
-              <button className="btn btn-email" disabled={!this.state.callUrl}
-                onClick={this.handleEmailButtonClick}>
-                {__("share_button")}
-              </button>
-              <button className="btn btn-copy" disabled={!this.state.callUrl}
-                onClick={this.handleCopyButtonClick}>
-                {this.state.copied ? __("copied_url_button") :
-                                     __("copy_url_button")}
-              </button>
-            </p>
-          </div>
-        </PanelLayout>
+        <div className="generate-url">
+          <header>{__("share_link_header_text")}</header>
+          <input type="url" value={this.state.callUrl} readOnly="true"
+                 onCopy={this.handleLinkExfiltration}
+                 className={inputCSSClass} />
+          <ButtonGroup additionalClass="url-actions">
+            <Button additionalClass="button-email"
+                    disabled={!this.state.callUrl}
+                    onClick={this.handleEmailButtonClick}
+                    caption={mozL10n.get("share_button")} />
+            <Button additionalClass="button-copy"
+                    disabled={!this.state.callUrl}
+                    onClick={this.handleCopyButtonClick}
+                    caption={this.state.copied ? mozL10n.get("copied_url_button") :
+                                                 mozL10n.get("copy_url_button")} />
+          </ButtonGroup>
+        </div>
       );
     }
   });
 
   /**
    * FxA sign in/up link component.
    */
   var AuthLink = React.createClass({
@@ -465,41 +444,65 @@ loop.panel = (function(_, mozL10n) {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
     _onAuthStatusChange: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
     },
 
+    startForm: function(name, contact) {
+      this.refs[name].initForm(contact);
+      this.selectTab(name);
+    },
+
+    selectTab: function(name) {
+      this.refs.tabView.setState({ selectedTab: name });
+    },
+
     componentDidMount: function() {
       window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
     componentWillUnmount: function() {
       window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         <div>
           <NotificationListView notifications={this.props.notifications}
                                 clearOnDocumentHidden={true} />
-          <TabView onSelect={this.selectTab}>
+          <TabView ref="tabView">
             <Tab name="call">
-              <CallUrlResult client={this.props.client}
-                             notifications={this.props.notifications}
-                             callUrl={this.props.callUrl} />
-              <ToSView />
+              <div className="content-area">
+                <CallUrlResult client={this.props.client}
+                               notifications={this.props.notifications}
+                               callUrl={this.props.callUrl} />
+                <ToSView />
+              </div>
             </Tab>
             <Tab name="contacts">
-              <ContactsList />
+              <ContactsList selectTab={this.selectTab}
+                            startForm={this.startForm} />
+            </Tab>
+            <Tab name="contacts_add" hidden={true}>
+              <ContactDetailsForm ref="contacts_add" mode="add"
+                                  selectTab={this.selectTab} />
+            </Tab>
+            <Tab name="contacts_edit" hidden={true}>
+              <ContactDetailsForm ref="contacts_edit" mode="edit"
+                                  selectTab={this.selectTab} />
+            </Tab>
+            <Tab name="contacts_import" hidden={true}>
+              <ContactDetailsForm ref="contacts_import" mode="import"
+                                  selectTab={this.selectTab}/>
             </Tab>
           </TabView>
           <div className="footer">
             <div className="user-details">
               <UserIdentity displayName={displayName} />
               <AvailabilityDropdown />
             </div>
             <AuthLink />
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -390,20 +390,8 @@ p {
 
 .firefox-logo {
   margin: 0 auto; /* horizontal block centering */
   width: 100px;
   height: 100px;
   background: transparent url(../img/firefox-logo.png) no-repeat center center;
   background-size: contain;
 }
-
-.header {
-  padding: 5px 10px;
-  color: #888;
-  margin: 0;
-  border-top: 1px solid #CCC;
-  background: #EEE;
-  display: flex;
-  align-items: center;
-  flex-direction: row;
-  height: 24px;
-}
--- a/browser/components/loop/content/shared/css/contacts.css
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -1,38 +1,52 @@
 /* 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/. */
 
+.contact-list {
+  border-top: 1px solid #ccc;
+  overflow-x: hidden;
+  overflow-y: auto;
+  /* Show six contacts and scroll for the rest */
+  max-height: 305px;
+}
+
+.contact,
+.contact-separator {
+  padding: 5px 10px;
+  font-size: 13px;
+}
+
 .contact {
+  position: relative;
   display: flex;
   flex-direction: row;
-  position: relative;
-  padding: 5px 10px;
+  align-items: center;
   color: #666;
-  font-size: 13px;
-  align-items: center;
+}
+
+.contact-separator {
+  height: 24px;
+  background: #eee;
+  color: #888;
 }
 
 .contact:not(:first-child) {
   border-top: 1px solid #ddd;
 }
 
-.contact.blocked > .details > .username {
-  color: #d74345;
+.contact-separator:not(:first-child) {
+  border-top: 1px solid #ccc;
 }
 
 .contact:hover {
   background: #eee;
 }
 
-.contact.selected {
-  background: #ebebeb;
-}
-
 .contact:hover > .icons {
   display: block;
   z-index: 1000;
 }
 
 .contact > .avatar {
   width: 40px;
   height: 40px;
@@ -40,27 +54,31 @@
   border-radius: 50%;
   margin-right: 10px;
   overflow: hidden;
   box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
   background-image: url("../img/audio-call-avatar.svg");
   background-repeat: no-repeat;
   background-color: #4ba6e7;
   background-size: contain;
+  -moz-user-select: none;
 }
 
 .contact > .avatar > img {
   width: 100%;
 }
 
 .contact > .details > .username {
   font-size: 12px;
   line-height: 20px;
   color: #222;
-  font-weight: normal;
+}
+
+.contact.blocked > .details > .username {
+  color: #d74345;
 }
 
 .contact > .details > .username > strong {
   font-weight: bold;
 }
 
 .contact > .details > .username > i.icon-blocked {
   display: inline-block;
@@ -88,58 +106,24 @@
 }
 
 .contact > .details > .email {
   color: #999;
   font-size: 11px;
   line-height: 16px;
 }
 
-.listWrapper {
-  overflow-x: hidden;
-  overflow-y: auto;
-  /* Show six contacts and scroll for the rest */
-  max-height: 305px;
-}
-
-.listPanels {
-  display: flex;
-  width: 200%;
-  flex-direction: row;
-  transition: 200ms ease-in;
-  transition-property: transform;
-}
-
-.listPanels > div {
-  flex: 0 0 50%;
-}
-
-.list {
-  display: flex;
-  flex-direction: column;
-  transition: opacity 0.3s ease-in-out;
-}
-
-.list.faded {
-  opacity: 0.3;
-}
-
-.list h3 {
-  margin: 0;
-  border-bottom: none;
-  border-top: 1px solid #ccc;
-}
-
 .icons {
   cursor: pointer;
   display: none;
   margin-left: auto;
   padding: 12px 10px;
   border-radius: 30px;
   background: #7ed321;
+  -moz-user-select: none;
 }
 
 .icons:hover {
   background: #89e029;
 }
 
 .icons i {
   margin: 0 5px;
@@ -156,8 +140,12 @@
 }
 
 .icons i.icon-caret-down {
   background-image: url("../img/icons-10x10.svg#dropdown-white");
   background-size: 10px 10px;
   width: 10px;
   height: 16px;
 }
+
+.contact-form > .button-group {
+  margin-top: 14px;
+}
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -1,55 +1,60 @@
 /* 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/. */
 
 /* Panel styles */
+
 .panel {
   /* hide the extra margin space that the panel resizer now wants to show */
   overflow: hidden;
 }
 
-.spacer {
-  margin-bottom: 1em;
+/* Notifications displayed over tabs */
+
+.panel .messages {
+  margin: 0;
 }
 
-.tab-view,
-.tab-view > li {
+.panel .messages .alert {
   margin: 0;
-  padding: 0;
-  border: 0;
-  vertical-align: baseline;
 }
 
+/* Tabs and tab selection buttons */
+
 .tab-view {
   display: flex;
   flex-direction: row;
   padding: 10px;
   border-bottom: 1px solid #ccc;
-  background: #fafafa;
+  background-color: #fbfbfb;
+  color: #000;
   border-top-right-radius: 2px;
   border-top-left-radius: 2px;
   list-style: none;
 }
 
 .tab-view > li {
   flex: 1;
   text-align: center;
   color: #ccc;
   border-right: 1px solid #ccc;
   padding: 0 10px;
   height: 16px;
   cursor: pointer;
-  overflow: hidden;
   background-repeat: no-repeat;
   background-size: 16px 16px;
   background-position: center;
 }
 
+.tab-view > li:last-child {
+  border-right-style: none;
+}
+
 .tab-view > li[data-tab-name="call"] {
   background-image: url("../img/icons-16x16.svg#precall");
 }
 
 .tab-view > li[data-tab-name="call"]:hover {
   background-image: url("../img/icons-16x16.svg#precall-hover");
 }
 
@@ -64,105 +69,131 @@
 .tab-view > li[data-tab-name="contacts"]:hover {
   background-image: url("../img/icons-16x16.svg#contacts-hover");
 }
 
 .tab-view > li[data-tab-name="contacts"].selected {
   background-image: url("../img/icons-16x16.svg#contacts-active");
 }
 
-.tab-view > li:last-child {
-  border-right: 0;
-}
-
 .tab {
   display: none;
 }
 
 .tab.selected {
   display: block;
 }
 
-.share {
-  background: #fbfbfb;
-  margin-bottom: 14px;
+/* Content area and input fields */
+
+.content-area {
+  padding: 14px;
 }
 
-.share .description,
-.share .action input,
-.share > .action > .invite > .url-actions {
-  margin: 14px 14px 0 14px;
-}
-
-.share .description {
+.content-area header {
   font-weight: 700;
 }
 
-.share .action input {
-  border: 1px solid #ccc; /* Overriding background style for a text input (see
-                             below) resets its borders to a weird beveled style;
-                             defining a default 1px border solves the issue. */
-  font-size: 1em;
+.content-area label {
+  display: block;
+  width: 100%;
+  margin-top: 10px;
+  font-size: 10px;
+  color: #777;
+}
+
+.content-area input {
+  display: block;
+  width: 100%;
+  outline: none;
+  border-radius: 2px;
+  margin: 5px 0;
+  border: 1px solid #ccc;
+  height: 24px;
   padding: 0 10px;
-  border-radius: 2px;
-  outline: 0;
-  height: 26px;
-  width: calc(100% - 28px);
 }
 
-.share .action input.pending {
-  background-image: url(../img/loading-icon.gif);
-  background-repeat: no-repeat;
-  background-position: right;
+.content-area input:invalid {
+  box-shadow: none;
+}
+
+.content-area input:not(.pristine):invalid {
+  border-color: #d74345;
+  box-shadow: 0 0 4px #c43c3e;
+}
+
+/* Buttons */
+
+.button-group {
+  display: flex;
+  flex-direction: row;
+  width: 100%;
+}
+
+.button-group > .button {
+  flex: 1;
+  margin: 0 7px;
 }
 
-.share .action .btn {
-  background-color: #0096DD;
-  border: 1px solid #0095DD;
-  color: #fff;
-  width: 50%;
-  height: 26px;
-  text-align: center;
+.button-group > .button:first-child {
+  -moz-margin-start: 0;
+}
+
+.button-group > .button:last-child {
+  -moz-margin-end: 0;
 }
 
-.btn-email,
-.btn-copy {
+.button {
+  padding: 2px 5px;
+  background-color: #fbfbfb;
+  color: #333;
+  border: 1px solid #c1c1c1;
   border-radius: 2px;
+  height: 26px;
+  font-size: 12px;
+}
+
+.button:hover {
+  background-color: #ebebeb;
 }
 
-.share > .action .btn:hover {
-  background-color: #008ACB;
-  border: 1px solid #008ACB;
+.button:hover:active {
+  background-color: #ccc;
+  color: #111;
 }
 
-.share > .action > .invite > .url-actions > .btn:first-child {
-  -moz-margin-end: 1em;
+.button.button-accept {
+  background-color: #74bf43;
+  border-color: #74bf43;
+  color: #fff;
 }
 
-/* Specific cases */
-
-.panel .messages {
-  margin: 0;
+.button.button-accept:hover {
+  background-color: #6cb23e;
+  border-color: #6cb23e;
+  color: #fff;
 }
 
-.panel .messages .alert {
-  margin: 0;
+.button.button-accept:hover:active {
+  background-color: #64a43a;
+  border-color: #64a43a;
+  color: #fff;
 }
 
-/* Dropdown menu (shared styles) */
+/* Dropdown menu */
 
 .dropdown {
   position: relative;
 }
 
 .dropdown-menu {
   position: absolute;
   top: -28px;
   left: 0;
-  background: #fdfdfd;
+  background-color: #fdfdfd;
   box-shadow: 0 1px 3px rgba(0,0,0,.3);
   list-style: none;
   padding: 5px;
   border-radius: 2px;
 }
 
 body[dir=rtl] .dropdown-menu-item {
   left: auto;
@@ -177,46 +208,88 @@ body[dir=rtl] .dropdown-menu-item {
   border: 1px solid transparent;
   border-radius: 2px;
   font-size: 1em;
   white-space: nowrap;
 }
 
 .dropdown-menu-item:hover {
   border: 1px solid #ccc;
-  background: #eee;
+  background-color: #eee;
+}
+
+/* Share tab */
+
+.generate-url input {
+  margin: 14px 0;
+  outline: 0;
+  border: 1px solid #ccc; /* Overriding background style for a text input (see
+                             below) resets its borders to a weird beveled style;
+                             defining a default 1px border solves the issue. */
+  border-radius: 2px;
+  height: 26px;
+  padding: 0 10px;
+  font-size: 1em;
+}
+
+.generate-url input.pending {
+  background-image: url(../img/loading-icon.gif);
+  background-repeat: no-repeat;
+  background-position: right;
+}
+
+.generate-url .button {
+  background-color: #0096dd;
+  border-color: #0096dd;
+  color: #fff;
+}
+
+.generate-url .button:hover {
+  background-color: #008acb;
+  border-color: #008acb;
+  color: #fff;
+}
+
+.terms-service {
+  color: #888;
+  text-align: center;
+  font-size: .9em;
+}
+
+.terms-service a {
+  color: #00caee;
 }
 
 /* DnD menu */
 
 .dnd-status {
   border: 1px solid transparent;
   padding: 2px 4px;
   font-size: .9em;
   cursor: pointer;
   border-radius: 3px;
 }
 
 .dnd-status:hover {
   border: 1px solid #DDD;
-  background: #F1F1F1;
+  background-color: #f1f1f1;
 }
 
 /* Status badges -- Available/Unavailable */
 
 .status {
   display: inline-block;
   width: 8px;
   height: 8px;
   margin: 0 5px;
   border-radius: 50%;
 }
 
 .status-available {
-  background: #6cb23e;
+  background-color: #6cb23e;
 }
 
 .status-dnd {
   border: 1px solid #888;
 }
 
 /* Sign in/up link */
 
@@ -232,24 +305,37 @@ body[dir=rtl] .dropdown-menu-item {
 .signin-link a {
   font-size: .9em;
   text-decoration: none;
   color: #888;
 }
 
 /* Settings (gear) menu */
 
-.btn-settings {
+.button-settings {
+  display: inline-block;
+  overflow: hidden;
+  margin: 0;
+  padding: 0;
+  border: none;
+  background-color: #a5a;
+  color: #fff;
+  text-align: center;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: .9em;
+  cursor: pointer;
   background: transparent url(../img/svg/glyph-settings-16x16.svg) no-repeat center center;
   background-size: contain;
   width: 12px;
   height: 12px;
 }
 
-.footer .btn-settings {
+.footer .button-settings {
   margin-top: 17px; /* used to align the gear icon with the availability dropdown menu inner text */
   opacity: .6;      /* used to "grey" the icon a little */
 }
 
 .settings-menu .dropdown-menu {
   /* The panel can't have dropdown menu overflowing its iframe boudaries;
      let's anchor it from the bottom-right, while resetting the top & left values
      set by .dropdown-menu */
@@ -278,39 +364,23 @@ body[dir=rtl] .dropdown-menu-item {
 .settings-menu .icon-signin {
   background: transparent url(../img/svg/glyph-signin-16x16.svg) no-repeat center center;
 }
 
 .settings-menu .icon-signout {
   background: transparent url(../img/svg/glyph-signout-16x16.svg) no-repeat center center;
 }
 
-/* Terms of Service */
-
-.terms-service {
-  padding: 3px 10px 10px;
-  background: #FFF;
-  text-align: center;
-  opacity: .5;
-  transition: opacity .3s;
-  font-family: 'Lucida Grande', sans-serif;
-  font-size: .9em;
-}
-
-.terms-service a {
-  color: #0095dd;
-}
-
 /* Footer */
 
 .footer {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
   justify-content: space-between;
   align-content: stretch;
   align-items: flex-start;
   font-size: 1em;
   border-top: 1px solid #D1D1D1;
-  background: #EAEAEA;
-  color: #7F7F7F;
+  background-color: #eaeaea;
+  color: #7f7f7f;
   padding: 14px;
 }
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -673,16 +673,74 @@ loop.shared.views = (function(_, OT, l10
             return NotificationView({key: key, notification: notification});
           })
         
         )
       );
     }
   });
 
+  var Button = React.createClass({displayName: 'Button',
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      onClick: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool,
+      additionalClass: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+      return {
+        disabled: false,
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { button: true, disabled: this.props.disabled };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        React.DOM.button({onClick: this.props.onClick, 
+                disabled: this.props.disabled, 
+                className: cx(classObject)}, 
+          this.props.caption
+        )
+      )
+    }
+  });
+
+  var ButtonGroup = React.createClass({displayName: 'ButtonGroup',
+    PropTypes: {
+      additionalClass: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { "button-group": true };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        React.DOM.div({className: cx(classObject)}, 
+          this.props.children
+        )
+      )
+    }
+  });
+
   return {
+    Button: Button,
+    ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
     NotificationListView: NotificationListView
   };
 })(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -673,16 +673,74 @@ loop.shared.views = (function(_, OT, l10
             return <NotificationView key={key} notification={notification}/>;
           })
         }
         </div>
       );
     }
   });
 
+  var Button = React.createClass({
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      onClick: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool,
+      additionalClass: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+      return {
+        disabled: false,
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { button: true, disabled: this.props.disabled };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        <button onClick={this.props.onClick}
+                disabled={this.props.disabled}
+                className={cx(classObject)}>
+          {this.props.caption}
+        </button>
+      )
+    }
+  });
+
+  var ButtonGroup = React.createClass({
+    PropTypes: {
+      additionalClass: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { "button-group": true };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        <div className={cx(classObject)}>
+          {this.props.children}
+        </div>
+      )
+    }
+  });
+
   return {
+    Button: Button,
+    ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
     NotificationListView: NotificationListView
   };
 })(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -353,35 +353,35 @@ describe("loop.panel", function() {
       it("should display a share button for email", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
         view.setState({pending: false, callUrl: "http://example.com"});
 
-        TestUtils.findRenderedDOMComponentWithClass(view, "btn-email");
-        TestUtils.Simulate.click(view.getDOMNode().querySelector(".btn-email"));
+        TestUtils.findRenderedDOMComponentWithClass(view, "button-email");
+        TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
         sinon.assert.calledOnce(navigator.mozLoop.composeEmail);
       });
 
       it("should feature a copy button capable of copying the call url when clicked", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
         view.setState({
           pending: false,
           copied: false,
           callUrl: "http://example.com",
           callUrlExpiry: 6000
         });
 
-        TestUtils.Simulate.click(view.getDOMNode().querySelector(".btn-copy"));
+        TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
 
         sinon.assert.calledOnce(navigator.mozLoop.copyString);
         sinon.assert.calledWithExactly(navigator.mozLoop.copyString,
           view.state.callUrl);
       });
 
       it("should note the call url expiry when the url is copied via button",
         function() {
@@ -391,17 +391,17 @@ describe("loop.panel", function() {
           }));
           view.setState({
             pending: false,
             copied: false,
             callUrl: "http://example.com",
             callUrlExpiry: 6000
           });
 
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".btn-copy"));
+          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
 
           sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
           sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
             6000);
         });
 
       it("should note the call url expiry when the url is emailed",
         function() {
@@ -411,17 +411,17 @@ describe("loop.panel", function() {
           }));
           view.setState({
             pending: false,
             copied: false,
             callUrl: "http://example.com",
             callUrlExpiry: 6000
           });
 
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".btn-email"));
+          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
 
           sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
           sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
             6000);
         });
 
       it("should note the call url expiry when the url is copied manually",
         function() {
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -42,16 +42,19 @@
     <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.
         loop.contacts = {
           ContactsList: React.createClass({render: function() {
             return React.DOM.div();
+          }}),
+          ContactDetailsForm: React.createClass({render: function() {
+            return React.DOM.div();
           }})
         };
       }
     </script>
     <script src="../content/js/panel.js"></script>
     <script src="../content/js/conversation.js"></script>
     <script src="ui-showcase.js"></script>
   </body>