Bug 1000766: add a contacts list to the Loop contacts tab. r=Niko,Standard8,paolo
authorMike de Boer <mdeboer@mozilla.com>
Fri, 19 Sep 2014 17:01:58 +0200
changeset 218083 456fc60aa93624ac5ca4c35162d1a7f1f15f58d8
parent 218082 0e6824c0d7172497cf30d6cdfcfd8adbe2725e92
child 218084 abcc867400dcc422cf97e9bd170cfa5ee05fac29
push id7029
push userrjesup@wgate.com
push dateSun, 05 Oct 2014 07:07:51 +0000
treeherdermozilla-aurora@701fcc5fcce9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersNiko, Standard8, paolo
bugs1000766
milestone34.0a2
Bug 1000766: add a contacts list to the Loop contacts tab. r=Niko,Standard8,paolo
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/panel.html
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/img/icons-10x10.svg
browser/components/loop/content/shared/img/icons-14x14.svg
browser/components/loop/content/shared/img/icons-16x16.svg
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/desktop-local/panel_test.js
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.js
@@ -0,0 +1,190 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  // 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
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        React.DOM.li({onClick: this.handleContactClick, className: contactCSSClass}, 
+          React.DOM.div({className: "avatar"}), 
+          React.DOM.div({className: "details"}, 
+            React.DOM.div({className: "username"}, React.DOM.strong(null, names.firstName), " ", names.lastName, 
+              React.DOM.i({className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
+              React.DOM.i({className: cx({"icon icon-blocked": this.props.contact.blocked})})
+            ), 
+            React.DOM.div({className: "email"}, email.value)
+          ), 
+          React.DOM.div({className: "icons"}, 
+            React.DOM.i({className: "icon icon-video"}), 
+            React.DOM.i({className: "icon icon-caret-down"})
+          )
+        )
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({displayName: 'ContactsList',
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // Add contacts already present in the DB. We do this in timed chunks to
+        // circumvent blocking the main event loop.
+        let addContactsInChunks = () => {
+          contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
+            this.handleContactAddOrUpdate(contact);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    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 make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return ContactDetail({key: item._guid, contact: item})
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      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 ?
+                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+                  null
+              )
+            )
+          )
+        )
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList
+  };
+})(_, document.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -0,0 +1,190 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  // 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
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        <li onClick={this.handleContactClick} className={contactCSSClass}>
+          <div className="avatar" />
+          <div className="details">
+            <div className="username"><strong>{names.firstName}</strong> {names.lastName}
+              <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
+              <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
+            </div>
+            <div className="email">{email.value}</div>
+          </div>
+          <div className="icons">
+            <i className="icon icon-video" />
+            <i className="icon icon-caret-down" />
+          </div>
+        </li>
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // Add contacts already present in the DB. We do this in timed chunks to
+        // circumvent blocking the main event loop.
+        let addContactsInChunks = () => {
+          contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
+            this.handleContactAddOrUpdate(contact);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    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 make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return <ContactDetail key={item._guid} contact={item} />
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      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 ?
+                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+                  null}
+              </ul>
+            </div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList
+  };
+})(_, document.mozL10n);
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -9,16 +9,17 @@
 
 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 ContactsList = loop.contacts.ContactsList;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   /**
    * Panel router.
    * @type {loop.desktopRouter.DesktopRouter}
    */
   var router;
 
