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 206251 4b348553c600121d923c7c1496c72853daf41be0
parent 206244 014f231ac17308d76631dbc5d59882070549e60e
child 206252 b26c709330d601cd9d66ad110b5f3de4ed5e111f
push id27519
push userkwierso@gmail.com
push dateFri, 19 Sep 2014 23:56:39 +0000
treeherdermozilla-central@a85324dfc960 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersNiko, Standard8, paolo
bugs1000766
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 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();