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 207463 a7e7c1333d689171dbe97e39a2c251da8a44a985
parent 207462 e16c4c4eb07a99753935057ff91d9a8966d9d613
child 207464 c4c40e8e2691b81e5e89306d9f0ccc41dce0a86e
push id27555
push userryanvm@gmail.com
push dateFri, 26 Sep 2014 20:30:28 +0000
treeherderautoland@4ff52be673f6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1000112
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 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>