@@ -493,17 +494,17 @@ loop.panel = (function(_, mozL10n) {
           TabView({onSelect: this.selectTab}, 
             Tab({name: "call"}, 
               CallUrlResult({client: this.props.client, 
                              notifications: this.props.notifications, 
                              callUrl: this.props.callUrl}), 
               ToSView(null)
             ), 
             Tab({name: "contacts"}, 
-              React.DOM.span(null, "contacts")
+              ContactsList(null)
             )
           ), 
           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,16 +9,17 @@
 
 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 ContactsList = loop.contacts.ContactsList;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   /**
    * Panel router.
    * @type {loop.desktopRouter.DesktopRouter}
    */
   var router;
 
@@ -493,17 +494,17 @@ loop.panel = (function(_, mozL10n) {
           <TabView onSelect={this.selectTab}>
             <Tab name="call">
               <CallUrlResult client={this.props.client}
                              notifications={this.props.notifications}
                              callUrl={this.props.callUrl} />
               <ToSView />
             </Tab>
             <Tab name="contacts">
-              <span>contacts</span>
+              <ContactsList />
             </Tab>
           </TabView>
           <div className="footer">
             <div className="user-details">
               <UserIdentity displayName={displayName} />
               <AvailabilityDropdown />
             </div>
             <AuthLink />
--- a/browser/components/loop/content/panel.html
+++ b/browser/components/loop/content/panel.html
@@ -4,27 +4,29 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/.  -->
 <html>
   <head>
     <meta charset="utf-8">
     <title>Loop Panel</title>
     <link rel="stylesheet" type="text/css" href="loop/shared/css/reset.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/common.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/panel.css">
+    <link rel="stylesheet" type="text/css" href="loop/shared/css/contacts.css">
   </head>
   <body class="panel">
 
     <div id="main"></div>
 
     <script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
     <script type="text/javascript" src="loop/libs/l10n.js"></script>
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
+    <script type="text/javascript;version=1.8" src="loop/js/contacts.js"></script>
     <script type="text/javascript" src="loop/js/panel.js"></script>
  </body>
 </html>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -390,8 +390,20 @@ 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-bottom: 1px solid #CCC;
+  background: #EEE;
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+  height: 24px;
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -0,0 +1,163 @@
+/* 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 {
+  display: flex;
+  flex-direction: row;
+  position: relative;
+  padding: 5px 10px;
+  color: #666;
+  font-size: 13px;
+  align-items: center;
+}
+
+.contact:not(:first-child) {
+  border-top: 1px solid #ddd;
+}
+
+.contact.blocked > .details > .username {
+  color: #d74345;
+}
+
+.contact:hover {
+  background: #eee;
+}
+
+.contact.selected {
+  background: #ebebeb;
+}
+
+.contact:hover > .icons {
+  display: block;
+  z-index: 1000;
+}
+
+.contact > .avatar {
+  width: 40px;
+  height: 40px;
+  background: #ccc;
+  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;
+}
+
+.contact > .avatar > img {
+  width: 100%;
+}
+
+.contact > .details > .username {
+  font-size: 12px;
+  line-height: 20px;
+  color: #222;
+  font-weight: normal;
+}
+
+.contact > .details > .username > strong {
+  font-weight: bold;
+}
+
+.contact > .details > .username > i.icon-blocked {
+  display: inline-block;
+  width: 10px;
+  height: 20px;
+  -moz-margin-start: 3px;
+  background-image: url("../img/icons-16x16.svg#block-red");
+  background-position: center;
+  background-size: 10px 10px;
+  background-repeat: no-repeat;
+}
+
+.contact > .details > .username > i.icon-google {
+  position: absolute;
+  right: 10px;
+  top: 35%;
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  background-image: url("../img/icons-16x16.svg#google");
+  background-position: center;
+  background-size: 16px 16px;
+  background-repeat: no-repeat;
+  background-color: fff;
+}
+
+.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;
+}
+
+.icons:hover {
+  background: #89e029;
+}
+
+.icons i {
+  margin: 0 5px;
+  display: inline-block;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.icons i.icon-video {
+  background-image: url("../img/icons-14x14.svg#video-white");
+  background-size: 14px 14px;
+  width: 16px;
+  height: 16px;
+}
+
+.icons i.icon-caret-down {
+  background-image: url("../img/icons-10x10.svg#dropdown-white");
+  background-size: 10px 10px;
+  width: 10px;
+  height: 16px;
+}
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -34,16 +34,17 @@
 .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[data-tab-name="call"] {
   background-image: url("../img/icons-16x16.svg#precall");
 }
@@ -77,16 +78,17 @@
 }
 
 .tab.selected {
   display: block;
 }
 
 .share {
   background: #fbfbfb;
+  margin-bottom: 14px;
 }
 
 .share .description,
 .share .action input,
 .share > .action > .invite > .url-actions {
   margin: 14px 14px 0 14px;
 }
 
@@ -308,10 +310,9 @@ body[dir=rtl] .dropdown-menu-item {
   justify-content: space-between;
   align-content: stretch;
   align-items: flex-start;
   font-size: 1em;
   border-top: 1px solid #D1D1D1;
   background: #EAEAEA;
   color: #7F7F7F;
   padding: 14px;
-  margin-top: 14px;
 }
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-10x10.svg
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 10 10"
+     enable-background="new 0 0 10 10"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: rgba(255, 255, 255, 0.8);
+}
+</style>
+<defs style="display:none">
+  <polygon id="close-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668 
+    3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
+  <path id="dropdown-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
+  <polygon id="expand-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494 
+    10,5.162"/>
+  <rect id="minimize-shape" y="3.6" fill-rule="evenodd" clip-rule="evenodd" width="10" height="2.8"/>
+</defs>
+<use id="close"               xlink:href="#close-shape"/>
+<use id="close-active"        xlink:href="#close-shape"/>
+<use id="close-disabled"      xlink:href="#close-shape"/>
+<use id="dropdown"            xlink:href="#dropdown-shape"/>
+<use id="dropdown-white"      xlink:href="#dropdown-shape"/>
+<use id="dropdown-active"     xlink:href="#dropdown-shape"/>
+<use id="dropdown-disabled"   xlink:href="#dropdown-shape"/>
+<use id="expand"              xlink:href="#expand-shape"/>
+<use id="expand-active"       xlink:href="#expand-shape"/>
+<use id="expand-disabled"     xlink:href="#expand-shape"/>
+<use id="minimize"            xlink:href="#minimize-shape"/>
+<use id="minimize-active"     xlink:href="#minimize-shape"/>
+<use id="minimize-disabled"   xlink:href="#minimize-shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-14x14.svg
@@ -0,0 +1,134 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 14 14"
+     enable-background="new 0 0 14 14"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: #fff;
+}
+</style>
+<defs style="display:none">
+  <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9.571,6.143v1.714c0,1.42-1.151,2.571-2.571,2.571
+    c-1.42,0-2.571-1.151-2.571-2.571V6.143H3.571v1.714c0,1.597,1.093,2.935,2.571,3.316v0.97H5.714c-0.56,0-1.034,0.358-1.211,0.857
+    h4.993c-0.177-0.499-0.651-0.857-1.211-0.857H7.857v-0.97c1.478-0.381,2.571-1.719,2.571-3.316V6.143H9.571z M7,10
+    c1.183,0,2.143-0.959,2.143-2.143V3.143C9.143,1.959,8.183,1,7,1C5.817,1,4.857,1.959,4.857,3.143v4.714C4.857,9.041,5.817,10,7,10
+    z"/>
+  <g id="facemute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.174,3.551L9.568,5.856V5.847L3.39,11.49h5.066
+      c0.613,0,1.111-0.533,1.111-1.19V8.526l2.606,2.304C12.4,11.071,12.71,11.142,13,11.078V3.302C12.71,3.239,12.4,3.309,12.174,3.551
+      z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.395,2.617l-0.001-0.001l-0.809-0.884l-2.102,1.92
+      C9.316,3.221,8.919,2.918,8.457,2.918H2.111C1.498,2.918,1,3.451,1,4.109v6.191c0,0.318,0.118,0.607,0.306,0.821l-0.288,0.263
+      l0.809,0.884l0.001,0.001l0.853-0.779l6.887-6.29L12.395,2.617z"/>
+  </g>
+  <path id="hangup-shape" fill-rule="evenodd" clip-rule="evenodd" d="M13,11.732c-0.602,0.52-1.254,0.946-1.941,1.267
+    c-1.825-0.337-4.164-1.695-6.264-3.795C2.696,7.106,1.339,4.769,1,2.945c0.321-0.688,0.748-1.341,1.268-1.944l2.528,2.855
+    C4.579,4.153,4.377,4.454,4.209,4.759L4.22,4.77C3.924,5.42,4.608,6.833,5.889,8.114c1.281,1.28,2.694,1.965,3.343,1.669
+    l0.011,0.011c0.305-0.168,0.606-0.37,0.904-0.587L13,11.732z"/>
+  <path id="incoming-shape" fill-rule="evenodd" clip-rule="evenodd" d="M2.745,7.558l0.637,0.669c0.04,0.041,0.085,0.073,0.134,0.1
+    l3.249,3.313c0.38,0.393,0.915,0.478,1.197,0.186l0.638-0.676c0.281-0.292,0.2-0.848-0.18-1.244L7.097,8.558h3.566
+    c0.419,0,0.759-0.34,0.759-0.759V6.28c0-0.419-0.34-0.759-0.759-0.759H7.059l1.42-1.443c0.381-0.392,0.461-0.945,0.18-1.234
+    l-0.637-0.67C7.74,1.883,7.204,1.966,6.824,2.359L3.55,5.688C3.487,5.717,3.43,5.755,3.381,5.806L2.745,6.482
+    c-0.131,0.137-0.183,0.332-0.162,0.54C2.562,7.229,2.613,7.423,2.745,7.558z"/>
+  <path id="link-shape" fill-rule="evenodd" clip-rule="evenodd" d="M7.359,6.107c0.757-0.757,0.757-1.995,0-2.752
+    L5.573,1.568c-0.757-0.757-1.995-0.757-2.752,0L1.568,2.82c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0L6.266,7.2L6.8,7.734L6.641,7.893c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0l1.253-1.253c0.757-0.757,0.757-1.995,0-2.752l-1.787-1.787c-0.757-0.757-1.995-0.757-2.752,0
+    L7.734,6.8L7.2,6.266L7.359,6.107z M9.87,7.868l1.335,1.335c0.294,0.294,0.294,0.774,0,1.068l-0.934,0.934
+    c-0.294,0.294-0.774,0.294-1.068,0L7.868,9.87c-0.294-0.294-0.294-0.774,0-1.068L8.13,9.064c0.294,0.294,0.744,0.324,1.001,0.067
+    C9.388,8.874,9.358,8.424,9.064,8.13L8.802,7.868C9.096,7.574,9.577,7.574,9.87,7.868z M4.13,6.132L2.795,4.797
+    c-0.294-0.294-0.294-0.774,0-1.068l0.934-0.934c0.294-0.294,0.774-0.294,1.068,0L6.132,4.13c0.294,0.294,0.294,0.774,0,1.068
+    L5.86,4.926C5.567,4.632,5.116,4.602,4.859,4.859C4.602,5.116,4.632,5.567,4.926,5.86l0.272,0.272
+    C4.904,6.426,4.423,6.426,4.13,6.132z"/>
+  <g id="mute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M5.186,9.492L5.49,9.188l3.822-3.822l2.354-2.354l-0.848-0.848
+      L9.312,3.669V3.142C9.312,1.959,8.352,1,7.169,1C5.986,1,5.026,1.959,5.026,3.142v4.715c0,0.032,0.001,0.064,0.002,0.096
+      L4.643,8.338c-0.03-0.156-0.046-0.317-0.046-0.481V6.142H3.741v1.715c0,0.414,0.073,0.81,0.208,1.176l-1.615,1.615l0.848,0.848
+      l1.398-1.398v0L5.186,9.492z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.312,7.857V6.045L5.829,9.528C6.196,9.824,6.662,10,7.169,10
+      C8.352,10,9.312,9.04,9.312,7.857z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.741,7.857c0,1.42-1.151,2.572-2.572,2.572
+      c-0.625,0-1.199-0.223-1.645-0.595l-0.605,0.605c0.395,0.344,0.87,0.599,1.393,0.734v0.97H5.884c-0.56,0-1.034,0.359-1.212,0.858
+      h4.994c-0.178-0.499-0.652-0.858-1.212-0.858H8.026v-0.97c1.478-0.38,2.572-1.718,2.572-3.316V6.142H9.741V7.857z"/>
+  </g>
+  <path id="pause-shape" fill-rule="evenodd" clip-rule="evenodd" d="M4.75,1h-1.5C2.836,1,2.5,1.336,2.5,1.75v10.5
+    C2.5,12.664,2.836,13,3.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C5.5,1.336,5.164,1,4.75,1z M10.75,1h-1.5
+    C8.836,1,8.5,1.336,8.5,1.75v10.5C8.5,12.664,8.836,13,9.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C11.5,1.336,11.164,1,10.75,1
+    z"/>
+  <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M12.175,3.347L9.568,5.651V3.905c0-0.657-0.497-1.19-1.111-1.19
+    H2.111C1.498,2.714,1,3.247,1,3.905v6.191c0,0.658,0.498,1.19,1.111,1.19h6.345c0.614,0,1.111-0.533,1.111-1.19V8.322l2.607,2.305
+    C12.4,10.867,12.71,10.938,13,10.874V3.099C12.71,3.035,12.4,3.106,12.175,3.347z"/>
+  <g id="volume-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M3.513,4.404H1.896c-0.417,0-0.756,0.338-0.756,0.755v3.679
+      c0,0.417,0.338,0.755,0.756,0.755H3.51l2.575,2.575c0.261,0.261,0.596,0.4,0.938,0.422V1.409C6.682,1.431,6.346,1.57,6.085,1.831
+      L3.513,4.404z M8.555,5.995C8.619,6.32,8.653,6.656,8.653,7c0,0.344-0.034,0.679-0.098,1.004l0.218,0.142
+      C8.852,7.777,8.895,7.393,8.895,7c0-0.394-0.043-0.777-0.123-1.147L8.555,5.995z M12.224,3.6l-0.475,0.31
+      c0.359,0.962,0.557,2.003,0.557,3.09c0,1.087-0.198,2.128-0.557,3.09l0.475,0.31c0.41-1.054,0.635-2.201,0.635-3.4
+      C12.859,5.8,12.634,4.654,12.224,3.6z M10.061,5.012C10.25,5.642,10.353,6.308,10.353,7c0,0.691-0.103,1.358-0.293,1.987
+      l0.351,0.229C10.634,8.517,10.756,7.772,10.756,7c0-0.773-0.121-1.517-0.345-2.216L10.061,5.012z"/>
+    <path d="M7.164,12.74l-0.15-0.009c-0.389-0.024-0.754-0.189-1.028-0.463L3.452,9.735H1.896
+      C1.402,9.735,1,9.333,1,8.838V5.16c0-0.494,0.402-0.896,0.896-0.896h1.558l2.531-2.531C6.26,1.458,6.625,1.293,7.014,1.269
+      l0.15-0.009V12.74z M1.896,4.545c-0.339,0-0.615,0.276-0.615,0.615v3.679c0,0.339,0.276,0.615,0.615,0.615h1.672l2.616,2.616
+      c0.19,0.19,0.434,0.316,0.697,0.363V1.568C6.619,1.615,6.375,1.741,6.185,1.931L3.571,4.545H1.896z M12.292,10.612l-0.714-0.467
+      l0.039-0.105C11.981,9.067,12.165,8.044,12.165,7c0-1.044-0.184-2.067-0.548-3.041l-0.039-0.105l0.714-0.467l0.063,0.162
+      C12.783,4.649,13,5.81,13,7s-0.217,2.351-0.645,3.451L12.292,10.612z M11.92,10.033l0.234,0.153
+      c0.374-1.019,0.564-2.09,0.564-3.186s-0.19-2.167-0.564-3.186L11.92,3.966C12.27,4.94,12.447,5.96,12.447,7
+      C12.447,8.04,12.27,9.059,11.92,10.033z M10.489,9.435L9.895,9.047l0.031-0.101C10.116,8.315,10.212,7.66,10.212,7
+      c0-0.661-0.096-1.316-0.287-1.947L9.895,4.952l0.594-0.388l0.056,0.176C10.779,5.471,10.897,6.231,10.897,7
+      c0,0.769-0.118,1.529-0.351,2.259L10.489,9.435z M10.225,8.926l0.106,0.069C10.52,8.348,10.615,7.677,10.615,7
+      c0-0.677-0.095-1.348-0.284-1.996l-0.106,0.07C10.403,5.699,10.494,6.347,10.494,7C10.494,7.652,10.403,8.3,10.225,8.926z
+       M8.867,8.376L8.398,8.07l0.018-0.093C8.48,7.654,8.512,7.325,8.512,7S8.48,6.345,8.417,6.022L8.398,5.929l0.469-0.306l0.043,0.2
+      C8.994,6.211,9.036,6.607,9.036,7c0,0.393-0.042,0.789-0.126,1.176L8.867,8.376z"/>
+  </g>
+</defs>
+<use id="audio"               xlink:href="#audio-shape"/>
+<use id="audio-active"        xlink:href="#audio-shape"/>
+<use id="audio-disabled"      xlink:href="#audio-shape"/>
+<use id="facemute"            xlink:href="#facemute-shape"/>
+<use id="facemute-active"     xlink:href="#facemute-shape"/>
+<use id="facemute-disabled"   xlink:href="#facemute-shape"/>
+<use id="hangup"              xlink:href="#hangup-shape"/>
+<use id="hangup-active"       xlink:href="#hangup-shape"/>
+<use id="hangup-disabled"     xlink:href="#hangup-shape"/>
+<use id="incoming"            xlink:href="#incoming-shape"/>
+<use id="incoming-active"     xlink:href="#incoming-shape"/>
+<use id="incoming-disabled"   xlink:href="#incoming-shape"/>
+<use id="link"                xlink:href="#link-shape"/>
+<use id="link-active"         xlink:href="#link-shape"/>
+<use id="link-disabled"       xlink:href="#link-shape"/>
+<use id="mute"                xlink:href="#mute-shape"/>
+<use id="mute-active"         xlink:href="#mute-shape"/>
+<use id="mute-disabled"       xlink:href="#mute-shape"/>
+<use id="pause"               xlink:href="#pause-shape"/>
+<use id="pause-active"        xlink:href="#pause-shape"/>
+<use id="pause-disabled"      xlink:href="#pause-shape"/>
+<use id="video"               xlink:href="#video-shape"/>
+<use id="video-white"         xlink:href="#video-shape"/>
+<use id="video-active"        xlink:href="#video-shape"/>
+<use id="video-disabled"      xlink:href="#video-shape"/>
+<use id="volume"              xlink:href="#volume-shape"/>
+<use id="volume-active"       xlink:href="#volume-shape"/>
+<use id="volume-disabled"     xlink:href="#volume-shape"/>
+</svg>
--- a/browser/components/loop/content/shared/img/icons-16x16.svg
+++ b/browser/components/loop/content/shared/img/icons-16x16.svg
@@ -19,16 +19,20 @@ use {
 
 use[id$="-hover"] {
   fill: #444;
 }
 
 use[id$="-active"] {
   fill: #0095dd;
 }
+
+use[id$="-red"] {
+  fill: #d74345
+}
 </style>
 <defs style="display:none">
   <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M11.429,6.857v2.286c0,1.894-1.535,3.429-3.429,3.429
     c-1.894,0-3.429-1.535-3.429-3.429V6.857H3.429v2.286c0,2.129,1.458,3.913,3.429,4.422v1.293H6.286
     c-0.746,0-1.379,0.477-1.615,1.143h6.658c-0.236-0.665-0.869-1.143-1.615-1.143H9.143v-1.293c1.971-0.508,3.429-2.292,3.429-4.422
     V6.857H11.429z M8,12c1.578,0,2.857-1.279,2.857-2.857V2.857C10.857,1.279,9.578,0,8,0C6.422,0,5.143,1.279,5.143,2.857v6.286
     C5.143,10.721,6.422,12,8,12z"/>
   <path id="block-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,0C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8
@@ -83,16 +87,17 @@ use[id$="-active"] {
   <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M14.9,3.129l-3.476,3.073V3.873c0-0.877-0.663-1.587-1.482-1.587
     H1.482C0.663,2.286,0,2.996,0,3.873v8.254c0,0.877,0.663,1.587,1.482,1.587h8.461c0.818,0,1.482-0.711,1.482-1.587V9.762
     l3.476,3.073c0.3,0.321,0.714,0.416,1.1,0.331V2.798C15.614,2.713,15.2,2.808,14.9,3.129z"/>
 </defs>
 <use id="audio"               xlink:href="#audio-shape"/>
 <use id="audio-hover"         xlink:href="#audio-shape"/>
 <use id="audio-active"        xlink:href="#audio-shape"/>
 <use id="block"               xlink:href="#block-shape"/>
+<use id="block-red"           xlink:href="#block-shape"/>
 <use id="block-hover"         xlink:href="#block-shape"/>
 <use id="block-active"        xlink:href="#block-shape"/>
 <use id="contacts"            xlink:href="#contacts-shape"/>
 <use id="contacts-hover"      xlink:href="#contacts-shape"/>
 <use id="contacts-active"     xlink:href="#contacts-shape"/>
 <use id="google"              xlink:href="#google-shape"/>
 <use id="google-hover"        xlink:href="#google-shape"/>
 <use id="google-active"       xlink:href="#google-shape"/>
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -11,22 +11,24 @@ browser.jar:
   content/browser/loop/libs/l10n.js                 (content/libs/l10n.js)
 
   # Desktop script
   content/browser/loop/js/client.js                 (content/js/client.js)
   content/browser/loop/js/desktopRouter.js          (content/js/desktopRouter.js)
   content/browser/loop/js/conversation.js           (content/js/conversation.js)
   content/browser/loop/js/otconfig.js               (content/js/otconfig.js)
   content/browser/loop/js/panel.js                  (content/js/panel.js)
+  content/browser/loop/js/contacts.js               (content/js/contacts.js)
 
   # Shared styles
   content/browser/loop/shared/css/reset.css         (content/shared/css/reset.css)
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/panel.css         (content/shared/css/panel.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
+  content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
   # Shared images
   content/browser/loop/shared/img/happy.png                     (content/shared/img/happy.png)
   content/browser/loop/shared/img/sad.png                       (content/shared/img/sad.png)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
   content/browser/loop/shared/img/loading-icon.gif              (content/shared/img/loading-icon.gif)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
@@ -41,16 +43,18 @@ browser.jar:
   content/browser/loop/shared/img/video-inverse-14x14@2x.png    (content/shared/img/video-inverse-14x14@2x.png)
   content/browser/loop/shared/img/dropdown-inverse.png          (content/shared/img/dropdown-inverse.png)
   content/browser/loop/shared/img/dropdown-inverse@2x.png       (content/shared/img/dropdown-inverse@2x.png)
   content/browser/loop/shared/img/svg/glyph-settings-16x16.svg  (content/shared/img/svg/glyph-settings-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-account-16x16.svg   (content/shared/img/svg/glyph-account-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signin-16x16.svg    (content/shared/img/svg/glyph-signin-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signout-16x16.svg   (content/shared/img/svg/glyph-signout-16x16.svg)
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
+  content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
+  content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
 
   # Shared scripts
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/router.js            (content/shared/js/router.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -37,16 +37,17 @@
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/router.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/desktopRouter.js"></script>
   <script src="../../content/js/conversation.js"></script>
+  <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
   <script src="panel_test.js"></script>
   <script>
     // Stop the default init functions running to avoid conflicts in tests
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -37,17 +37,23 @@ describe("loop.panel", function() {
       },
       get locale() {
         return "en-US";
       },
       setLoopCharPref: sandbox.stub(),
       getLoopCharPref: sandbox.stub().returns("unseen"),
       copyString: sandbox.stub(),
       noteCallUrlExpiry: sinon.spy(),
-      composeEmail: sinon.spy()
+      composeEmail: sinon.spy(),
+      contacts: {
+        getAll: function(callback) {
+          callback(null, []);
+        },
+        on: sandbox.stub()
+      }
     };
 
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     sandbox.restore();