Merge changesets for loop uplift
authorRandell Jesup <rjesup@jesup.org>
Fri, 10 Oct 2014 00:25:05 -0400
changeset 225596 e90f9d476c1f403fe474c1c2df54d5e41eea4d63
parent 225524 72c13d8631ff8b34369ef1f7fa15a643269cbef2 (current diff)
parent 225595 f0b953a9d3a56e256678c618795e63b7a1246d5a (diff)
child 225597 6dfc2524085b9f645e4e2c0f41d38e748b032f6a
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone34.0a2
Merge changesets for loop uplift
browser/app/profile/firefox.js
browser/components/loop/test/xpcshell/xpcshell.ini
configure.in
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1594,18 +1594,28 @@ pref("loop.legal.ToS_url", "https://acco
 pref("loop.legal.privacy_url", "https://www.mozilla.org/privacy/");
 pref("loop.do_not_disturb", false);
 pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
 pref("loop.retry_delay.start", 60000);
 pref("loop.retry_delay.limit", 300000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
+pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
+#ifdef DEBUG
+pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*");
+#else
+pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net");
+#endif
+pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
+pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
+pref("loop.fxa_oauth.tokendata", "");
+pref("loop.fxa_oauth.profile", "");
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 pref("dom.identity.enabled", false);
 
--- a/browser/base/content/test/general/browser_devices_get_user_media_about_urls.js
+++ b/browser/base/content/test/general/browser_devices_get_user_media_about_urls.js
@@ -7,16 +7,17 @@ const kObservedTopics = [
   "getUserMedia:revoke",
   "getUserMedia:response:deny",
   "getUserMedia:request",
   "recording-device-events",
   "recording-window-ended"
 ];
 
 const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+const PREF_LOOP_CSP = "loop.CSP";
 
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService",
                                    "@mozilla.org/mediaManagerService;1",
                                    "nsIMediaManagerService");
 
 var gTab;
@@ -157,30 +158,34 @@ fakeLoopAboutModule.prototype = {
            Ci.nsIAboutModule.ALLOW_SCRIPT |
            Ci.nsIAboutModule.HIDE_FROM_ABOUTABOUT;
   }
 };
 
 let factory = XPCOMUtils._getFactory(fakeLoopAboutModule);
 let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
 
+let originalLoopCsp = Services.prefs.getCharPref(PREF_LOOP_CSP);
 registerCleanupFunction(function() {
   gBrowser.removeCurrentTab();
   kObservedTopics.forEach(topic => {
     Services.obs.removeObserver(observer, topic);
   });
   Services.prefs.clearUserPref(PREF_PERMISSION_FAKE);
+  Services.prefs.setCharPref(PREF_LOOP_CSP, originalLoopCsp);
 });
 
 
 let gTests = [
 
 {
   desc: "getUserMedia about:loopconversation shouldn't prompt",
   run: function checkAudioVideoLoop() {
+    Services.prefs.setCharPref(PREF_LOOP_CSP, "default-src 'unsafe-inline'");
+
     let classID = Cc["@mozilla.org/uuid-generator;1"]
                     .getService(Ci.nsIUUIDGenerator).generateUUID();
     registrar.registerFactory(classID, "",
                               "@mozilla.org/network/protocol/about;1?what=loopconversation",
                               factory);
 
     yield loadPage("about:loopconversation");
 
@@ -193,16 +198,17 @@ let gTests = [
     yield promisePopupNotification("webRTC-sharingDevices");
 
     is(getMediaCaptureState(), "CameraAndMicrophone",
        "expected camera and microphone to be shared");
 
     yield closeStream();
 
     registrar.unregisterFactory(classID, factory);
+    Services.prefs.setCharPref(PREF_LOOP_CSP, originalLoopCsp);
   }
 },
 
 {
   desc: "getUserMedia about:evil should prompt",
   run: function checkAudioVideoNonLoop() {
     let classID = Cc["@mozilla.org/uuid-generator;1"]
                     .getService(Ci.nsIUUIDGenerator).generateUUID();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/GoogleImporter.jsm
@@ -0,0 +1,543 @@
+/* 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/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Log",
+                                  "resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["GoogleImporter"];
+
+let log = Log.repository.getLogger("Loop.Importer.Google");
+log.level = Log.Level.Debug;
+log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+
+/**
+ * Helper function that reads and maps the respective node value from specific
+ * XML DOMNodes to fields on a `target` object.
+ * Example: the value for field 'fullName' can be read from the XML DOMNode
+ *          'name', so that's the mapping we need to make; get the nodeValue of
+ *          the node called 'name' and tack it to the target objects' 'fullName'
+ *          property.
+ *
+ * @param {Map}        fieldMap    Map object containing the field name -> node
+ *                                 name mapping
+ * @param {XMLDOMNode} node        DOM node to fetch the values from for each field
+ * @param {String}     ns XML      namespace for the DOM nodes to retrieve. Optional.
+ * @param {Object}     target      Object to store the values found. Optional.
+ *                                 Defaults to a new object.
+ * @param {Boolean}    wrapInArray Indicates whether to map the field values in
+ *                                 an Array. Optional. Defaults to `false`.
+ * @returns The `target` object with the node values mapped to the appropriate fields.
+ */
+const extractFieldsFromNode = function(fieldMap, node, ns = null, target = {}, wrapInArray = false) {
+  for (let [field, nodeName] of fieldMap) {
+    let nodeList = ns ? node.getElementsByTagNameNS(ns, nodeName) :
+                        node.getElementsByTagName(nodeName);
+    if (nodeList.length) {
+      if (!nodeList[0].firstChild) {
+        continue;
+      }
+      let value = nodeList[0].firstChild.nodeValue;
+      target[field] = wrapInArray ? [value] : value;
+    }
+  }
+  return target;
+};
+
+/**
+ * Helper function that reads the type of (email-)address or phone number from an
+ * XMLDOMNode.
+ *
+ * @param {XMLDOMNode} node
+ * @returns String that depicts the type of field value.
+ */
+const getFieldType = function(node) {
+  if (node.hasAttribute("rel")) {
+    let rel = node.getAttribute("rel");
+    // The 'rel' attribute is formatted like: http://schemas.google.com/g/2005#work.
+    return rel.substr(rel.lastIndexOf("#") + 1);
+  }
+  if (node.hasAttribute("label")) {
+    return node.getAttribute("label");
+  }
+  return "other";
+};
+
+/**
+ * Fetch the preferred entry of a contact. Returns the first entry when no
+ * preferred flag is set.
+ *
+ * @param {Object} contact The contact object to check for preferred entries
+ * @param {String} which   Type of entry to check. Optional, defaults to 'email'
+ * @throws An Error when no (preferred) entries are listed for this contact.
+ */
+const getPreferred = function(contact, which = "email") {
+  if (!(which in contact) || !contact[which].length) {
+    throw new Error("No " + which + " entry available.");
+  }
+  let preferred = contact[which][0];
+  contact[which].some(function(entry) {
+    if (entry.pref) {
+      preferred = entry;
+      return true;
+    }
+    return false;
+  });
+  return preferred;
+};
+
+/**
+ * Fetch an auth token (clientID or client secret), which may be overridden by
+ * a pref if it's set.
+ *
+ * @param {String}  paramValue Initial, default, value of the parameter
+ * @param {String}  prefName   Fully qualified name of the pref to check for
+ * @param {Boolean} encode     Whether to URLEncode the param string
+ */
+const getUrlParam = function(paramValue, prefName, encode = true) {
+  if (Services.prefs.getPrefType(prefName))
+    paramValue = Services.prefs.getCharPref(prefName);
+  paramValue = Services.urlFormatter.formatURL(paramValue);
+
+  return encode ? encodeURIComponent(paramValue) : paramValue;
+};
+
+let gAuthWindow, gProfileId;
+const kAuthWindowSize = {
+  width: 420,
+  height: 460
+};
+const kContactsMaxResults = 10000000;
+const kContactsChunkSize = 100;
+const kTitlebarPollTimeout = 200;
+const kNS_GD = "http://schemas.google.com/g/2005";
+
+/**
+ * GoogleImporter class.
+ *
+ * Main entrypoint is the `startImport` method which calls several tasks necessary
+ * to import contacts from Google.
+ * Authentication is performed using an OAuth strategy which is loaded in a popup
+ * window.
+ */
+this.GoogleImporter = function() {};
+
+this.GoogleImporter.prototype = {
+  /**
+   * Start the import process of contacts from the Google service, using its Contacts
+   * API - https://developers.google.com/google-apps/contacts/v3/.
+   * The import consists of four tasks:
+   * 1. Get the authentication code which can be used to retrieve an OAuth token
+   *    pair. This is the bulk of the authentication flow that will be handled in
+   *    a popup window by Google. The user will need to login to the Google service
+   *    with his or her account and grant permission to our app to manage their
+   *    contacts.
+   * 2. Get the tokenset from the Google service, using the authentication code
+   *    that was retrieved in task 1.
+   * 3. Fetch all the contacts from the Google service, using the OAuth tokenset
+   *    that was retrieved in task 2.
+   * 4. Process the contacts, map them to the MozContact format and store each
+   *    contact in the database, if it doesn't exist yet.
+   *
+   * @param {Object}       options   Options to control the behavior of the import.
+   *                                 Not used by this importer class.
+   * @param {Function}     callback  Function to invoke when the import process
+   *                                 is done or when an error occurs that halts
+   *                                 the import process. The first argument passed
+   *                                 in an Error object or `null` and the second
+   *                                 argument is an object with import statistics.
+   * @param {LoopContacts} db        Instance of the LoopContacts database object,
+   *                                 which will store the newly found contacts
+   * @param {nsIDomWindow} windowRef Reference to the ChromeWindow the import is
+   *                                 invoked from. It will be used to be able to
+   *                                 open a window for the OAuth process with chrome
+   *                                 privileges.
+   */
+  startImport: function(options, callback, db, windowRef) {
+    Task.spawn(function* () {
+      let code = yield this._promiseAuthCode(windowRef);
+      let tokenSet = yield this._promiseTokenSet(code);
+      let contactEntries = yield this._promiseContactEntries(tokenSet);
+      let {total, success, ids} = yield this._processContacts(contactEntries, db);
+      yield this._purgeContacts(ids, db);
+
+      return {
+        total: total,
+        success: success
+      };
+    }.bind(this)).then(stats => callback(null, stats),
+                       error => callback(error))
+                 .then(null, ex => log.error(ex.fileName + ":" + ex.lineNumber + ": " + ex.message));
+  },
+
+  /**
+   * Task that yields an authentication code that is returned after the user signs
+   * in to the Google service. This code can be used by this class to retrieve an
+   * OAuth tokenset.
+   *
+   * @param {nsIDOMWindow} windowRef Reference to the ChromeWindow the import is
+   *                                 invoked from. It will be used to be able to
+   *                                 open a window for the OAuth process with chrome
+   *                                 privileges.
+   * @throws An `Error` object when authentication fails, or the authentication
+   *         code as a String.
+   */
+  _promiseAuthCode: Task.async(function* (windowRef) {
+    // Close a window that got lost in a previous login attempt.
+    if (gAuthWindow && !gAuthWindow.closed) {
+      gAuthWindow.close();
+      gAuthWindow = null;
+    }
+
+    let url = getUrlParam("https://accounts.google.com/o/oauth2/",
+                          "loop.oauth.google.URL", false) +
+              "auth?response_type=code&client_id=" +
+              getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%", "loop.oauth.google.clientIdOverride");
+    for (let param of ["redirect_uri", "scope"]) {
+      url += "&" + param + "=" + encodeURIComponent(
+             Services.prefs.getCharPref("loop.oauth.google." + param));
+    }
+    const features = "centerscreen,resizable=yes,toolbar=no,menubar=no,status=no,directories=no," +
+                     "width=" + kAuthWindowSize.width + ",height=" + kAuthWindowSize.height;
+    gAuthWindow = windowRef.openDialog(windowRef.getBrowserURL(), "_blank", features, url);
+    gAuthWindow.focus();
+
+    let code;
+    // The following loops runs as long as the OAuth windows' titlebar doesn't
+    // yield a response from the Google service. If an error occurs, the loop
+    // will terminate early.
+    while (!code) {
+      if (!gAuthWindow || gAuthWindow.closed) {
+        throw new Error("Popup window was closed before authentication succeeded");
+      }
+
+      let matches = gAuthWindow.document.title.match(/(error|code)=(.*)$/);
+      if (matches && matches.length) {
+        let [, type, message] = matches;
+        gAuthWindow.close();
+        gAuthWindow = null;
+        if (type == "error") {
+          throw new Error("Google authentication failed with error: " + message.trim());
+        } else if (type == "code") {
+          code = message.trim();
+        } else {
+          throw new Error("Unknown response from Google");
+        }
+      } else {
+        yield new Promise(resolve => setTimeout(resolve, kTitlebarPollTimeout));
+      }
+    }
+
+    return code;
+  }),
+
+  /**
+   * Fetch an OAuth tokenset, that will be used to authenticate Google API calls,
+   * using the authentication token retrieved in `_promiseAuthCode`.
+   *
+   * @param {String} code The authentication code.
+   * @returns an `Error` object upon failure or an object containing OAuth tokens.
+   */
+  _promiseTokenSet: function(code) {
+    return new Promise(function(resolve, reject) {
+      let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                      .createInstance(Ci.nsIXMLHttpRequest);
+
+      request.open("POST", getUrlParam("https://accounts.google.com/o/oauth2/",
+                                       "loop.oauth.google.URL",
+                                       false) + "token");
+
+      request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+
+      request.onload = function() {
+        if (request.status < 400) {
+          let tokenSet = JSON.parse(request.responseText);
+          tokenSet.date = Date.now();
+          resolve(tokenSet);
+        } else {
+          reject(new Error(request.status + " " + request.statusText));
+        }
+      };
+
+      request.onerror = function(error) {
+        reject(error);
+      };
+
+      let body = "grant_type=authorization_code&code=" + encodeURIComponent(code) +
+                 "&client_id=" + getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%",
+                                             "loop.oauth.google.clientIdOverride") +
+                 "&client_secret=" + getUrlParam("%GOOGLE_OAUTH_API_KEY%",
+                                                 "loop.oauth.google.clientSecretOverride") +
+                 "&redirect_uri=" + encodeURIComponent(Services.prefs.getCharPref(
+                                                       "loop.oauth.google.redirect_uri"));
+
+      request.send(body);
+    });
+  },
+
+  /**
+   * Fetches all the contacts in a users' address book.
+   *
+   * @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contacts
+   *
+   * @param {Object} tokenSet OAuth tokenset used to authenticate the request
+   * @returns An `Error` object upon failure or an Array of contact XML nodes.
+   */
+  _promiseContactEntries: function(tokenSet) {
+    return new Promise(function(resolve, reject) {
+      let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                      .createInstance(Ci.nsIXMLHttpRequest);
+
+      request.open("GET", getUrlParam("https://www.google.com/m8/feeds/contacts/default/full",
+                                      "loop.oauth.google.getContactsURL",
+                                      false) + "?max-results=" + kContactsMaxResults);
+
+      request.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
+      request.setRequestHeader("GData-Version", "3.0");
+      request.setRequestHeader("Authorization", "Bearer " + tokenSet.access_token);
+
+      request.onload = function() {
+        if (request.status < 400) {
+          let doc = request.responseXML;
+          // First get the profile id.
+          let currNode = doc.documentElement.firstChild;
+          while (currNode) {
+            if (currNode.nodeType == 1 && currNode.localName == "id") {
+              gProfileId = currNode.firstChild.nodeValue;
+              break;
+            }
+            currNode = currNode.nextSibling;
+          }
+
+          // Then kick of the importing of contact entries.
+          let entries = Array.prototype.slice.call(doc.querySelectorAll("entry"));
+          resolve(entries);
+        } else {
+          reject(new Error(request.status + " " + request.statusText));
+        }
+      };
+
+      request.onerror = function(error) {
+        reject(error);
+      }
+
+      request.send();
+    });
+  },
+
+  /**
+   * Process the contact XML nodes that Google provides, convert them to the MozContact
+   * format, check if the contact already exists in the database and when it doesn't,
+   * store it permanently.
+   * During this process statistics are collected about the amount of successful
+   * imports. The consumer of this class may use these statistics to inform the
+   * user.
+   *
+   * @param {Array}        contactEntries List of XML DOMNodes contact entries.
+   * @param {LoopContacts} db             Instance of the LoopContacts database
+   *                                      object, which will store the newly found
+   *                                      contacts.
+   * @returns An `Error` object upon failure or an Object with statistics in the
+   *          following format: `{ total: 25, success: 13, ids: {} }`.
+   */
+  _processContacts: Task.async(function* (contactEntries, db) {
+    let stats = {
+      total: contactEntries.length,
+      success: 0,
+      ids: {}
+    };
+
+    for (let entry of contactEntries) {
+      let contact = this._processContactFields(entry);
+
+      stats.ids[contact.id] = 1;
+      let existing = yield db.promise("getByServiceId", contact.id);
+      if (existing) {
+        yield db.promise("remove", existing._guid);
+      }
+
+      // If the contact contains neither email nor phone number, then it is not
+      // useful in the Loop address book: do not add.
+      if (!("email" in contact) && !("tel" in contact)) {
+        continue;
+      }
+
+      yield db.promise("add", contact);
+      stats.success++;
+    }
+
+    return stats;
+  }),
+
+  /**
+   * Parse an XML node to map the appropriate data to MozContact field equivalents.
+   *
+   * @param {XMLDOMNode} entry The contact XML node in Google format to process.
+   * @returns `null` if the contact entry appears to be invalid or an Object containing
+   *          all the contact data found in the XML.
+   */
+  _processContactFields: function(entry) {
+    // Basic fields in the main 'atom' namespace.
+    let contact = extractFieldsFromNode(new Map([
+      ["id", "id"],
+      // published: n/a
+      ["updated", "updated"]
+      // bday: n/a
+    ]), entry);
+
+    // Fields that need to wrapped in an Array.
+    extractFieldsFromNode(new Map([
+      ["name", "fullName"],
+      ["givenName", "givenName"],
+      ["familyName", "familyName"],
+      ["additionalName", "additionalName"]
+    ]), entry, kNS_GD, contact, true);
+
+    // The 'note' field needs to wrapped in an array, but its source node is not
+    // namespaced.
+    extractFieldsFromNode(new Map([
+      ["note", "content"]
+    ]), entry, null, contact, true);
+
+    // Process physical, earthly addresses.
+    let addressNodes = entry.getElementsByTagNameNS(kNS_GD, "structuredPostalAddress");
+    if (addressNodes.length) {
+      contact.adr = [];
+      for (let [,addressNode] of Iterator(addressNodes)) {
+        let adr = extractFieldsFromNode(new Map([
+          ["countryName", "country"],
+          ["locality", "city"],
+          ["postalCode", "postcode"],
+          ["region", "region"],
+          ["streetAddress", "street"]
+        ]), addressNode, kNS_GD);
+        if (Object.keys(adr).length) {
+          adr.pref = (addressNode.getAttribute("primary") == "true");
+          adr.type = [getFieldType(addressNode)];
+          contact.adr.push(adr);
+        }
+      }
+    }
+
+    // Process email addresses.
+    let emailNodes = entry.getElementsByTagNameNS(kNS_GD, "email");
+    if (emailNodes.length) {
+      contact.email = [];
+      for (let [,emailNode] of Iterator(emailNodes)) {
+        contact.email.push({
+          pref: (emailNode.getAttribute("primary") == "true"),
+          type: [getFieldType(emailNode)],
+          value: emailNode.getAttribute("address")
+        });
+      }
+    }
+
+    // Process telephone numbers.
+    let phoneNodes = entry.getElementsByTagNameNS(kNS_GD, "phoneNumber");
+    if (phoneNodes.length) {
+      contact.tel = [];
+      for (let [,phoneNode] of Iterator(phoneNodes)) {
+        contact.tel.push({
+          pref: (phoneNode.getAttribute("primary") == "true"),
+          type: [getFieldType(phoneNode)],
+          value: phoneNode.firstChild.nodeValue
+        });
+      }
+    }
+
+    let orgNodes = entry.getElementsByTagNameNS(kNS_GD, "organization");
+    if (orgNodes.length) {
+      contact.org = [];
+      contact.jobTitle = [];
+      for (let [,orgNode] of Iterator(orgNodes)) {
+        let orgElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0];
+        let titleElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0];
+        contact.org.push(orgElement ? orgElement.firstChild.nodeValue : "")
+        contact.jobTitle.push(titleElement ? titleElement.firstChild.nodeValue : "");
+      }
+    }
+
+    contact.category = ["google"];
+
+    // Basic sanity checking: make sure the name field isn't empty
+    if (!("name" in contact) || contact.name[0].length == 0) {
+      if (("familyName" in contact) && ("givenName" in contact)) {
+        // First, try to synthesize a full name from the name fields.
+        // Ordering is culturally sensitive, but we don't have
+        // cultural origin information available here. The best we
+        // can really do is "family, given additional"
+        contact.name = [contact.familyName[0] + ", " + contact.givenName[0]];
+        if (("additionalName" in contact)) {
+          contact.name[0] += " " + contact.additionalName[0];
+        }
+      } else {
+        let profileTitle = extractFieldsFromNode(new Map([["title", "title"]]), entry);
+        if (("title" in profileTitle)) {
+          contact.name = [profileTitle.title];
+        } else if ("familyName" in contact) {
+          contact.name = [contact.familyName[0]];
+        } else if ("givenName" in contact) {
+          contact.name = [contact.givenName[0]];
+        } else if ("org" in contact) {
+          contact.name = [contact.org[0]];
+        } else {
+          let email;
+          try {
+            email = getPreferred(contact);
+          } catch (ex) {}
+          if (email) {
+            contact.name = [email.value];
+          } else {
+            let tel;
+            try {
+              tel = getPreferred(contact, "phone");
+            } catch (ex) {}
+            if (tel) {
+              contact.name = [tel.value];
+            }
+          }
+        }
+      }
+    }
+
+    return contact;
+  },
+
+  /**
+   * Remove all contacts from the database that are not present anymore in the
+   * remote data-source.
+   *
+   * @param {Object}       ids Map of IDs collected earlier of all the contacts
+   *                           that are available on the remote data-source
+   * @param {LoopContacts} db  Instance of the LoopContacts database object, which
+   *                           will store the newly found contacts
+   */
+  _purgeContacts: Task.async(function* (ids, db) {
+    let contacts = yield db.promise("getAll");
+    let profileId = "https://www.google.com/m8/feeds/contacts/" + encodeURIComponent(gProfileId);
+    let processed = 0;
+
+    for (let [guid, contact] of Iterator(contacts)) {
+      if (++processed % kContactsChunkSize === 0) {
+        // Skip a beat every time we processed a chunk.
+        yield new Promise(resolve => Services.tm.currentThread.dispatch(resolve,
+                                       Ci.nsIThread.DISPATCH_NORMAL));
+      }
+
+      if (contact.id.indexOf(profileId) >= 0 && !ids[contact.id]) {
+        yield db.promise("remove", guid);
+      }
+    }
+  })
+};
--- a/browser/components/loop/LoopContacts.jsm
+++ b/browser/components/loop/LoopContacts.jsm
@@ -5,18 +5,22 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "console",
                                   "resource://gre/modules/devtools/Console.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                   "resource:///modules/loop/LoopStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CardDavImporter",
                                   "resource:///modules/loop/CardDavImporter.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "GoogleImporter",
+                                  "resource:///modules/loop/GoogleImporter.jsm");
 XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
   const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
   return new EventEmitter();
 });
 
 this.EXPORTED_SYMBOLS = ["LoopContacts"];
 
 const kObjectStoreName = "contacts";
@@ -319,17 +323,18 @@ LoopStorage.on("upgrade", function(e, db
  * callback Function. MozLoopAPI will cause things to break if this invariant is
  * violated. You'll notice this as well in the documentation for each method.
  */
 let LoopContactsInternal = Object.freeze({
   /**
    * Map of contact importer names to instances
    */
   _importServices: {
-    "carddav": new CardDavImporter()
+    "carddav": new CardDavImporter(),
+    "google": new GoogleImporter()
   },
 
   /**
    * Add a contact to the data store.
    *
    * @param {Object}   details  An object that will be added to the data store
    *                            as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
    *                            for more information of this objects' structure
@@ -765,26 +770,27 @@ let LoopContactsInternal = Object.freeze
    * Import a list of (new) contacts from an external data source.
    *
    * @param {Object}   options  Property bag of options for the importer
    * @param {Function} callback Function that will be invoked once the operation
    *                            finished. The first argument passed will be an
    *                            `Error` object or `null`. The second argument will
    *                            be the result of the operation, if successfull.
    */
-  startImport: function(options, callback) {
+  startImport: function(options, windowRef, callback) {
     if (!("service" in options)) {
       callback(new Error("No import service specified in options"));
       return;
     }
     if (!(options.service in this._importServices)) {
       callback(new Error("Unknown import service specified: " + options.service));
       return;
     }
-    this._importServices[options.service].startImport(options, callback, this);
+    this._importServices[options.service].startImport(options, callback,
+                                                      LoopContacts, windowRef);
   },
 
   /**
    * Search through the data store for contacts that match a certain (sub-)string.
    *
    * @param {String}   query    Needle to search for in our haystack of contacts
    * @param {Function} callback Function that will be invoked once the operation
    *                            finished. The first argument passed will be an
@@ -853,22 +859,34 @@ this.LoopContacts = Object.freeze({
   block: function(guid, callback) {
     return LoopContactsInternal.block(guid, callback);
   },
 
   unblock: function(guid, callback) {
     return LoopContactsInternal.unblock(guid, callback);
   },
 
-  startImport: function(options, callback) {
-    return LoopContactsInternal.startImport(options, callback);
+  startImport: function(options, windowRef, callback) {
+    return LoopContactsInternal.startImport(options, windowRef, callback);
   },
 
   search: function(query, callback) {
     return LoopContactsInternal.search(query, callback);
   },
 
+  promise: function(method, ...params) {
+    return new Promise((resolve, reject) => {
+      this[method](...params, (error, result) => {
+        if (error) {
+          reject(error);
+        } else {
+          resolve(result);
+        }
+      });
+    });
+  },
+
   on: (...params) => eventEmitter.on(...params),
 
   once: (...params) => eventEmitter.once(...params),
 
   off: (...params) => eventEmitter.off(...params)
 });
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -93,25 +93,16 @@ const injectObjectAPI = function(api, ta
   // the `contentObj` without Xrays.
   try {
     Object.seal(Cu.waiveXrays(contentObj));
   } catch (ex) {}
   return contentObj;
 };
 
 /**
- * Get the two-digit hexadecimal code for a byte
- *
- * @param {byte} charCode
- */
-const toHexString = function(charCode) {
-  return ("0" + charCode.toString(16)).slice(-2);
-};
-
-/**
  * Inject the loop API into the given window.  The caller must be sure the
  * window is a loop content window (eg, a panel, chatwindow, or similar).
  *
  * See the documentation on the individual functions for details of the API.
  *
  * @param {nsIDOMWindow} targetWindow The content window to attach the API.
  */
 function injectLoopAPI(targetWindow) {
@@ -148,16 +139,38 @@ function injectLoopAPI(targetWindow) {
       get: function() {
         return MozLoopService.doNotDisturb;
       },
       set: function(aFlag) {
         MozLoopService.doNotDisturb = aFlag;
       }
     },
 
+    errors: {
+      enumerable: true,
+      get: function() {
+        let errors = {};
+        for (let [type, error] of MozLoopService.errors) {
+          // if error.error is an nsIException, just delete it since it's hard
+          // to clone across the boundary.
+          if (error.error instanceof Ci.nsIException) {
+            MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
+                                     "due to issues copying nsIException across boundaries.",
+                                     error.error);
+            delete error.error;
+          }
+
+          // We have to clone the error property since it may be an Error object.
+          errors[type] = Cu.cloneInto(error, targetWindow);
+
+        }
+        return Cu.cloneInto(errors, targetWindow);
+      },
+    },
+
     /**
      * Returns the current locale of the browser.
      *
      * @returns {String} The locale string
      */
     locale: {
       enumerable: true,
       get: function() {
@@ -208,16 +221,35 @@ function injectLoopAPI(targetWindow) {
         if (contactsAPI) {
           return contactsAPI;
         }
         return contactsAPI = injectObjectAPI(LoopContacts, targetWindow);
       }
     },
 
     /**
+     * Import a list of (new) contacts from an external data source.
+     *
+     * @param {Object}   options  Property bag of options for the importer
+     * @param {Function} callback Function that will be invoked once the operation
+     *                            finished. The first argument passed will be an
+     *                            `Error` object or `null`. The second argument will
+     *                            be the result of the operation, if successfull.
+     */
+    startImport: {
+      enumerable: true,
+      writable: true,
+      value: function(options, callback) {
+        LoopContacts.startImport(options, getChromeWindow(targetWindow), function(...results) {
+          callback(...[cloneValueInto(r, targetWindow) for (r of results)]);
+        });
+      }
+    },
+
+    /**
      * Returns translated strings associated with an element. Designed
      * for use with l10n.js
      *
      * @param {String} key The element id
      * @returns {Object} A JSON string containing the localized
      *                   attribute/value pairs for the element.
      */
     getStrings: {
@@ -241,16 +273,43 @@ function injectLoopAPI(targetWindow) {
       enumerable: true,
       writable: true,
       value: function(num, str) {
         return PluralForm.get(num, str);
       }
     },
 
     /**
+     * Displays a confirmation dialog using the specified strings.
+     *
+     * Callback parameters:
+     * - err null on success, non-null on unexpected failure to show the prompt.
+     * - {Boolean} True if the user chose the OK button.
+     */
+    confirm: {
+      enumerable: true,
+      writable: true,
+      value: function(bodyMessage, okButtonMessage, cancelButtonMessage, callback) {
+        try {
+          let buttonFlags =
+            (Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING) +
+            (Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING);
+
+          let chosenButton = Services.prompt.confirmEx(null, "",
+            bodyMessage, buttonFlags, okButtonMessage, cancelButtonMessage,
+            null, null, {});
+
+          callback(null, chosenButton == 0);
+        } catch (ex) {
+          callback(cloneValueInto(ex, targetWindow));
+        }
+      }
+    },
+
+    /**
      * Call to ensure that any necessary registrations for the Loop Service
      * have taken place.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      *
      * @param {Function} callback Will be called once registration is complete,
      *                            or straight away if registration has already
@@ -437,16 +496,23 @@ function injectLoopAPI(targetWindow) {
 
     LOOP_SESSION_TYPE: {
       enumerable: true,
       get: function() {
         return Cu.cloneInto(LOOP_SESSION_TYPE, targetWindow);
       }
     },
 
+    fxAEnabled: {
+      enumerable: true,
+      get: function() {
+        return MozLoopService.fxAEnabled;
+      },
+    },
+
     logInToFxA: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.logInToFxA();
       }
     },
 
@@ -498,19 +564,19 @@ function injectLoopAPI(targetWindow) {
           try {
             appVersionInfo = Cu.cloneInto({
               channel: defaults.getCharPref("app.update.channel"),
               version: appInfo.version,
               OS: appInfo.OS
             }, targetWindow);
           } catch (ex) {
             // only log outside of xpcshell to avoid extra message noise
-            if (typeof window !== 'undefined' && "console" in window) {
-              console.log("Failed to construct appVersionInfo; if this isn't " +
-                          "an xpcshell unit test, something is wrong", ex);
+            if (typeof targetWindow !== 'undefined' && "console" in targetWindow) {
+              MozLoopService.log.error("Failed to construct appVersionInfo; if this isn't " +
+                                       "an xpcshell unit test, something is wrong", ex);
             }
           }
         }
         return appVersionInfo;
       }
     },
 
     /**
@@ -550,55 +616,34 @@ function injectLoopAPI(targetWindow) {
       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.
+     * Starts a direct call to the contact addresses.
      *
-     * @param {String} emailAddress Users' email address
-     * @param {Number} size         Size of the avatar image to return in pixels.
-     *                              Optional. Default value: 40.
-     * @return the URL pointing to an avatar matching the provided email address.
+     * @param {Object} contact The contact to call
+     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+     * @return true if the call is opened, false if it is not opened (i.e. busy)
      */
-    getUserAvatar: {
+    startDirectCall: {
       enumerable: true,
       writable: true,
-      value: function(emailAddress, size = 40) {
-        if (!emailAddress) {
-          return "";
-        }
-
-        // Do the MD5 dance.
-        let hasher = Cc["@mozilla.org/security/hash;1"]
-                       .createInstance(Ci.nsICryptoHash);
-        hasher.init(Ci.nsICryptoHash.MD5);
-        let stringStream = Cc["@mozilla.org/io/string-input-stream;1"]
-                             .createInstance(Ci.nsIStringInputStream);
-        stringStream.data = emailAddress.trim().toLowerCase();
-        hasher.updateFromStream(stringStream, -1);
-        let hash = hasher.finish(false);
-        // Convert the binary hash data to a hex string.
-        let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
-
-        // Compose the Gravatar URL.
-        return "http://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
+      value: function(contact, callType) {
+        MozLoopService.startDirectCall(contact, callType);
       }
     },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
-    targetWindow.dispatchEvent(event)
+    targetWindow.dispatchEvent(event);
   };
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
     if (targetWindow && aSubject != targetWindow)
       return;
     Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed");
     Services.obs.removeObserver(onStatusChanged, "loop-status-changed");
   };
--- a/browser/components/loop/MozLoopPushHandler.jsm
+++ b/browser/components/loop/MozLoopPushHandler.jsm
@@ -162,35 +162,35 @@ let MozLoopPushHandler = {
    * Handles the PushServer registration response.
    *
    * @param {} msg PushServer to UserAgent registration response (parsed from JSON).
    */
   _onRegister: function(msg) {
     switch (msg.status) {
       case 200:
         this._retryEnd(); // reset retry mechanism
-	this.registered = true;
+        this.registered = true;
         if (this.pushUrl !== msg.pushEndpoint) {
           this.pushUrl = msg.pushEndpoint;
           this._registerCallback(null, this.pushUrl);
         }
         break;
 
       case 500:
         // retry the registration request after a suitable delay
         this._retryOperation(() => this._registerChannel());
         break;
 
       case 409:
         this._registerCallback("error: PushServer ChannelID already in use");
-	break;
+        break;
 
       default:
         this._registerCallback("error: PushServer registration failure, status = " + msg.status);
-	break;
+        break;
     }
   },
 
   /**
    * Attempts to open a websocket.
    *
    * A new websocket interface is used each time. If an onStop callback
    * was received, calling asyncOpen() on the same interface will
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1,15 +1,15 @@
 /* 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/. */
 
 "use strict";
 
-const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 // Ticket numbers are 24 bits in length.
 // The highest valid ticket number is 16777214 (2^24 - 2), so that a "now
 // serving" number of 2^24 - 1 is greater than it.
@@ -72,28 +72,37 @@ XPCOMUtils.defineLazyGetter(this, "log",
   let ConsoleAPI = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).ConsoleAPI;
   let consoleOptions = {
     maxLogLevel: Services.prefs.getCharPref(PREF_LOG_LEVEL).toLowerCase(),
     prefix: "Loop",
   };
   return new ConsoleAPI(consoleOptions);
 });
 
+function setJSONPref(aName, aValue) {
+  let value = !!aValue ? JSON.stringify(aValue) : "";
+  Services.prefs.setCharPref(aName, value);
+}
+
+function getJSONPref(aName) {
+  let value = Services.prefs.getCharPref(aName);
+  return !!value ? JSON.parse(value) : null;
+}
+
 // The current deferred for the registration process. This is set if in progress
 // or the registration was successful. This is null if a registration attempt was
 // unsuccessful.
 let gRegisteredDeferred = null;
 let gPushHandler = null;
 let gHawkClient = null;
-let gLocalizedStrings =  null;
+let gLocalizedStrings = null;
 let gInitializeTimer = null;
+let gFxAEnabled = true;
 let gFxAOAuthClientPromise = null;
 let gFxAOAuthClient = null;
-let gFxAOAuthTokenData = null;
-let gFxAOAuthProfile = null;
 let gErrors = new Map();
 
  /**
  * Attempts to open a websocket.
  *
  * A new websocket interface is used each time. If an onStop callback
  * was received, calling asyncOpen() on the same interface will
  * trigger a "alreay open socket" exception even though the channel
@@ -301,16 +310,48 @@ let MozLoopServiceInternal = {
   /**
    * Returns true if the expiry time is in the future.
    */
   urlExpiryTimeIsInFuture: function() {
     return this.expiryTimeSeconds * 1000 > Date.now();
   },
 
   /**
+   * Retrieves MozLoopService Firefox Accounts OAuth token.
+   *
+   * @return {Object} OAuth token
+   */
+  get fxAOAuthTokenData() {
+    return getJSONPref("loop.fxa_oauth.tokendata");
+  },
+
+  /**
+   * Sets MozLoopService Firefox Accounts OAuth token.
+   * If the tokenData is being cleared, will also clear the
+   * profile since the profile is dependent on the token data.
+   *
+   * @param {Object} aTokenData OAuth token
+   */
+  set fxAOAuthTokenData(aTokenData) {
+    setJSONPref("loop.fxa_oauth.tokendata", aTokenData);
+    if (!aTokenData) {
+      this.fxAOAuthProfile = null;
+    }
+  },
+
+  /**
+   * Sets MozLoopService Firefox Accounts Profile data.
+   *
+   * @param {Object} aProfileData Profile data
+   */
+  set fxAOAuthProfile(aProfileData) {
+    setJSONPref("loop.fxa_oauth.profile", aProfileData);
+  },
+
+  /**
    * Retrieves MozLoopService "do not disturb" pref value.
    *
    * @return {Boolean} aFlag
    */
   get doNotDisturb() {
     return Services.prefs.getBoolPref("loop.do_not_disturb");
   },
 
@@ -325,21 +366,70 @@ let MozLoopServiceInternal = {
   },
 
   notifyStatusChanged: function(aReason = null) {
     log.debug("notifyStatusChanged with reason:", aReason);
     Services.obs.notifyObservers(null, "loop-status-changed", aReason);
   },
 
   /**
+   * Record an error and notify interested UI with the relevant user-facing strings attached.
+   *
    * @param {String} errorType a key to identify the type of error. Only one
-   *                           error of a type will be saved at a time.
+   *                           error of a type will be saved at a time. This value may be used to
+   *                           determine user-facing (aka. friendly) strings.
    * @param {Object} error     an object describing the error in the format from Hawk errors
    */
   setError: function(errorType, error) {
+    let messageString, detailsString, detailsButtonLabelString;
+    const NETWORK_ERRORS = [
+      Cr.NS_ERROR_CONNECTION_REFUSED,
+      Cr.NS_ERROR_NET_INTERRUPT,
+      Cr.NS_ERROR_NET_RESET,
+      Cr.NS_ERROR_NET_TIMEOUT,
+      Cr.NS_ERROR_OFFLINE,
+      Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
+      Cr.NS_ERROR_UNKNOWN_HOST,
+      Cr.NS_ERROR_UNKNOWN_PROXY_HOST,
+    ];
+
+    if (error.code === null && error.errno === null &&
+        error.error instanceof Ci.nsIException &&
+        NETWORK_ERRORS.indexOf(error.error.result) != -1) {
+      // Network error. Override errorType so we can easily clear it on the next succesful request.
+      errorType = "network";
+      messageString = "could_not_connect";
+      detailsString = "check_internet_connection";
+      detailsButtonLabelString = "retry_button";
+    } else if (errorType == "profile" && error.code >= 500 && error.code < 600) {
+      messageString = "problem_accessing_account";
+    } else if (error.code == 401) {
+      if (errorType == "login") {
+        messageString = "could_not_authenticate"; // XXX: Bug 1076377
+        detailsString = "password_changed_question";
+        detailsButtonLabelString = "retry_button";
+      } else {
+        messageString = "session_expired_error_description";
+      }
+    } else if (error.code >= 500 && error.code < 600) {
+      messageString = "service_not_available";
+      detailsString = "try_again_later";
+      detailsButtonLabelString = "retry_button";
+    } else {
+      messageString = "generic_failure_title";
+    }
+
+    error.friendlyMessage = this.localizedStrings[messageString].textContent;
+    error.friendlyDetails = detailsString ?
+                              this.localizedStrings[detailsString].textContent :
+                              null;
+    error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
+                                         this.localizedStrings[detailsButtonLabelString].textContent :
+                                         null;
+
     gErrors.set(errorType, error);
     this.notifyStatusChanged();
   },
 
   clearError: function(errorType) {
     gErrors.delete(errorType);
     this.notifyStatusChanged();
   },
@@ -365,19 +455,18 @@ let MozLoopServiceInternal = {
     }
 
     gRegisteredDeferred = Promise.defer();
     // We grab the promise early in case .initialize or its results sets
     // it back to null on error.
     let result = gRegisteredDeferred.promise;
 
     gPushHandler = mockPushHandler || MozLoopPushHandler;
-
     gPushHandler.initialize(this.onPushRegistered.bind(this),
-      this.onHandleNotification.bind(this));
+                            this.onHandleNotification.bind(this));
 
     return result;
   },
 
   /**
    * Performs a hawk based request to the loop server.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
@@ -406,17 +495,40 @@ let MozLoopServiceInternal = {
 
     let credentials;
     if (sessionToken) {
       // true = use a hex key, as required by the server (see bug 1032738).
       credentials = deriveHawkCredentials(sessionToken, "sessionToken",
                                           2 * 32, true);
     }
 
-    return gHawkClient.request(path, method, credentials, payloadObj);
+    return gHawkClient.request(path, method, credentials, payloadObj).then((result) => {
+      this.clearError("network");
+      return result;
+    }, (error) => {
+      if (error.code == 401) {
+        this.clearSessionToken(sessionType);
+
+        if (sessionType == LOOP_SESSION_TYPE.FXA) {
+          MozLoopService.logOutFromFxA().then(() => {
+            // Set a user-visible error after logOutFromFxA clears existing ones.
+            this.setError("login", error);
+          });
+        } else {
+          if (!this.urlExpiryTimeIsInFuture()) {
+            // If there are no Guest URLs in the future, don't use setError to notify the user since
+            // there isn't a need for a Guest registration at this time.
+            throw error;
+          }
+
+          this.setError("registration", error);
+        }
+      }
+      throw error;
+    });
   },
 
   /**
    * Generic hawkRequest onError handler for the hawkRequest promise.
    *
    * @param {Object} error - error reporting object
    *
    */
@@ -497,22 +609,25 @@ let MozLoopServiceInternal = {
       return;
     }
 
     this.registerWithLoopServer(LOOP_SESSION_TYPE.GUEST, pushUrl).then(() => {
       // storeSessionToken could have rejected and nulled the promise if the token was malformed.
       if (!gRegisteredDeferred) {
         return;
       }
-      gRegisteredDeferred.resolve();
+      gRegisteredDeferred.resolve("registered to guest status");
       // No need to clear the promise here, everything was good, so we don't need
       // to re-register.
-    }, (error) => {
+    }, error => {
       log.error("Failed to register with Loop server: ", error);
-      gRegisteredDeferred.reject(error.errno);
+      // registerWithLoopServer may have already made this null.
+      if (gRegisteredDeferred) {
+        gRegisteredDeferred.reject(error);
+      }
       gRegisteredDeferred = null;
     });
   },
 
   /**
    * Registers with the Loop server either as a guest or a FxA user.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
@@ -529,33 +644,27 @@ let MozLoopServiceInternal = {
         if (!this.storeSessionToken(sessionType, response.headers))
           return;
 
         log.debug("Successfully registered with server for sessionType", sessionType);
         this.clearError("registration");
       }, (error) => {
         // There's other errors than invalid auth token, but we should only do the reset
         // as a last resort.
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
-          if (this.urlExpiryTimeIsInFuture()) {
-            // XXX Should this be reported to the user is a visible manner?
-            Cu.reportError("Loop session token is invalid, all previously "
-                           + "generated urls will no longer work.");
-          }
-
+        if (error.code === 401) {
           // Authorization failed, invalid token, we need to try again with a new token.
-          this.clearSessionToken(sessionType);
           if (retry) {
             return this.registerWithLoopServer(sessionType, pushUrl, false);
           }
         }
 
-        // XXX Bubble the precise details up to the UI somehow (bug 1013248).
         log.error("Failed to register with the loop server. Error: ", error);
         this.setError("registration", error);
+        gRegisteredDeferred.reject(error);
+        gRegisteredDeferred = null;
         throw error;
       }
     );
   },
 
   /**
    * Unregisters from the Loop server either as a guest or a FxA user.
    *
@@ -563,26 +672,31 @@ let MozLoopServiceInternal = {
    * guest session with the device.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
    * @param {String} pushURL The push URL previously given by the push server.
    *                         This may not be necessary to unregister in the future.
    * @return {Promise} resolving when the unregistration request finishes
    */
   unregisterFromLoopServer: function(sessionType, pushURL) {
+    let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
+    if (prefType == Services.prefs.PREF_INVALID) {
+      return Promise.resolve("already unregistered");
+    }
+
     let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
     return this.hawkRequest(sessionType, unregisterURL, "DELETE")
       .then(() => {
         log.debug("Successfully unregistered from server for sessionType", sessionType);
         MozLoopServiceInternal.clearSessionToken(sessionType);
       },
       error => {
         // Always clear the registration token regardless of whether the server acknowledges the logout.
         MozLoopServiceInternal.clearSessionToken(sessionType);
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
+        if (error.code === 401) {
           // Authorization failed, invalid token. This is fine since it may mean we already logged out.
           return;
         }
 
         log.error("Failed to unregister with the loop server. Error: ", error);
         throw error;
       });
   },
@@ -603,17 +717,19 @@ let MozLoopServiceInternal = {
     // bug 1046039 for background.
     Services.prefs.setCharPref("loop.seenToS", "seen");
 
     // Request the information on the new call(s) associated with this version.
     // The registered FxA session is checked first, then the anonymous session.
     // Make the call to get the GUEST session regardless of whether the FXA
     // request fails.
 
-    this._getCalls(LOOP_SESSION_TYPE.FXA, version).catch(() => {});
+    if (MozLoopService.userProfile) {
+      this._getCalls(LOOP_SESSION_TYPE.FXA, version).catch(() => {});
+    }
     this._getCalls(LOOP_SESSION_TYPE.GUEST, version).catch(
       error => {this._hawkRequestError(error);});
   },
 
   /**
    * Make a hawkRequest to GET/calls?=version for this session type.
    *
    * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
@@ -642,35 +758,68 @@ let MozLoopServiceInternal = {
    */
 
   _processCalls: function(response, sessionType) {
     try {
       let respData = JSON.parse(response.body);
       if (respData.calls && Array.isArray(respData.calls)) {
         respData.calls.forEach((callData) => {
           if (!this.callsData.inUse) {
-            this.callsData.inUse = true;
             callData.sessionType = sessionType;
-            this.callsData.data = callData;
-            this.openChatWindow(
-              null,
-              this.localizedStrings["incoming_call_title2"].textContent,
-              "about:loopconversation#incoming/" + callData.callId);
+            this._startCall(callData, "incoming");
           } else {
             this._returnBusy(callData);
           }
         });
       } else {
         log.warn("Error: missing calls[] in response");
       }
     } catch (err) {
       log.warn("Error parsing calls info", err);
     }
   },
 
+  /**
+   * Starts a call, saves the call data, and opens a chat window.
+   *
+   * @param {Object} callData The data associated with the call including an id.
+   * @param {Boolean} conversationType Whether or not the call is "incoming"
+   *                                   or "outgoing"
+   */
+  _startCall: function(callData, conversationType) {
+    this.callsData.inUse = true;
+    this.callsData.data = callData;
+    this.openChatWindow(
+      null,
+      // No title, let the page set that, to avoid flickering.
+      "",
+      "about:loopconversation#" + conversationType + "/" + callData.callId);
+  },
+
+  /**
+   * Starts a direct call to the contact addresses.
+   *
+   * @param {Object} contact The contact to call
+   * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+   * @return true if the call is opened, false if it is not opened (i.e. busy)
+   */
+  startDirectCall: function(contact, callType) {
+    if (this.callsData.inUse)
+      return false;
+
+    var callData = {
+      contact: contact,
+      callType: callType,
+      callId: Math.floor((Math.random() * 10))
+    };
+
+    this._startCall(callData, "outgoing");
+    return true;
+  },
+
    /**
    * Open call progress websocket and terminate with a reason of busy
    * the server.
    *
    * @param {callData} Must contain the progressURL, callId and websocketToken
    *                   returned by the LoopService.
    */
   _returnBusy: function(callData) {
@@ -959,59 +1108,101 @@ let MozLoopServiceInternal = {
       deferred.resolve(result);
     } else {
       deferred.reject("Invalid token data");
     }
   },
 };
 Object.freeze(MozLoopServiceInternal);
 
-let gInitializeTimerFunc = () => {
-  // Kick off the push notification service into registering after a timeout
-  // this ensures we're not doing too much straight after the browser's finished
+let gInitializeTimerFunc = (deferredInitialization, mockPushHandler, mockWebSocket) => {
+  // Kick off the push notification service into registering after a timeout.
+  // This ensures we're not doing too much straight after the browser's finished
   // starting up.
   gInitializeTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
-  gInitializeTimer.initWithCallback(() => {
-    MozLoopService.register();
+  gInitializeTimer.initWithCallback(Task.async(function* initializationCallback() {
+    yield MozLoopService.register(mockPushHandler, mockWebSocket).then(Task.async(function*() {
+      if (!MozLoopServiceInternal.fxAOAuthTokenData) {
+        log.debug("MozLoopService: Initialized without an already logged-in account");
+        deferredInitialization.resolve("initialized to guest status");
+        return;
+      }
+
+      log.debug("MozLoopService: Initializing with already logged-in account");
+      let registeredPromise =
+            MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA,
+                                                          gPushHandler.pushUrl);
+      registeredPromise.then(() => {
+        deferredInitialization.resolve("initialized to logged-in status");
+      }, error => {
+        log.debug("MozLoopService: error logging in using cached auth token");
+        MozLoopServiceInternal.setError("login", error);
+        deferredInitialization.reject("error logging in using cached auth token");
+      });
+    }), error => {
+      log.debug("MozLoopService: Failure of initial registration", error);
+      deferredInitialization.reject(error);
+    });
     gInitializeTimer = null;
-  },
+  }),
   MozLoopServiceInternal.initialRegistrationDelayMilliseconds, Ci.nsITimer.TYPE_ONE_SHOT);
 };
 
 /**
  * Public API
  */
 this.MozLoopService = {
   _DNSService: gDNSService,
 
   set initializeTimerFunc(value) {
     gInitializeTimerFunc = value;
   },
 
   /**
    * Initialized the loop service, and starts registration with the
    * push and loop servers.
+   *
+   * @return {Promise}
    */
-  initialize: function() {
-
+  initialize: Task.async(function*(mockPushHandler, mockWebSocket) {
     // Do this here, rather than immediately after definition, so that we can
     // stub out API functions for unit testing
     Object.freeze(this);
 
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled") ||
         Services.prefs.getBoolPref("loop.throttled")) {
-      return;
+      return Promise.reject("loop is not enabled");
+    }
+
+    if (Services.prefs.getPrefType("loop.fxa.enabled") == Services.prefs.PREF_BOOL) {
+      gFxAEnabled = Services.prefs.getBoolPref("loop.fxa.enabled");
+      if (!gFxAEnabled) {
+        yield this.logOutFromFxA();
+      }
     }
 
-    // If expiresTime is in the future then kick-off registration.
-    if (MozLoopServiceInternal.urlExpiryTimeIsInFuture()) {
-      gInitializeTimerFunc();
+    // If expiresTime is not in the future and the user hasn't
+    // previously authenticated then skip registration.
+    if (!MozLoopServiceInternal.urlExpiryTimeIsInFuture() &&
+        !MozLoopServiceInternal.fxAOAuthTokenData) {
+      return Promise.resolve("registration not needed");
     }
-  },
+
+    let deferredInitialization = Promise.defer();
+    gInitializeTimerFunc(deferredInitialization, mockPushHandler, mockWebSocket);
+
+    return deferredInitialization.promise.catch(error => {
+      if (typeof(error) == "object") {
+        // This never gets cleared since there is no UI to recover. Only restarting will work.
+        MozLoopServiceInternal.setError("initialization", error);
+      }
+      throw error;
+    });
+  }),
 
   /**
    * If we're operating the service in "soft start" mode, and this browser
    * isn't already activated, check whether it's time for it to become active.
    * If so, activate the loop service.
    *
    * @param {Object} buttonNode DOM node representing the Loop button -- if we
    *                            change from inactive to active, we need this
@@ -1112,16 +1303,17 @@ this.MozLoopService = {
    * with the Loop server. It will return early if already registered.
    *
    * @param {Object} mockPushHandler Optional, test-only mock push handler. Used
    *                                 to allow mocking of the MozLoopPushHandler.
    * @returns {Promise} a promise that is resolved with no params on completion, or
    *          rejected with an error code or string.
    */
   register: function(mockPushHandler, mockWebSocket) {
+    log.debug("registering");
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled")) {
       throw new Error("Loop is not enabled");
     }
 
     if (Services.prefs.getBoolPref("loop.throttled")) {
       throw new Error("Loop is disabled by the soft-start mechanism");
     }
@@ -1130,17 +1322,17 @@ this.MozLoopService = {
   },
 
   /**
    * Used to note a call url expiry time. If the time is later than the current
    * latest expiry time, then the stored expiry time is increased. For times
    * sooner, this function is a no-op; this ensures we always have the latest
    * expiry time for a url.
    *
-   * This is used to deterimine whether or not we should be registering with the
+   * This is used to determine whether or not we should be registering with the
    * push server on start.
    *
    * @param {Integer} expiryTimeSeconds The seconds since epoch of the expiry time
    *                                    of the url.
    */
   noteCallUrlExpiry: function(expiryTimeSeconds) {
     MozLoopServiceInternal.expiryTimeSeconds = expiryTimeSeconds;
   },
@@ -1183,24 +1375,40 @@ this.MozLoopService = {
    * Sets MozLoopService "do not disturb" value.
    *
    * @param {Boolean} aFlag
    */
   set doNotDisturb(aFlag) {
     MozLoopServiceInternal.doNotDisturb = aFlag;
   },
 
+  get fxAEnabled() {
+    return gFxAEnabled;
+  },
+
+  /**
+   * Gets the user profile, but only if there is
+   * tokenData present. Without tokenData, the
+   * profile is meaningless.
+   *
+   * @return {Object}
+   */
   get userProfile() {
-    return gFxAOAuthProfile;
+    return getJSONPref("loop.fxa_oauth.tokendata") &&
+           getJSONPref("loop.fxa_oauth.profile");
   },
 
   get errors() {
     return MozLoopServiceInternal.errors;
   },
 
+  get log() {
+    return log;
+  },
+
   /**
    * Returns the current locale
    *
    * @return {String} The code of the current locale.
    */
   get locale() {
     try {
       return Services.prefs.getComplexValue("general.useragent.locale",
@@ -1309,79 +1517,92 @@ this.MozLoopService = {
   /**
    * Start the FxA login flow using the OAuth client and params from the Loop server.
    *
    * The caller should be prepared to handle rejections related to network, server or login errors.
    *
    * @return {Promise} that resolves when the FxA login flow is complete.
    */
   logInToFxA: function() {
-    log.debug("logInToFxA with gFxAOAuthTokenData:", !!gFxAOAuthTokenData);
-    if (gFxAOAuthTokenData) {
-      return Promise.resolve(gFxAOAuthTokenData);
+    log.debug("logInToFxA with fxAOAuthTokenData:", !!MozLoopServiceInternal.fxAOAuthTokenData);
+    if (MozLoopServiceInternal.fxAOAuthTokenData) {
+      return Promise.resolve(MozLoopServiceInternal.fxAOAuthTokenData);
     }
 
     return MozLoopServiceInternal.promiseFxAOAuthAuthorization().then(response => {
       return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
     }).then(tokenData => {
-      gFxAOAuthTokenData = tokenData;
+      MozLoopServiceInternal.fxAOAuthTokenData = tokenData;
       return tokenData;
     }).then(tokenData => {
       return gRegisteredDeferred.promise.then(Task.async(function*() {
         if (gPushHandler.pushUrl) {
           yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
         } else {
           throw new Error("No pushUrl for FxA registration");
         }
-        return gFxAOAuthTokenData;
+        MozLoopServiceInternal.clearError("login");
+        MozLoopServiceInternal.clearError("profile");
+        return MozLoopServiceInternal.fxAOAuthTokenData;
       }));
     }).then(tokenData => {
       let client = new FxAccountsProfileClient({
         serverURL: gFxAOAuthClient.parameters.profile_uri,
         token: tokenData.access_token
       });
       client.fetchProfile().then(result => {
-        gFxAOAuthProfile = result;
+        MozLoopServiceInternal.fxAOAuthProfile = result;
         MozLoopServiceInternal.notifyStatusChanged("login");
       }, error => {
         log.error("Failed to retrieve profile", error);
-        gFxAOAuthProfile = null;
+        this.setError("profile", error);
+        MozLoopServiceInternal.fxAOAuthProfile = null;
         MozLoopServiceInternal.notifyStatusChanged();
       });
       return tokenData;
     }).catch(error => {
-      gFxAOAuthTokenData = null;
-      gFxAOAuthProfile = null;
+      MozLoopServiceInternal.fxAOAuthTokenData = null;
+      MozLoopServiceInternal.fxAOAuthProfile = null;
+      throw error;
+    }).catch((error) => {
+      MozLoopServiceInternal.setError("login", error);
+      // Re-throw for testing
       throw error;
     });
   },
 
   /**
    * Logs the user out from FxA.
    *
    * Gracefully handles if the user is already logged out.
    *
    * @return {Promise} that resolves when the FxA logout flow is complete.
    */
   logOutFromFxA: Task.async(function*() {
     log.debug("logOutFromFxA");
-    yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
-                                                          gPushHandler.pushUrl);
+    if (gPushHandler && gPushHandler.pushUrl) {
+      yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
+                                                            gPushHandler.pushUrl);
+    } else {
+      MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
+    }
 
-    gFxAOAuthTokenData = null;
-    gFxAOAuthProfile = null;
+    MozLoopServiceInternal.fxAOAuthTokenData = null;
+    MozLoopServiceInternal.fxAOAuthProfile = null;
 
     // Reset the client since the initial promiseFxAOAuthParameters() call is
     // what creates a new session.
     gFxAOAuthClient = null;
     gFxAOAuthClientPromise = null;
 
     // clearError calls notifyStatusChanged so should be done last when the
     // state is clean.
     MozLoopServiceInternal.clearError("registration");
+    MozLoopServiceInternal.clearError("login");
+    MozLoopServiceInternal.clearError("profile");
   }),
 
   openFxASettings: function() {
     let url = new URL("/settings", gFxAOAuthClient.parameters.content_uri);
     let win = Services.wm.getMostRecentWindow("navigator:browser");
     win.switchToTabHavingURI(url.toString(), true);
   },
 
@@ -1399,9 +1620,20 @@ this.MozLoopService = {
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
   hawkRequest: function(sessionType, path, method, payloadObj) {
     return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch(
       error => {MozLoopServiceInternal._hawkRequestError(error);});
   },
+
+    /**
+     * Starts a direct call to the contact addresses.
+     *
+     * @param {Object} contact The contact to call
+     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+     * @return true if the call is opened, false if it is not opened (i.e. busy)
+     */
+  startDirectCall: function(contact, callType) {
+    MozLoopServiceInternal.startDirectCall(contact, callType);
+  },
 };
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -25,13 +25,20 @@
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
+    <script type="text/javascript" src="loop/shared/js/actions.js"></script>
+    <script type="text/javascript" src="loop/shared/js/validate.js"></script>
+    <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
+    <script type="text/javascript" src="loop/shared/js/otSdkDriver.js"></script>
+    <script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
+    <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
+    <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -10,16 +10,22 @@ loop.Client = (function($) {
   "use strict";
 
   // The expected properties to be returned from the POST /call-url/ request.
   var expectedCallUrlProperties = ["callUrl", "expiresAt"];
 
   // The expected properties to be returned from the GET /calls request.
   var expectedCallProperties = ["calls"];
 
+  // THe expected properties to be returned from the POST /calls request.
+  var expectedPostCallProperties = [
+    "apiKey", "callId", "progressURL",
+    "sessionId", "sessionToken", "websocketToken"
+  ];
+
   /**
    * Loop server client.
    *
    * @param {Object} settings Settings object.
    */
   function Client(settings) {
     if (!settings) {
       settings = {};
@@ -67,17 +73,17 @@ loop.Client = (function($) {
      * Generic handler for XHR failures.
      *
      * @param {Function} cb Callback(err)
      * @param {Object} error See MozLoopAPI.hawkRequest
      */
     _failureHandler: function(cb, error) {
       var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
       console.error(message);
-      cb(new Error(message));
+      cb(error);
     },
 
     /**
      * Ensures the client is registered with the push server.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      *
@@ -205,16 +211,56 @@ loop.Client = (function($) {
           return;
         }
 
         this._requestCallUrlInternal(nickname, cb);
       }.bind(this));
     },
 
     /**
+     * Sets up an outgoing call, getting the relevant data from the server.
+     *
+     * Callback parameters:
+     * - err null on successful registration, non-null otherwise.
+     * - result an object of the obtained data for starting the call, if successful
+     *
+     * @param {Array} calleeIds an array of emails and phone numbers.
+     * @param {String} callType the type of call.
+     * @param {Function} cb Callback(err, result)
+     */
+    setupOutgoingCall: function(calleeIds, callType, cb) {
+      // For direct calls, we only ever use the logged-in session. Direct
+      // calls by guests aren't valid.
+      this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA,
+        "/calls", "POST", {
+          calleeId: calleeIds,
+          callType: callType
+        },
+        function (err, responseText) {
+          if (err) {
+            this._failureHandler(cb, err);
+            return;
+          }
+
+          try {
+            var postData = JSON.parse(responseText);
+
+            var outgoingCallData = this._validate(postData,
+              expectedPostCallProperties);
+
+            cb(null, outgoingCallData);
+          } catch (err) {
+            console.log("Error requesting call info", err);
+            cb(err);
+          }
+        }.bind(this)
+      );
+    },
+
+    /**
      * Adds a value to a telemetry histogram, ignoring errors.
      *
      * @param  {string}  histogramId Name of the telemetry histogram to update.
      * @param  {integer} value       Value to add to the histogram.
      */
     _telemetryAdd: function(histogramId, value) {
       try {
         this.mozLoop.telemetryAdd(histogramId, value);
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -8,113 +8,244 @@
 /*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;
+  const CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+  // At least this number of contacts should be present for the filter to appear.
+  const MIN_CONTACTS_FOR_FILTERING = 7;
+
+  let getContactNames = function(contact) {
+    // 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 = contact.name[0].split(" ");
+    return {
+      firstName: names.shift(),
+      lastName: names.join(" ")
+    };
+  };
+
+  let getPreferredEmail = function(contact) {
+    // A contact may not contain email addresses, but only a phone number.
+    if (!contact.email || contact.email.length == 0) {
+      return { value: "" };
+    }
+    return contact.email.find(e => e.pref) || contact.email[0];
+  };
+
+  const ContactDropdown = React.createClass({displayName: 'ContactDropdown',
     propTypes: {
-      handleContactClick: React.PropTypes.func,
-      contact: React.PropTypes.object.isRequired
+      handleAction: React.PropTypes.func.isRequired,
+      canEdit: React.PropTypes.bool
     },
 
-    handleContactClick: function() {
-      if (this.props.handleContactClick) {
-        this.props.handleContactClick(this.props.key);
+    getInitialState: function () {
+      return {
+        openDirUp: false,
+      };
+    },
+
+    componentDidMount: function () {
+      // This method is called once when the dropdown menu is added to the DOM
+      // inside the contact item.  If the menu extends outside of the visible
+      // area of the scrollable list, it is re-rendered in different direction.
+
+      let menuNode = this.getDOMNode();
+      let menuNodeRect = menuNode.getBoundingClientRect();
+
+      let listNode = document.getElementsByClassName("contact-list")[0];
+      let listNodeRect = listNode.getBoundingClientRect();
+
+      if (menuNodeRect.top + menuNodeRect.height >=
+          listNodeRect.top + listNodeRect.height) {
+        this.setState({
+          openDirUp: true,
+        });
       }
     },
 
-    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(" ");
+    onItemClick: function(event) {
+      this.props.handleAction(event.currentTarget.dataset.action);
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+
+      let blockAction = this.props.blocked ? "unblock" : "block";
+      let blockLabel = this.props.blocked ? "unblock_contact_menu_button"
+                                          : "block_contact_menu_button";
+
+      return (
+        React.DOM.ul({className: cx({ "dropdown-menu": true,
+                            "dropdown-menu-up": this.state.openDirUp })}, 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true }), 
+              onClick: this.onItemClick, 'data-action': "video-call"}, 
+            React.DOM.i({className: "icon icon-video-call"}), 
+            mozL10n.get("video_call_menu_button")
+          ), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true }), 
+              onClick: this.onItemClick, 'data-action': "audio-call"}, 
+            React.DOM.i({className: "icon icon-audio-call"}), 
+            mozL10n.get("audio_call_menu_button")
+          ), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true,
+                              "disabled": !this.props.canEdit }), 
+              onClick: this.onItemClick, 'data-action': "edit"}, 
+            React.DOM.i({className: "icon icon-edit"}), 
+            mozL10n.get("edit_contact_menu_button")
+          ), 
+          React.DOM.li({className: "dropdown-menu-item", 
+              onClick: this.onItemClick, 'data-action': blockAction}, 
+            React.DOM.i({className: "icon icon-" + blockAction}), 
+            mozL10n.get(blockLabel)
+          ), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true,
+                              "disabled": !this.props.canEdit }), 
+              onClick: this.onItemClick, 'data-action': "remove"}, 
+            React.DOM.i({className: "icon icon-remove"}), 
+            mozL10n.get("remove_contact_menu_button")
+          )
+        )
+      );
+    }
+  });
+
+  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+    getInitialState: function() {
       return {
-        firstName: names.shift(),
-        lastName: names.join(" ")
+        showMenu: false,
       };
     },
 
-    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;
+    propTypes: {
+      handleContactAction: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    _onBodyClick: function() {
+      // Hide the menu after other click handlers have been invoked.
+      setTimeout(this.hideDropdownMenu, 10);
+    },
+
+    showDropdownMenu: function() {
+      document.body.addEventListener("click", this._onBodyClick);
+      this.setState({showMenu: true});
+    },
+
+    hideDropdownMenu: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+      // Since this call may be deferred, we need to guard it, for example in
+      // case the contact was removed in the meantime.
+      if (this.isMounted()) {
+        this.setState({showMenu: false});
+      }
+    },
+
+    componentWillUnmount: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+    },
+
+    componentShouldUpdate: function(nextProps, nextState) {
+      let currContact = this.props.contact;
+      let nextContact = nextProps.contact;
+      return (
+        currContact.name[0] !== nextContact.name[0] ||
+        currContact.blocked !== nextContact.blocked ||
+        getPreferredEmail(currContact).value !==
+          getPreferredEmail(nextContact).value
+      );
+    },
+
+    handleAction: function(actionName) {
+      if (this.props.handleContactAction) {
+        this.props.handleContactAction(this.props.contact, actionName);
+      }
+    },
+
+    canEdit: function() {
+      // We cannot modify imported contacts.  For the moment, the check for
+      // determining whether the contact is imported is based on its category.
+      return this.props.contact.category[0] != "google";
     },
 
     render: function() {
-      let names = this.getContactNames();
-      let email = this.getPreferredEmail();
+      let names = getContactNames(this.props.contact);
+      let email = getPreferredEmail(this.props.contact);
       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.img({src: navigator.mozLoop.getUserAvatar(email.value)})
-          ), 
+        React.DOM.li({className: contactCSSClass, onMouseLeave: this.hideDropdownMenu}, 
+          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"})
-          )
+            React.DOM.i({className: "icon icon-video", 
+               onClick: this.handleAction.bind(null, "video-call")}), 
+            React.DOM.i({className: "icon icon-caret-down", 
+               onClick: this.showDropdownMenu})
+          ), 
+          this.state.showMenu
+            ? ContactDropdown({handleAction: this.handleAction, 
+                               canEdit: this.canEdit(), 
+                               blocked: this.props.contact.blocked})
+            : null
+          
         )
       );
     }
   });
 
   const ContactsList = React.createClass({displayName: 'ContactsList',
+    mixins: [React.addons.LinkedStateMixin],
+
     getInitialState: function() {
       return {
-        contacts: {}
+        contacts: {},
+        importBusy: false,
+        filter: "",
       };
     },
 
     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);
+            this.handleContactAddOrUpdate(contact, false);
           });
           if (contacts.length) {
             setTimeout(addContactsInChunks, 0);
           }
+          this.forceUpdate();
         };
 
         addContactsInChunks(contacts);
 
         // Listen for contact changes/ updates.
         contactsAPI.on("add", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
@@ -125,74 +256,159 @@ loop.contacts = (function(_, mozL10n) {
           this.handleContactRemoveAll();
         });
         contactsAPI.on("update", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
       });
     },
 
-    handleContactAddOrUpdate: function(contact) {
+    handleContactAddOrUpdate: function(contact, render = true) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       contacts[guid] = contact;
-      this.setState({});
+      if (render) {
+        this.forceUpdate();
+      }
     },
 
     handleContactRemove: function(contact) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       if (!contacts[guid]) {
         return;
       }
       delete contacts[guid];
-      this.setState({});
+      this.forceUpdate();
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
     handleImportButtonClick: function() {
+      this.setState({ importBusy: true });
+      navigator.mozLoop.startImport({
+        service: "google"
+      }, (err, stats) => {
+        this.setState({ importBusy: false });
+        // TODO: bug 1076764 - proper error and success reporting.
+        if (err) {
+          throw err;
+        }
+      });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
+    handleContactAction: function(contact, actionName) {
+      switch (actionName) {
+        case "edit":
+          this.props.startForm("contacts_edit", contact);
+          break;
+        case "remove":
+          navigator.mozLoop.confirm(
+            mozL10n.get("confirm_delete_contact_alert"),
+            mozL10n.get("confirm_delete_contact_remove_button"),
+            mozL10n.get("confirm_delete_contact_cancel_button"),
+            (err, result) => {
+              if (err) {
+                throw err;
+              }
+
+              if (!result) {
+                return;
+              }
+
+              navigator.mozLoop.contacts.remove(contact._guid, err => {
+                if (err) {
+                  throw err;
+                }
+              });
+            });
+          break;
+        case "block":
+        case "unblock":
+          // Invoke the API named like the action.
+          navigator.mozLoop.contacts[actionName](contact._guid, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+        case "video-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          break;
+        case "audio-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          break;
+        default:
+          console.error("Unrecognized action: " + actionName);
+          break;
+      }
+    },
+
     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;
     },
 
     render: function() {
       let viewForItem = item => {
-        return ContactDetail({key: item._guid, contact: item})
+        return ContactDetail({key: item._guid, contact: item, 
+                              handleContactAction: this.handleContactAction})
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
-      // Buttons are temporarily hidden using "style".
+      let showFilter = Object.getOwnPropertyNames(this.state.contacts).length >=
+                       MIN_CONTACTS_FOR_FILTERING;
+      if (showFilter) {
+        let filter = this.state.filter.trim().toLocaleLowerCase();
+        if (filter) {
+          let filterFn = contact => {
+            return contact.name[0].toLocaleLowerCase().contains(filter) ||
+                   getPreferredEmail(contact).value.toLocaleLowerCase().contains(filter);
+          };
+          if (shownContacts.available) {
+            shownContacts.available = shownContacts.available.filter(filterFn);
+          }
+          if (shownContacts.blocked) {
+            shownContacts.blocked = shownContacts.blocked.filter(filterFn);
+          }
+        }
+      }
+
+      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         React.DOM.div(null, 
-          React.DOM.div({className: "content-area", style: {display: "none"}}, 
+          React.DOM.div({className: "content-area"}, 
             ButtonGroup(null, 
-              Button({caption: mozL10n.get("import_contacts_button"), 
-                      disabled: true, 
+              Button({caption: this.state.importBusy
+                               ? mozL10n.get("importing_contacts_progress_button")
+                               : mozL10n.get("import_contacts_button"), 
+                      disabled: this.state.importBusy, 
                       onClick: this.handleImportButtonClick}), 
               Button({caption: mozL10n.get("new_contact_button"), 
                       onClick: this.handleAddContactButtonClick})
-            )
+            ), 
+            showFilter ?
+            React.DOM.input({className: "contact-filter", 
+                   placeholder: mozL10n.get("contacts_search_placesholder"), 
+                   valueLink: this.linkState("filter")})
+            : null
           ), 
           React.DOM.ul({className: "contact-list"}, 
             shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
               null, 
             shownContacts.blocked ?
               shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
               null
@@ -215,17 +431,21 @@ loop.contacts = (function(_, mozL10n) {
         pristine: true,
         name: "",
         email: "",
       };
     },
 
     initForm: function(contact) {
       let state = this.getInitialState();
-      state.contact = contact || null;
+      if (contact) {
+        state.contact = contact;
+        state.name = contact.name[0];
+        state.email = contact.email[0].value;
+      }
       this.setState(state);
     },
 
     handleAcceptButtonClick: function() {
       // Allow validity error indicators to be displayed.
       this.setState({
         pristine: false,
       });
@@ -236,16 +456,23 @@ loop.contacts = (function(_, mozL10n) {
       }
 
       this.props.selectTab("contacts");
 
       let contactsAPI = navigator.mozLoop.contacts;
 
       switch (this.props.mode) {
         case "edit":
+          this.state.contact.name[0] = this.state.name.trim();
+          this.state.contact.email[0].value = this.state.email.trim();
+          contactsAPI.update(this.state.contact, err => {
+            if (err) {
+              throw err;
+            }
+          });
           this.setState({
             contact: null,
           });
           break;
         case "add":
           contactsAPI.add({
             id: navigator.mozLoop.generateUUID(),
             name: [this.state.name.trim()],
@@ -267,31 +494,35 @@ loop.contacts = (function(_, mozL10n) {
     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.header(null, this.props.mode == "add"
+                   ? mozL10n.get("add_contact_button")
+                   : mozL10n.get("edit_contact_title")), 
           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"), 
+                    caption: this.props.mode == "add"
+                             ? mozL10n.get("add_contact_button")
+                             : mozL10n.get("edit_contact_done_button"), 
                     onClick: this.handleAcceptButtonClick})
           )
         )
       );
     }
   });
 
   return {
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -8,113 +8,244 @@
 /*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;
+  const CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({
+  // At least this number of contacts should be present for the filter to appear.
+  const MIN_CONTACTS_FOR_FILTERING = 7;
+
+  let getContactNames = function(contact) {
+    // 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 = contact.name[0].split(" ");
+    return {
+      firstName: names.shift(),
+      lastName: names.join(" ")
+    };
+  };
+
+  let getPreferredEmail = function(contact) {
+    // A contact may not contain email addresses, but only a phone number.
+    if (!contact.email || contact.email.length == 0) {
+      return { value: "" };
+    }
+    return contact.email.find(e => e.pref) || contact.email[0];
+  };
+
+  const ContactDropdown = React.createClass({
     propTypes: {
-      handleContactClick: React.PropTypes.func,
-      contact: React.PropTypes.object.isRequired
+      handleAction: React.PropTypes.func.isRequired,
+      canEdit: React.PropTypes.bool
     },
 
-    handleContactClick: function() {
-      if (this.props.handleContactClick) {
-        this.props.handleContactClick(this.props.key);
+    getInitialState: function () {
+      return {
+        openDirUp: false,
+      };
+    },
+
+    componentDidMount: function () {
+      // This method is called once when the dropdown menu is added to the DOM
+      // inside the contact item.  If the menu extends outside of the visible
+      // area of the scrollable list, it is re-rendered in different direction.
+
+      let menuNode = this.getDOMNode();
+      let menuNodeRect = menuNode.getBoundingClientRect();
+
+      let listNode = document.getElementsByClassName("contact-list")[0];
+      let listNodeRect = listNode.getBoundingClientRect();
+
+      if (menuNodeRect.top + menuNodeRect.height >=
+          listNodeRect.top + listNodeRect.height) {
+        this.setState({
+          openDirUp: true,
+        });
       }
     },
 
-    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(" ");
+    onItemClick: function(event) {
+      this.props.handleAction(event.currentTarget.dataset.action);
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+
+      let blockAction = this.props.blocked ? "unblock" : "block";
+      let blockLabel = this.props.blocked ? "unblock_contact_menu_button"
+                                          : "block_contact_menu_button";
+
+      return (
+        <ul className={cx({ "dropdown-menu": true,
+                            "dropdown-menu-up": this.state.openDirUp })}>
+          <li className={cx({ "dropdown-menu-item": true })}
+              onClick={this.onItemClick} data-action="video-call">
+            <i className="icon icon-video-call" />
+            {mozL10n.get("video_call_menu_button")}
+          </li>
+          <li className={cx({ "dropdown-menu-item": true })}
+              onClick={this.onItemClick} data-action="audio-call">
+            <i className="icon icon-audio-call" />
+            {mozL10n.get("audio_call_menu_button")}
+          </li>
+          <li className={cx({ "dropdown-menu-item": true,
+                              "disabled": !this.props.canEdit })}
+              onClick={this.onItemClick} data-action="edit">
+            <i className="icon icon-edit" />
+            {mozL10n.get("edit_contact_menu_button")}
+          </li>
+          <li className="dropdown-menu-item"
+              onClick={this.onItemClick} data-action={blockAction}>
+            <i className={"icon icon-" + blockAction} />
+            {mozL10n.get(blockLabel)}
+          </li>
+          <li className={cx({ "dropdown-menu-item": true,
+                              "disabled": !this.props.canEdit })}
+              onClick={this.onItemClick} data-action="remove">
+            <i className="icon icon-remove" />
+            {mozL10n.get("remove_contact_menu_button")}
+          </li>
+        </ul>
+      );
+    }
+  });
+
+  const ContactDetail = React.createClass({
+    getInitialState: function() {
       return {
-        firstName: names.shift(),
-        lastName: names.join(" ")
+        showMenu: false,
       };
     },
 
-    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;
+    propTypes: {
+      handleContactAction: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    _onBodyClick: function() {
+      // Hide the menu after other click handlers have been invoked.
+      setTimeout(this.hideDropdownMenu, 10);
+    },
+
+    showDropdownMenu: function() {
+      document.body.addEventListener("click", this._onBodyClick);
+      this.setState({showMenu: true});
+    },
+
+    hideDropdownMenu: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+      // Since this call may be deferred, we need to guard it, for example in
+      // case the contact was removed in the meantime.
+      if (this.isMounted()) {
+        this.setState({showMenu: false});
+      }
+    },
+
+    componentWillUnmount: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+    },
+
+    componentShouldUpdate: function(nextProps, nextState) {
+      let currContact = this.props.contact;
+      let nextContact = nextProps.contact;
+      return (
+        currContact.name[0] !== nextContact.name[0] ||
+        currContact.blocked !== nextContact.blocked ||
+        getPreferredEmail(currContact).value !==
+          getPreferredEmail(nextContact).value
+      );
+    },
+
+    handleAction: function(actionName) {
+      if (this.props.handleContactAction) {
+        this.props.handleContactAction(this.props.contact, actionName);
+      }
+    },
+
+    canEdit: function() {
+      // We cannot modify imported contacts.  For the moment, the check for
+      // determining whether the contact is imported is based on its category.
+      return this.props.contact.category[0] != "google";
     },
 
     render: function() {
-      let names = this.getContactNames();
-      let email = this.getPreferredEmail();
+      let names = getContactNames(this.props.contact);
+      let email = getPreferredEmail(this.props.contact);
       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">
-            <img src={navigator.mozLoop.getUserAvatar(email.value)} />
-          </div>
+        <li className={contactCSSClass} onMouseLeave={this.hideDropdownMenu}>
+          <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" />
+            <i className="icon icon-video"
+               onClick={this.handleAction.bind(null, "video-call")} />
+            <i className="icon icon-caret-down"
+               onClick={this.showDropdownMenu} />
           </div>
+          {this.state.showMenu
+            ? <ContactDropdown handleAction={this.handleAction}
+                               canEdit={this.canEdit()}
+                               blocked={this.props.contact.blocked} />
+            : null
+          }
         </li>
       );
     }
   });
 
   const ContactsList = React.createClass({
+    mixins: [React.addons.LinkedStateMixin],
+
     getInitialState: function() {
       return {
-        contacts: {}
+        contacts: {},
+        importBusy: false,
+        filter: "",
       };
     },
 
     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);
+            this.handleContactAddOrUpdate(contact, false);
           });
           if (contacts.length) {
             setTimeout(addContactsInChunks, 0);
           }
+          this.forceUpdate();
         };
 
         addContactsInChunks(contacts);
 
         // Listen for contact changes/ updates.
         contactsAPI.on("add", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
@@ -125,74 +256,159 @@ loop.contacts = (function(_, mozL10n) {
           this.handleContactRemoveAll();
         });
         contactsAPI.on("update", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
       });
     },
 
-    handleContactAddOrUpdate: function(contact) {
+    handleContactAddOrUpdate: function(contact, render = true) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       contacts[guid] = contact;
-      this.setState({});
+      if (render) {
+        this.forceUpdate();
+      }
     },
 
     handleContactRemove: function(contact) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       if (!contacts[guid]) {
         return;
       }
       delete contacts[guid];
-      this.setState({});
+      this.forceUpdate();
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
     handleImportButtonClick: function() {
+      this.setState({ importBusy: true });
+      navigator.mozLoop.startImport({
+        service: "google"
+      }, (err, stats) => {
+        this.setState({ importBusy: false });
+        // TODO: bug 1076764 - proper error and success reporting.
+        if (err) {
+          throw err;
+        }
+      });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
+    handleContactAction: function(contact, actionName) {
+      switch (actionName) {
+        case "edit":
+          this.props.startForm("contacts_edit", contact);
+          break;
+        case "remove":
+          navigator.mozLoop.confirm(
+            mozL10n.get("confirm_delete_contact_alert"),
+            mozL10n.get("confirm_delete_contact_remove_button"),
+            mozL10n.get("confirm_delete_contact_cancel_button"),
+            (err, result) => {
+              if (err) {
+                throw err;
+              }
+
+              if (!result) {
+                return;
+              }
+
+              navigator.mozLoop.contacts.remove(contact._guid, err => {
+                if (err) {
+                  throw err;
+                }
+              });
+            });
+          break;
+        case "block":
+        case "unblock":
+          // Invoke the API named like the action.
+          navigator.mozLoop.contacts[actionName](contact._guid, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+        case "video-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          break;
+        case "audio-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          break;
+        default:
+          console.error("Unrecognized action: " + actionName);
+          break;
+      }
+    },
+
     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;
     },
 
     render: function() {
       let viewForItem = item => {
-        return <ContactDetail key={item._guid} contact={item} />
+        return <ContactDetail key={item._guid} contact={item}
+                              handleContactAction={this.handleContactAction} />
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
-      // Buttons are temporarily hidden using "style".
+      let showFilter = Object.getOwnPropertyNames(this.state.contacts).length >=
+                       MIN_CONTACTS_FOR_FILTERING;
+      if (showFilter) {
+        let filter = this.state.filter.trim().toLocaleLowerCase();
+        if (filter) {
+          let filterFn = contact => {
+            return contact.name[0].toLocaleLowerCase().contains(filter) ||
+                   getPreferredEmail(contact).value.toLocaleLowerCase().contains(filter);
+          };
+          if (shownContacts.available) {
+            shownContacts.available = shownContacts.available.filter(filterFn);
+          }
+          if (shownContacts.blocked) {
+            shownContacts.blocked = shownContacts.blocked.filter(filterFn);
+          }
+        }
+      }
+
+      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         <div>
-          <div className="content-area" style={{display: "none"}}>
+          <div className="content-area">
             <ButtonGroup>
-              <Button caption={mozL10n.get("import_contacts_button")}
-                      disabled
+              <Button caption={this.state.importBusy
+                               ? mozL10n.get("importing_contacts_progress_button")
+                               : mozL10n.get("import_contacts_button")}
+                      disabled={this.state.importBusy}
                       onClick={this.handleImportButtonClick} />
               <Button caption={mozL10n.get("new_contact_button")}
                       onClick={this.handleAddContactButtonClick} />
             </ButtonGroup>
+            {showFilter ?
+            <input className="contact-filter"
+                   placeholder={mozL10n.get("contacts_search_placesholder")}
+                   valueLink={this.linkState("filter")} />
+            : null }
           </div>
           <ul className="contact-list">
             {shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
               null}
             {shownContacts.blocked ?
               shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
               null}
@@ -215,17 +431,21 @@ loop.contacts = (function(_, mozL10n) {
         pristine: true,
         name: "",
         email: "",
       };
     },
 
     initForm: function(contact) {
       let state = this.getInitialState();
-      state.contact = contact || null;
+      if (contact) {
+        state.contact = contact;
+        state.name = contact.name[0];
+        state.email = contact.email[0].value;
+      }
       this.setState(state);
     },
 
     handleAcceptButtonClick: function() {
       // Allow validity error indicators to be displayed.
       this.setState({
         pristine: false,
       });
@@ -236,16 +456,23 @@ loop.contacts = (function(_, mozL10n) {
       }
 
       this.props.selectTab("contacts");
 
       let contactsAPI = navigator.mozLoop.contacts;
 
       switch (this.props.mode) {
         case "edit":
+          this.state.contact.name[0] = this.state.name.trim();
+          this.state.contact.email[0].value = this.state.email.trim();
+          contactsAPI.update(this.state.contact, err => {
+            if (err) {
+              throw err;
+            }
+          });
           this.setState({
             contact: null,
           });
           break;
         case "add":
           contactsAPI.add({
             id: navigator.mozLoop.generateUUID(),
             name: [this.state.name.trim()],
@@ -267,31 +494,35 @@ loop.contacts = (function(_, mozL10n) {
     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>
+          <header>{this.props.mode == "add"
+                   ? mozL10n.get("add_contact_button")
+                   : mozL10n.get("edit_contact_title")}</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")}
+                    caption={this.props.mode == "add"
+                             ? mozL10n.get("add_contact_button")
+                             : mozL10n.get("edit_contact_done_button")}
                     onClick={this.handleAcceptButtonClick} />
           </ButtonGroup>
         </div>
       );
     }
   });
 
   return {
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -6,47 +6,36 @@
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
       return {
-        showDeclineMenu: false,
+        showMenu: false,
         video: true
       };
     },
 
-    getInitialState: function() {
-      return {showDeclineMenu: this.props.showDeclineMenu};
-    },
-
-    componentDidMount: function() {
-      window.addEventListener("click", this.clickHandler);
-      window.addEventListener("blur", this._hideDeclineMenu);
-    },
-
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      window.removeEventListener("blur", this._hideDeclineMenu);
-    },
-
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -62,25 +51,16 @@ loop.conversation = (function(mozL10n) {
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
-    _toggleDeclineMenu: function() {
-      var currentState = this.state.showDeclineMenu;
-      this.setState({showDeclineMenu: !currentState});
-    },
-
-    _hideDeclineMenu: function() {
-      this.setState({showDeclineMenu: false});
-    },
-
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
         handler: this._handleAccept("audio-video"),
@@ -103,58 +83,53 @@ loop.conversation = (function(mozL10n) {
         props.secondary = videoButton;
       }
 
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-accept";
-      var btnClassDecline = "btn btn-error btn-decline";
-      var conversationPanelClass = "incoming-call";
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
-        "visually-hidden": !this.state.showDeclineMenu
+        "visually-hidden": !this.state.showMenu
       });
       return (
-        React.DOM.div({className: conversationPanelClass}, 
+        React.DOM.div({className: "call-window"}, 
           React.DOM.h2(null, mozL10n.get("incoming_call_title2")), 
-          React.DOM.div({className: "btn-group incoming-call-action-group"}, 
+          React.DOM.div({className: "btn-group call-action-group"}, 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"}), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             React.DOM.div({className: "btn-chevron-menu-group"}, 
               React.DOM.div({className: "btn-group-chevron"}, 
                 React.DOM.div({className: "btn-group"}, 
 
-                  React.DOM.button({className: btnClassDecline, 
+                  React.DOM.button({className: "btn btn-decline", 
                           onClick: this._handleDecline}, 
                     mozL10n.get("incoming_call_cancel_button")
                   ), 
-                  React.DOM.div({className: "btn-chevron", 
-                       onClick: this._toggleDeclineMenu}
-                  )
+                  React.DOM.div({className: "btn-chevron", onClick: this.toggleDropdownMenu})
                 ), 
 
                 React.DOM.ul({className: dropdownMenuClassesDecline}, 
                   React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock}, 
                     mozL10n.get("incoming_call_cancel_and_block_button")
                   )
                 )
 
               )
             ), 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"}), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             AcceptCallButton({mode: this._answerModeProps()}), 
 
-            React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"})
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
 
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
@@ -198,25 +173,24 @@ loop.conversation = (function(mozL10n) {
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
-      notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
-                          .isRequired,
       sdk: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
+        callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
-      }
+      };
     },
 
     componentDidMount: function() {
       this.props.conversation.on("accept", this.accept, this);
       this.props.conversation.on("decline", this.decline, this);
       this.props.conversation.on("declineAndBlock", this.declineAndBlock, this);
       this.props.conversation.on("call:accepted", this.accepted, this);
       this.props.conversation.on("change:publishedStream", this._checkConnected, this);
@@ -263,17 +237,22 @@ loop.conversation = (function(mozL10n) {
               initiate: true, 
               sdk: this.props.sdk, 
               model: this.props.conversation, 
               video: {enabled: callType !== "audio"}}
             )
           );
         }
         case "end": {
-          document.title = mozL10n.get("conversation_has_ended");
+          // XXX To be handled with the "failed" view state when bug 1047410 lands
+          if (this.state.callFailed) {
+            document.title = mozL10n.get("generic_failure_title");
+          } else {
+            document.title = mozL10n.get("conversation_has_ended");
+          }
 
           var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
             "feedback.baseUrl");
 
           var appVersionInfo = navigator.mozLoop.appVersionInfo;
 
           var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
             product: navigator.mozLoop.getLoopCharPref("feedback.product"),
@@ -296,52 +275,52 @@ loop.conversation = (function(mozL10n) {
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
       console.error(error);
-      this.props.notifications.errorL10n("connection_error_see_console_notification");
-      this.setState({callStatus: "end"});
+      this.setState({callFailed: true, callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
      * - {String} connectionId: OT session id
      */
     _onPeerHungup: function() {
-      this.props.notifications.warnL10n("peer_ended_conversation2");
-      this.setState({callStatus: "end"});
+      this.setState({callFailed: false, callStatus: "end"});
     },
 
     /**
      * Network disconnected. Notifies the user and ends the call.
      */
     _onNetworkDisconnected: function() {
-      this.props.notifications.warnL10n("network_disconnected");
-      this.setState({callStatus: "end"});
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
+      this.setState({callFailed: true, callStatus: "end"});
     },
 
     /**
      * Incoming call route.
      */
     setupIncomingCall: function() {
       navigator.mozLoop.startAlerting();
 
       var callData = navigator.mozLoop.getCallData(this.props.conversation.get("callId"));
       if (!callData) {
-        console.error("Failed to get the call data");
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
-        this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
+        console.error("Failed to get the call data");
         return;
       }
       this.props.conversation.setIncomingSessionData(callData);
       this._setupWebSocket();
     },
 
     /**
      * Starts the actual conversation
@@ -363,18 +342,20 @@ loop.conversation = (function(mozL10n) {
      * call view if appropriate.
      */
     _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this.props.conversation.get("progressURL"),
         websocketToken: this.props.conversation.get("websocketToken"),
         callId: this.props.conversation.get("callId"),
       });
-      this._websocket.promiseConnect().then(function() {
-        this.setState({callStatus: "incoming"});
+      this._websocket.promiseConnect().then(function(progressStatus) {
+        this.setState({
+          callStatus: progressStatus === "terminated" ? "close" : "incoming"
+        });
       }.bind(this), function() {
         this._handleSessionError();
         return;
       }.bind(this));
 
       this._websocket.on("progress", this._handleWebSocketProgress, this);
     },
 
@@ -478,22 +459,70 @@ loop.conversation = (function(mozL10n) {
     },
 
     /**
      * Handles a error starting the session
      */
     _handleSessionError: function() {
       // XXX Not the ideal response, but bug 1047410 will be replacing
       // this by better "call failed" UI.
-      this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
+      console.error("Failed initiating the call session.");
     },
   });
 
   /**
-   * Panel initialisation.
+   * Master controller view for handling if incoming or outgoing calls are
+   * in progress, and hence, which view to display.
+   */
+  var ConversationControllerView = React.createClass({displayName: 'ConversationControllerView',
+    propTypes: {
+      // XXX Old types required for incoming call view.
+      client: React.PropTypes.instanceOf(loop.Client).isRequired,
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+
+      // XXX New types for OutgoingConversationView
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    componentWillMount: function() {
+      this.props.store.on("change:outgoing", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
+    render: function() {
+      // Don't display anything, until we know what type of call we are.
+      if (this.state.outgoing === undefined) {
+        return null;
+      }
+
+      if (this.state.outgoing) {
+        return (OutgoingConversationView({
+          store: this.props.store, 
+          dispatcher: this.props.dispatcher}
+        ));
+      }
+
+      return (IncomingConversationView({
+        client: this.props.client, 
+        conversation: this.props.conversation, 
+        sdk: this.props.sdk}
+      ));
+    }
+  });
+
+  /**
+   * Conversation initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
     // don't work in the conversation window
@@ -502,45 +531,78 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.Client();
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
-    var client = new loop.Client();
+    var conversationStore = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
+    });
+
+    // XXX Old class creation for the incoming conversation view, whilst
+    // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
-    var notifications = new sharedModels.NotificationCollection();
+
+    // Obtain the callId and pass it through
+    var helper = new loop.shared.utils.Helper();
+    var locationHash = helper.locationHash();
+    var callId;
+    var outgoing;
+
+    var hash = locationHash.match(/\#incoming\/(.*)/);
+    if (hash) {
+      callId = hash[1];
+      outgoing = false;
+    } else {
+      hash = locationHash.match(/\#outgoing\/(.*)/);
+      if (hash) {
+        callId = hash[1];
+        outgoing = true;
+      }
+    }
+
+    conversation.set({callId: callId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+      navigator.mozLoop.releaseCallData(callId);
     });
 
-    // Obtain the callId and pass it to the conversation
-    var helper = new loop.shared.utils.Helper();
-    var locationHash = helper.locationHash();
-    if (locationHash) {
-      conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
-    }
+    document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
-    React.renderComponent(IncomingConversationView({
+    React.renderComponent(ConversationControllerView({
+      store: conversationStore, 
       client: client, 
       conversation: conversation, 
-      notifications: notifications, 
+      dispatcher: dispatcher, 
       sdk: window.OT}
     ), document.querySelector('#main'));
+
+    dispatcher.dispatch(new loop.shared.actions.GatherCallData({
+      callId: callId,
+      outgoing: outgoing
+    }));
   }
 
   return {
+    ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -6,47 +6,36 @@
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
       return {
-        showDeclineMenu: false,
+        showMenu: false,
         video: true
       };
     },
 
-    getInitialState: function() {
-      return {showDeclineMenu: this.props.showDeclineMenu};
-    },
-
-    componentDidMount: function() {
-      window.addEventListener("click", this.clickHandler);
-      window.addEventListener("blur", this._hideDeclineMenu);
-    },
-
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      window.removeEventListener("blur", this._hideDeclineMenu);
-    },
-
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -62,25 +51,16 @@ loop.conversation = (function(mozL10n) {
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
-    _toggleDeclineMenu: function() {
-      var currentState = this.state.showDeclineMenu;
-      this.setState({showDeclineMenu: !currentState});
-    },
-
-    _hideDeclineMenu: function() {
-      this.setState({showDeclineMenu: false});
-    },
-
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
         handler: this._handleAccept("audio-video"),
@@ -103,58 +83,53 @@ loop.conversation = (function(mozL10n) {
         props.secondary = videoButton;
       }
 
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-accept";
-      var btnClassDecline = "btn btn-error btn-decline";
-      var conversationPanelClass = "incoming-call";
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
-        "visually-hidden": !this.state.showDeclineMenu
+        "visually-hidden": !this.state.showMenu
       });
       return (
-        <div className={conversationPanelClass}>
+        <div className="call-window">
           <h2>{mozL10n.get("incoming_call_title2")}</h2>
-          <div className="btn-group incoming-call-action-group">
+          <div className="btn-group call-action-group">
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
                 <div className="btn-group">
 
-                  <button className={btnClassDecline}
+                  <button className="btn btn-decline"
                           onClick={this._handleDecline}>
                     {mozL10n.get("incoming_call_cancel_button")}
                   </button>
-                  <div className="btn-chevron"
-                       onClick={this._toggleDeclineMenu}>
-                  </div>
+                  <div className="btn-chevron" onClick={this.toggleDropdownMenu} />
                 </div>
 
                 <ul className={dropdownMenuClassesDecline}>
                   <li className="btn-block" onClick={this._handleDeclineBlock}>
                     {mozL10n.get("incoming_call_cancel_and_block_button")}
                   </li>
                 </ul>
 
               </div>
             </div>
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
             <AcceptCallButton mode={this._answerModeProps()} />
 
-            <div className="fx-embedded-incoming-call-button-spacer"></div>
+            <div className="fx-embedded-call-button-spacer"></div>
 
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
@@ -198,25 +173,24 @@ loop.conversation = (function(mozL10n) {
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
-      notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
-                          .isRequired,
       sdk: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
+        callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
-      }
+      };
     },
 
     componentDidMount: function() {
       this.props.conversation.on("accept", this.accept, this);
       this.props.conversation.on("decline", this.decline, this);
       this.props.conversation.on("declineAndBlock", this.declineAndBlock, this);
       this.props.conversation.on("call:accepted", this.accepted, this);
       this.props.conversation.on("change:publishedStream", this._checkConnected, this);
@@ -263,17 +237,22 @@ loop.conversation = (function(mozL10n) {
               initiate={true}
               sdk={this.props.sdk}
               model={this.props.conversation}
               video={{enabled: callType !== "audio"}}
             />
           );
         }
         case "end": {
-          document.title = mozL10n.get("conversation_has_ended");
+          // XXX To be handled with the "failed" view state when bug 1047410 lands
+          if (this.state.callFailed) {
+            document.title = mozL10n.get("generic_failure_title");
+          } else {
+            document.title = mozL10n.get("conversation_has_ended");
+          }
 
           var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
             "feedback.baseUrl");
 
           var appVersionInfo = navigator.mozLoop.appVersionInfo;
 
           var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
             product: navigator.mozLoop.getLoopCharPref("feedback.product"),
@@ -296,52 +275,52 @@ loop.conversation = (function(mozL10n) {
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
       console.error(error);
-      this.props.notifications.errorL10n("connection_error_see_console_notification");
-      this.setState({callStatus: "end"});
+      this.setState({callFailed: true, callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
      * - {String} connectionId: OT session id
      */
     _onPeerHungup: function() {
-      this.props.notifications.warnL10n("peer_ended_conversation2");
-      this.setState({callStatus: "end"});
+      this.setState({callFailed: false, callStatus: "end"});
     },
 
     /**
      * Network disconnected. Notifies the user and ends the call.
      */
     _onNetworkDisconnected: function() {
-      this.props.notifications.warnL10n("network_disconnected");
-      this.setState({callStatus: "end"});
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
+      this.setState({callFailed: true, callStatus: "end"});
     },
 
     /**
      * Incoming call route.
      */
     setupIncomingCall: function() {
       navigator.mozLoop.startAlerting();
 
       var callData = navigator.mozLoop.getCallData(this.props.conversation.get("callId"));
       if (!callData) {
-        console.error("Failed to get the call data");
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
-        this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
+        console.error("Failed to get the call data");
         return;
       }
       this.props.conversation.setIncomingSessionData(callData);
       this._setupWebSocket();
     },
 
     /**
      * Starts the actual conversation
@@ -363,18 +342,20 @@ loop.conversation = (function(mozL10n) {
      * call view if appropriate.
      */
     _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this.props.conversation.get("progressURL"),
         websocketToken: this.props.conversation.get("websocketToken"),
         callId: this.props.conversation.get("callId"),
       });
-      this._websocket.promiseConnect().then(function() {
-        this.setState({callStatus: "incoming"});
+      this._websocket.promiseConnect().then(function(progressStatus) {
+        this.setState({
+          callStatus: progressStatus === "terminated" ? "close" : "incoming"
+        });
       }.bind(this), function() {
         this._handleSessionError();
         return;
       }.bind(this));
 
       this._websocket.on("progress", this._handleWebSocketProgress, this);
     },
 
@@ -478,22 +459,70 @@ loop.conversation = (function(mozL10n) {
     },
 
     /**
      * Handles a error starting the session
      */
     _handleSessionError: function() {
       // XXX Not the ideal response, but bug 1047410 will be replacing
       // this by better "call failed" UI.
-      this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
+      console.error("Failed initiating the call session.");
     },
   });
 
   /**
-   * Panel initialisation.
+   * Master controller view for handling if incoming or outgoing calls are
+   * in progress, and hence, which view to display.
+   */
+  var ConversationControllerView = React.createClass({
+    propTypes: {
+      // XXX Old types required for incoming call view.
+      client: React.PropTypes.instanceOf(loop.Client).isRequired,
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+
+      // XXX New types for OutgoingConversationView
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    componentWillMount: function() {
+      this.props.store.on("change:outgoing", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
+    render: function() {
+      // Don't display anything, until we know what type of call we are.
+      if (this.state.outgoing === undefined) {
+        return null;
+      }
+
+      if (this.state.outgoing) {
+        return (<OutgoingConversationView
+          store={this.props.store}
+          dispatcher={this.props.dispatcher}
+        />);
+      }
+
+      return (<IncomingConversationView
+        client={this.props.client}
+        conversation={this.props.conversation}
+        sdk={this.props.sdk}
+      />);
+    }
+  });
+
+  /**
+   * Conversation initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
     // don't work in the conversation window
@@ -502,45 +531,78 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    document.body.classList.add(loop.shared.utils.getTargetPlatform());
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.Client();
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
-    var client = new loop.Client();
+    var conversationStore = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
+    });
+
+    // XXX Old class creation for the incoming conversation view, whilst
+    // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
-    var notifications = new sharedModels.NotificationCollection();
+
+    // Obtain the callId and pass it through
+    var helper = new loop.shared.utils.Helper();
+    var locationHash = helper.locationHash();
+    var callId;
+    var outgoing;
+
+    var hash = locationHash.match(/\#incoming\/(.*)/);
+    if (hash) {
+      callId = hash[1];
+      outgoing = false;
+    } else {
+      hash = locationHash.match(/\#outgoing\/(.*)/);
+      if (hash) {
+        callId = hash[1];
+        outgoing = true;
+      }
+    }
+
+    conversation.set({callId: callId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+      navigator.mozLoop.releaseCallData(callId);
     });
 
-    // Obtain the callId and pass it to the conversation
-    var helper = new loop.shared.utils.Helper();
-    var locationHash = helper.locationHash();
-    if (locationHash) {
-      conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
-    }
+    document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
-    React.renderComponent(<IncomingConversationView
+    React.renderComponent(<ConversationControllerView
+      store={conversationStore}
       client={client}
       conversation={conversation}
-      notifications={notifications}
+      dispatcher={dispatcher}
       sdk={window.OT}
     />, document.querySelector('#main'));
+
+    dispatcher.dispatch(new loop.shared.actions.GatherCallData({
+      callId: callId,
+      outgoing: outgoing
+    }));
   }
 
   return {
+    ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -0,0 +1,391 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.conversationViews = (function(mozL10n) {
+
+  var CALL_STATES = loop.store.CALL_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var sharedActions = loop.shared.actions;
+  var sharedViews = loop.shared.views;
+
+  /**
+   * Displays details of the incoming/outgoing conversation
+   * (name, link, audio/video type etc).
+   *
+   * Allows the view to be extended with different buttons and progress
+   * via children properties.
+   */
+  var ConversationDetailView = React.createClass({displayName: 'ConversationDetailView',
+    propTypes: {
+      contact: React.PropTypes.object
+    },
+
+    // This duplicates a similar function in contacts.jsx that isn't used in the
+    // conversation window. If we get too many of these, we might want to consider
+    // finding a logical place for them to be shared.
+    _getPreferredEmail: function(contact) {
+      // A contact may not contain email addresses, but only a phone number.
+      if (!contact.email || contact.email.length == 0) {
+        return { value: "" };
+      }
+      return contact.email.find(e => e.pref) || contact.email[0];
+    },
+
+    render: function() {
+      var contactName;
+
+      if (this.props.contact.name &&
+          this.props.contact.name[0]) {
+        contactName = this.props.contact.name[0];
+      } else {
+        contactName = this._getPreferredEmail(this.props.contact).value;
+      }
+
+      document.title = contactName;
+
+      return (
+        React.DOM.div({className: "call-window"}, 
+          React.DOM.h2(null, contactName), 
+          React.DOM.div(null, this.props.children)
+        )
+      );
+    }
+  });
+
+  /**
+   * View for pending conversations. Displays a cancel button and appropriate
+   * pending/ringing strings.
+   */
+  var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      callState: React.PropTypes.string,
+      contact: React.PropTypes.object,
+      enableCancelButton: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        enableCancelButton: false
+      };
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var pendingStateString;
+      if (this.props.callState === CALL_STATES.ALERTING) {
+        pendingStateString = mozL10n.get("call_progress_ringing_description");
+      } else {
+        pendingStateString = mozL10n.get("call_progress_connecting_description");
+      }
+
+      var btnCancelStyles = cx({
+        "btn": true,
+        "btn-cancel": true,
+        "disabled": !this.props.enableCancelButton
+      });
+
+      return (
+        ConversationDetailView({contact: this.props.contact}, 
+
+          React.DOM.p({className: "btn-label"}, pendingStateString), 
+
+          React.DOM.div({className: "btn-group call-action-group"}, 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: btnCancelStyles, 
+                      onClick: this.cancelCall}, 
+                mozL10n.get("initiate_call_cancel_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
+          )
+
+        )
+      );
+    }
+  });
+
+  /**
+   * Call failed view. Displayed when a call fails.
+   */
+  var CallFailedView = React.createClass({displayName: 'CallFailedView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    retryCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryCall());
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
+    render: function() {
+      return (
+        React.DOM.div({className: "call-window"}, 
+          React.DOM.h2(null, mozL10n.get("generic_failure_title")), 
+
+          React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_no_reason2")), 
+
+          React.DOM.div({className: "btn-group call-action-group"}, 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: "btn btn-accept btn-retry", 
+                      onClick: this.retryCall}, 
+                mozL10n.get("retry_call_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: "btn btn-cancel", 
+                      onClick: this.cancelCall}, 
+                mozL10n.get("cancel_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
+          )
+        )
+      );
+    }
+  });
+
+  var OngoingConversationView = React.createClass({displayName: 'OngoingConversationView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object
+    },
+
+    getDefaultProps: function() {
+      return {
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
+      };
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener('orientationchange', this.updateVideoContainer);
+      window.removeEventListener('resize', this.updateVideoContainer);
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Returns the required configuration for publishing video on the sdk.
+     */
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: this.props.video.enabled,
+        style: {
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off"
+        }
+      }
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Hangs up the call.
+     */
+    hangup: function() {
+      this.props.dispatcher.dispatch(
+        new sharedActions.HangupCall());
+    },
+
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
+    },
+
+    render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.props.video.enabled
+      });
+
+      return (
+        React.DOM.div({className: "video-layout-wrapper"}, 
+          React.DOM.div({className: "conversation"}, 
+            React.DOM.div({className: "media nested"}, 
+              React.DOM.div({className: "video_wrapper remote_wrapper"}, 
+                React.DOM.div({className: "video_inner remote"})
+              ), 
+              React.DOM.div({className: localStreamClasses})
+            ), 
+            loop.shared.views.ConversationToolbar({
+              video: this.props.video, 
+              audio: this.props.audio, 
+              publishStream: this.publishStream, 
+              hangup: this.hangup})
+          )
+        )
+      );
+    }
+  });
+
+  /**
+   * Master View Controller for outgoing calls. This manages
+   * the different views that need displaying.
+   */
+  var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      store: React.PropTypes.instanceOf(
+        loop.store.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    componentWillMount: function() {
+      this.props.store.on("change", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
+    _closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Returns true if the call is in a cancellable state, during call setup.
+     */
+    _isCancellable: function() {
+      return this.state.callState !== CALL_STATES.INIT &&
+             this.state.callState !== CALL_STATES.GATHER;
+    },
+
+    /**
+     * Used to setup and render the feedback view.
+     */
+    _renderFeedbackView: function() {
+      document.title = mozL10n.get("conversation_has_ended");
+
+      // XXX Bug 1076754 Feedback view should be redone in the Flux style.
+      var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
+        "feedback.baseUrl");
+
+      var appVersionInfo = navigator.mozLoop.appVersionInfo;
+
+      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
+        product: navigator.mozLoop.getLoopCharPref("feedback.product"),
+        platform: appVersionInfo.OS,
+        channel: appVersionInfo.channel,
+        version: appVersionInfo.version
+      });
+
+      return (
+        sharedViews.FeedbackView({
+          feedbackApiClient: feedbackClient, 
+          onAfterFeedbackReceived: this._closeWindow.bind(this)}
+        )
+      );
+    },
+
+    render: function() {
+      switch (this.state.callState) {
+        case CALL_STATES.CLOSE: {
+          this._closeWindow();
+          return null;
+        }
+        case CALL_STATES.TERMINATED: {
+          return (CallFailedView({
+            dispatcher: this.props.dispatcher}
+          ));
+        }
+        case CALL_STATES.ONGOING: {
+          return (OngoingConversationView({
+            dispatcher: this.props.dispatcher, 
+            video: {enabled: !this.state.videoMuted}, 
+            audio: {enabled: !this.state.audioMuted}}
+            )
+          );
+        }
+        case CALL_STATES.FINISHED: {
+          return this._renderFeedbackView();
+        }
+        default: {
+          return (PendingConversationView({
+            dispatcher: this.props.dispatcher, 
+            callState: this.state.callState, 
+            contact: this.state.contact, 
+            enableCancelButton: this._isCancellable()}
+          ))
+        }
+      }
+    },
+  });
+
+  return {
+    PendingConversationView: PendingConversationView,
+    ConversationDetailView: ConversationDetailView,
+    CallFailedView: CallFailedView,
+    OngoingConversationView: OngoingConversationView,
+    OutgoingConversationView: OutgoingConversationView
+  };
+
+})(document.mozL10n || navigator.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -0,0 +1,391 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.conversationViews = (function(mozL10n) {
+
+  var CALL_STATES = loop.store.CALL_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var sharedActions = loop.shared.actions;
+  var sharedViews = loop.shared.views;
+
+  /**
+   * Displays details of the incoming/outgoing conversation
+   * (name, link, audio/video type etc).
+   *
+   * Allows the view to be extended with different buttons and progress
+   * via children properties.
+   */
+  var ConversationDetailView = React.createClass({
+    propTypes: {
+      contact: React.PropTypes.object
+    },
+
+    // This duplicates a similar function in contacts.jsx that isn't used in the
+    // conversation window. If we get too many of these, we might want to consider
+    // finding a logical place for them to be shared.
+    _getPreferredEmail: function(contact) {
+      // A contact may not contain email addresses, but only a phone number.
+      if (!contact.email || contact.email.length == 0) {
+        return { value: "" };
+      }
+      return contact.email.find(e => e.pref) || contact.email[0];
+    },
+
+    render: function() {
+      var contactName;
+
+      if (this.props.contact.name &&
+          this.props.contact.name[0]) {
+        contactName = this.props.contact.name[0];
+      } else {
+        contactName = this._getPreferredEmail(this.props.contact).value;
+      }
+
+      document.title = contactName;
+
+      return (
+        <div className="call-window">
+          <h2>{contactName}</h2>
+          <div>{this.props.children}</div>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * View for pending conversations. Displays a cancel button and appropriate
+   * pending/ringing strings.
+   */
+  var PendingConversationView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      callState: React.PropTypes.string,
+      contact: React.PropTypes.object,
+      enableCancelButton: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        enableCancelButton: false
+      };
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var pendingStateString;
+      if (this.props.callState === CALL_STATES.ALERTING) {
+        pendingStateString = mozL10n.get("call_progress_ringing_description");
+      } else {
+        pendingStateString = mozL10n.get("call_progress_connecting_description");
+      }
+
+      var btnCancelStyles = cx({
+        "btn": true,
+        "btn-cancel": true,
+        "disabled": !this.props.enableCancelButton
+      });
+
+      return (
+        <ConversationDetailView contact={this.props.contact}>
+
+          <p className="btn-label">{pendingStateString}</p>
+
+          <div className="btn-group call-action-group">
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className={btnCancelStyles}
+                      onClick={this.cancelCall}>
+                {mozL10n.get("initiate_call_cancel_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+          </div>
+
+        </ConversationDetailView>
+      );
+    }
+  });
+
+  /**
+   * Call failed view. Displayed when a call fails.
+   */
+  var CallFailedView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    retryCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryCall());
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
+    render: function() {
+      return (
+        <div className="call-window">
+          <h2>{mozL10n.get("generic_failure_title")}</h2>
+
+          <p className="btn-label">{mozL10n.get("generic_failure_no_reason2")}</p>
+
+          <div className="btn-group call-action-group">
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className="btn btn-accept btn-retry"
+                      onClick={this.retryCall}>
+                {mozL10n.get("retry_call_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className="btn btn-cancel"
+                      onClick={this.cancelCall}>
+                {mozL10n.get("cancel_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  var OngoingConversationView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object
+    },
+
+    getDefaultProps: function() {
+      return {
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
+      };
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener('orientationchange', this.updateVideoContainer);
+      window.removeEventListener('resize', this.updateVideoContainer);
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Returns the required configuration for publishing video on the sdk.
+     */
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: this.props.video.enabled,
+        style: {
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off"
+        }
+      }
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Hangs up the call.
+     */
+    hangup: function() {
+      this.props.dispatcher.dispatch(
+        new sharedActions.HangupCall());
+    },
+
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
+    },
+
+    render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.props.video.enabled
+      });
+
+      return (
+        <div className="video-layout-wrapper">
+          <div className="conversation">
+            <div className="media nested">
+              <div className="video_wrapper remote_wrapper">
+                <div className="video_inner remote"></div>
+              </div>
+              <div className={localStreamClasses}></div>
+            </div>
+            <loop.shared.views.ConversationToolbar
+              video={this.props.video}
+              audio={this.props.audio}
+              publishStream={this.publishStream}
+              hangup={this.hangup} />
+          </div>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * Master View Controller for outgoing calls. This manages
+   * the different views that need displaying.
+   */
+  var OutgoingConversationView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      store: React.PropTypes.instanceOf(
+        loop.store.ConversationStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.store.attributes;
+    },
+
+    componentWillMount: function() {
+      this.props.store.on("change", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
+    _closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Returns true if the call is in a cancellable state, during call setup.
+     */
+    _isCancellable: function() {
+      return this.state.callState !== CALL_STATES.INIT &&
+             this.state.callState !== CALL_STATES.GATHER;
+    },
+
+    /**
+     * Used to setup and render the feedback view.
+     */
+    _renderFeedbackView: function() {
+      document.title = mozL10n.get("conversation_has_ended");
+
+      // XXX Bug 1076754 Feedback view should be redone in the Flux style.
+      var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
+        "feedback.baseUrl");
+
+      var appVersionInfo = navigator.mozLoop.appVersionInfo;
+
+      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
+        product: navigator.mozLoop.getLoopCharPref("feedback.product"),
+        platform: appVersionInfo.OS,
+        channel: appVersionInfo.channel,
+        version: appVersionInfo.version
+      });
+
+      return (
+        <sharedViews.FeedbackView
+          feedbackApiClient={feedbackClient}
+          onAfterFeedbackReceived={this._closeWindow.bind(this)}
+        />
+      );
+    },
+
+    render: function() {
+      switch (this.state.callState) {
+        case CALL_STATES.CLOSE: {
+          this._closeWindow();
+          return null;
+        }
+        case CALL_STATES.TERMINATED: {
+          return (<CallFailedView
+            dispatcher={this.props.dispatcher}
+          />);
+        }
+        case CALL_STATES.ONGOING: {
+          return (<OngoingConversationView
+            dispatcher={this.props.dispatcher}
+            video={{enabled: !this.state.videoMuted}}
+            audio={{enabled: !this.state.audioMuted}}
+            />
+          );
+        }
+        case CALL_STATES.FINISHED: {
+          return this._renderFeedbackView();
+        }
+        default: {
+          return (<PendingConversationView
+            dispatcher={this.props.dispatcher}
+            callState={this.state.callState}
+            contact={this.state.contact}
+            enableCancelButton={this._isCancellable()}
+          />)
+        }
+      }
+    },
+  });
+
+  return {
+    PendingConversationView: PendingConversationView,
+    ConversationDetailView: ConversationDetailView,
+    CallFailedView: CallFailedView,
+    OngoingConversationView: OngoingConversationView,
+    OutgoingConversationView: OutgoingConversationView
+  };
+
+})(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -50,17 +50,19 @@ loop.panel = (function(_, mozL10n) {
         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"}, 
-          React.DOM.ul({className: "tab-view"}, tabButtons), 
+          !this.props.buttonsHidden
+            ? React.DOM.ul({className: "tab-view"}, tabButtons)
+            : null, 
           tabs
         )
       );
     }
   });
 
   var Tab = React.createClass({displayName: 'Tab',
     render: function() {
@@ -223,16 +225,22 @@ loop.panel = (function(_, mozL10n) {
     },
 
     _isSignedIn: function() {
       return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
+
+      // For now all of the menu entries require FxA so hide the whole gear if FxA is disabled.
+      if (!navigator.mozLoop.fxAEnabled) {
+        return null;
+      }
+
       return (
         React.DOM.div({className: "settings-menu dropdown"}, 
           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, 
@@ -241,16 +249,17 @@ loop.panel = (function(_, mozL10n) {
             SettingsDropdownEntry({label: __("settings_menu_item_account"), 
                                    onClick: this.handleClickAccountEntry, 
                                    icon: "account", 
                                    displayed: this._isSignedIn()}), 
             SettingsDropdownEntry({label: this._isSignedIn() ?
                                           __("settings_menu_item_signout") :
                                           __("settings_menu_item_signin"), 
                                    onClick: this.handleClickAuthEntry, 
+                                   displayed: navigator.mozLoop.fxAEnabled, 
                                    icon: this._isSignedIn() ? "signout" : "signin"})
           )
         )
       );
     }
   });
 
   /**
@@ -307,20 +316,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -347,17 +358,21 @@ loop.panel = (function(_, mozL10n) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
     },
 
     handleLinkExfiltration: function(event) {
-      // TODO Bug 1015988 -- Increase link exfiltration telemetry count
+      try {
+        navigator.mozLoop.telemetryAdd("LOOP_CLIENT_CALL_URL_SHARED", true);
+      } catch (err) {
+        console.error("Error recording telemetry", err);
+      }
       if (this.state.callUrlExpiry) {
         navigator.mozLoop.noteCallUrlExpiry(this.state.callUrlExpiry);
       }
     },
 
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
@@ -396,17 +411,17 @@ loop.panel = (function(_, mozL10n) {
    * FxA sign in/up link component.
    */
   var AuthLink = React.createClass({displayName: 'AuthLink',
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
     },
 
     render: function() {
-      if (navigator.mozLoop.userProfile) {
+      if (!navigator.mozLoop.fxAEnabled || navigator.mozLoop.userProfile) {
         return null;
       }
       return (
         React.DOM.p({className: "signin-link"}, 
           React.DOM.a({href: "#", onClick: this.handleSignUpLinkClick}, 
             __("panel_footer_signin_or_signup_link")
           )
         )
@@ -432,54 +447,92 @@ loop.panel = (function(_, mozL10n) {
    */
   var PanelView = React.createClass({displayName: 'PanelView',
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       callUrl: React.PropTypes.string,
       userProfile: React.PropTypes.object,
+      showTabButtons: React.PropTypes.bool,
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
-      this.setState({userProfile: navigator.mozLoop.userProfile});
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
+      var profile = navigator.mozLoop.userProfile;
+      if (profile != this.state.userProfile) {
+        // On profile change (login, logout), switch back to the default tab.
+        this.selectTab("call");
+      }
+      this.setState({userProfile: profile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     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({ref: "tabView"}, 
+          TabView({ref: "tabView", buttonsHidden: !this.state.userProfile && !this.props.showTabButtons}, 
             Tab({name: "call"}, 
               React.DOM.div({className: "content-area"}, 
                 CallUrlResult({client: this.props.client, 
                                notifications: this.props.notifications, 
                                callUrl: this.props.callUrl}), 
                 ToSView(null)
               )
             ), 
@@ -535,16 +588,17 @@ loop.panel = (function(_, mozL10n) {
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     UserIdentity: UserIdentity,
+    AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -50,17 +50,19 @@ loop.panel = (function(_, mozL10n) {
         tabs.push(
           <div key={i} className={cx({tab: true, selected: isSelected})}>
             {tab.props.children}
           </div>
         );
       }, this);
       return (
         <div className="tab-view-container">
-          <ul className="tab-view">{tabButtons}</ul>
+          {!this.props.buttonsHidden
+            ? <ul className="tab-view">{tabButtons}</ul>
+            : null}
           {tabs}
         </div>
       );
     }
   });
 
   var Tab = React.createClass({
     render: function() {
@@ -223,16 +225,22 @@ loop.panel = (function(_, mozL10n) {
     },
 
     _isSignedIn: function() {
       return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
+
+      // For now all of the menu entries require FxA so hide the whole gear if FxA is disabled.
+      if (!navigator.mozLoop.fxAEnabled) {
+        return null;
+      }
+
       return (
         <div className="settings-menu dropdown">
           <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}
@@ -241,16 +249,17 @@ loop.panel = (function(_, mozL10n) {
             <SettingsDropdownEntry label={__("settings_menu_item_account")}
                                    onClick={this.handleClickAccountEntry}
                                    icon="account"
                                    displayed={this._isSignedIn()} />
             <SettingsDropdownEntry label={this._isSignedIn() ?
                                           __("settings_menu_item_signout") :
                                           __("settings_menu_item_signin")}
                                    onClick={this.handleClickAuthEntry}
+                                   displayed={navigator.mozLoop.fxAEnabled}
                                    icon={this._isSignedIn() ? "signout" : "signin"} />
           </ul>
         </div>
       );
     }
   });
 
   /**
@@ -307,20 +316,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -347,17 +358,21 @@ loop.panel = (function(_, mozL10n) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
     },
 
     handleLinkExfiltration: function(event) {
-      // TODO Bug 1015988 -- Increase link exfiltration telemetry count
+      try {
+        navigator.mozLoop.telemetryAdd("LOOP_CLIENT_CALL_URL_SHARED", true);
+      } catch (err) {
+        console.error("Error recording telemetry", err);
+      }
       if (this.state.callUrlExpiry) {
         navigator.mozLoop.noteCallUrlExpiry(this.state.callUrlExpiry);
       }
     },
 
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
@@ -396,17 +411,17 @@ loop.panel = (function(_, mozL10n) {
    * FxA sign in/up link component.
    */
   var AuthLink = React.createClass({
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
     },
 
     render: function() {
-      if (navigator.mozLoop.userProfile) {
+      if (!navigator.mozLoop.fxAEnabled || navigator.mozLoop.userProfile) {
         return null;
       }
       return (
         <p className="signin-link">
           <a href="#" onClick={this.handleSignUpLinkClick}>
             {__("panel_footer_signin_or_signup_link")}
           </a>
         </p>
@@ -432,54 +447,92 @@ loop.panel = (function(_, mozL10n) {
    */
   var PanelView = React.createClass({
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       callUrl: React.PropTypes.string,
       userProfile: React.PropTypes.object,
+      showTabButtons: React.PropTypes.bool,
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
-      this.setState({userProfile: navigator.mozLoop.userProfile});
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
+      var profile = navigator.mozLoop.userProfile;
+      if (profile != this.state.userProfile) {
+        // On profile change (login, logout), switch back to the default tab.
+        this.selectTab("call");
+      }
+      this.setState({userProfile: profile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     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 ref="tabView">
+          <TabView ref="tabView" buttonsHidden={!this.state.userProfile && !this.props.showTabButtons}>
             <Tab name="call">
               <div className="content-area">
                 <CallUrlResult client={this.props.client}
                                notifications={this.props.notifications}
                                callUrl={this.props.callUrl} />
                 <ToSView />
               </div>
             </Tab>
@@ -535,16 +588,17 @@ loop.panel = (function(_, mozL10n) {
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     UserIdentity: UserIdentity,
+    AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -8,17 +8,17 @@
  * "Fixes" the Box Model.
  * @see http://www.paulirish.com/2012/box-sizing-border-box-ftw/
  */
 *, *:before, *:after {
   box-sizing: border-box;
 }
 
 body {
-  font-family: "Lucida Grande", sans-serif;
+  font: message-box;
   font-size: 12px;
   background: #fbfbfb;
 }
 
 img {
   border: none;
 }
 
@@ -93,16 +93,17 @@ p {
   white-space: nowrap;
   font-size: .9em;
   cursor: pointer;
 }
 
 .btn-info {
   background-color: #0096dd;
   border: 1px solid #0095dd;
+  color: #fff;
 }
 
   .btn-info:hover {
     background-color: #008acb;
     border: 1px solid #008acb;
   }
 
   .btn-info:active {
@@ -132,33 +133,39 @@ p {
   }
 
 .btn-warning {
   background-color: #f0ad4e;
 }
 
 .btn-cancel,
 .btn-error,
+.btn-decline,
 .btn-hangup,
+.btn-decline + .btn-chevron,
 .btn-error + .btn-chevron {
   background-color: #d74345;
   border: 1px solid #d74345;
 }
 
   .btn-cancel:hover,
   .btn-error:hover,
+  .btn-decline:hover,
   .btn-hangup:hover,
+  .btn-decline + .btn-chevron:hover,
   .btn-error + .btn-chevron:hover {
     background-color: #c53436;
     border: 1px solid #c53436;
   }
 
   .btn-cancel:active,
   .btn-error:active,
+  .btn-decline:active,
   .btn-hangup:active,
+  .btn-decline + .btn-chevron:active,
   .btn-error + .btn-chevron:active {
     background-color: #ae2325;
     border: 1px solid #ae2325;
   }
 
 .btn-chevron {
   width: 26px;
   height: 26px;
@@ -177,16 +184,17 @@ p {
  * and the dropdown menu */
 .btn-chevron-menu-group {
   display: flex;
   justify-content: space-between;
   flex: 8;
 }
 
 .btn-group-chevron .btn {
+  border-radius: 2px;
   border-top-right-radius: 0;
   border-bottom-right-radius: 0;
   flex: 2;
 }
 
   .btn + .btn-chevron,
   .btn + .btn-chevron:hover,
   .btn + .btn-chevron:active {
@@ -217,22 +225,30 @@ p {
 
 .btn-chevron-menu-group .btn {
   flex: 1;
   border-radius: 2px;
   border-bottom-right-radius: 0;
   border-top-right-radius: 0;
 }
 
-/* Alerts */
+/* Alerts/Notifications */
+.notificationContainer {
+  border-bottom: 2px solid #E9E9E9;
+  margin-bottom: 1em;
+}
+
+.messages > .notificationContainer > .alert {
+  text-align: center;
+}
+
+.notificationContainer > .detailsBar,
 .alert {
   background: #eee;
   padding: .4em 1em;
-  margin-bottom: 1em;
-  border-bottom: 2px solid #E9E9E9;
 }
 
 .alert p.message {
   padding: 0;
   margin: 0;
 }
 
 .alert-error {
@@ -240,16 +256,21 @@ p {
   color: #fff;
 }
 
 .alert-warning {
   background: #fcf8e3;
   border: 1px solid #fbeed5;
 }
 
+.notificationContainer > .details-error {
+  background: #fbebeb;
+  color: #d74345
+}
+
 .alert .close {
   position: relative;
   top: -.1rem;
   right: -1rem;
 }
 
 /* Misc */
 
@@ -339,42 +360,25 @@ p {
 }
 
 .mac p,
 .windows p,
 .linux p {
   line-height: 16px;
 }
 
-/* Using star selector to override
- * the specificity of other selectors
- * if performance is an issue we could
- * explicitely list all the elements */
-.windows * {
-  font-family: 'Segoe';
-}
-
-.mac * {
-  font-family: 'Lucida Grande';
-}
-
-.linux * {
-  /* XXX requires fallbacks */
-  font-family: 'Ubuntu', sans-serif;
-}
-
 /* Web panel */
 
 .info-panel {
   border-radius: 4px;
   background: #fff;
   padding: 20px 0;
   border: 1px solid #e7e7e7;
   box-shadow: 0 2px 0 rgba(0, 0, 0, .03);
-  margin-bottom: 25px;
+  margin: 2rem 0;
 }
 
 .info-panel h1 {
   font-size: 1.2em;
   font-weight: 700;
   padding: 20px 0;
   text-align: center;
 }
--- a/browser/components/loop/content/shared/css/contacts.css
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -1,18 +1,24 @@
 /* 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/. */
 
+.content-area input.contact-filter {
+  margin-top: 14px;
+  border-radius: 10000px;
+}
+
 .contact-list {
   border-top: 1px solid #ccc;
   overflow-x: hidden;
   overflow-y: auto;
-  /* Show six contacts and scroll for the rest */
-  max-height: 305px;
+  /* Space for six contacts, not affected by filtering.  This is enough space
+     to show the dropdown menu when there is only one contact. */
+  height: 306px;
 }
 
 .contact,
 .contact-separator {
   padding: 5px 10px;
   font-size: 13px;
 }
 
@@ -39,17 +45,31 @@
 }
 
 .contact:hover {
   background: #eee;
 }
 
 .contact:hover > .icons {
   display: block;
-  z-index: 1000;
+  z-index: 1;
+}
+
+.contact > .details {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.contact:hover > .details {
+  /* Hovering the contact shows the icons/ buttons, which takes up horizontal
+   * space. This causes the fixed-size avatar to resize horizontally, so we assign
+   * a flex value equivalent to the maximum pixel value to avoid the resizing
+   * to happen. Consider this a hack. */
+  flex: 190;
 }
 
 .contact > .avatar {
   width: 40px;
   height: 40px;
   background: #ccc;
   border-radius: 50%;
   margin-right: 10px;
@@ -141,11 +161,57 @@
 
 .icons i.icon-caret-down {
   background-image: url("../img/icons-10x10.svg#dropdown-white");
   background-size: 10px 10px;
   width: 10px;
   height: 16px;
 }
 
+.contact > .dropdown-menu {
+  z-index: 2;
+  top: 10px;
+  bottom: auto;
+  right: 3em;
+  left: auto;
+}
+
+.contact > .dropdown-menu-up {
+  bottom: 10px;
+  top: auto;
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon {
+  display: inline-block;
+  width: 20px;
+  height: 10px;
+  background-position: center left;
+  background-size: 10px 10px;
+  background-repeat: no-repeat;
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-audio-call {
+  background-image: url("../img/icons-16x16.svg#audio");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-video-call {
+  background-image: url("../img/icons-16x16.svg#video");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-edit {
+  background-image: url("../img/icons-16x16.svg#contacts");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-block {
+  background-image: url("../img/icons-16x16.svg#block");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-unblock {
+  background-image: url("../img/icons-16x16.svg#unblock");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-remove {
+  background-image: url("../img/icons-16x16.svg#delete");
+}
+
 .contact-form > .button-group {
   margin-top: 14px;
 }
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -218,60 +218,61 @@
   width: 50%;
 }
 
 /* Call ended view */
 .call-ended p {
   text-align: center;
 }
 
-/* Incoming call */
+/* General Call (incoming or outgoing). */
 
 /*
  * Height matches the height of the docked window
  * but the UI breaks when you pop out
  * Bug 1040985
  */
-.incoming-call {
+.call-window {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: space-between;
   min-height: 230px;
 }
 
-.incoming-call-action-group {
+.call-action-group {
   display: flex;
   padding: 2.5em 0 0 0;
   width: 100%;
   justify-content: space-around;
 }
 
-.incoming-call-action-group > .btn {
+.call-action-group > .btn {
   margin-left: .5em;
+  height: 26px;
 }
 
-.incoming-call-action-group .btn-group-chevron,
-.incoming-call-action-group .btn-group {
+.call-action-group .btn-group-chevron,
+.call-action-group .btn-group {
   width: 100%;
 }
 
 /* XXX Once we get the incoming call avatar, bug 1047435, the H2 should
  * disappear from our markup, and we should remove this rule entirely.
  */
-.incoming-call h2 {
+.call-window h2 {
   font-size: 1.5em;
   font-weight: normal;
 
   /* compensate for reset.css overriding this; values borrowed from
      Firefox Mac html.css */
   margin: 0.83em 0;
 }
 
-.fx-embedded-incoming-call-button-spacer {
+.fx-embedded-call-button-spacer {
   display: flex;
   flex: 1;
 }
 
 /* Expired call url page */
 
 .expired-url-info {
   width: 400px;
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -1,12 +1,16 @@
 /* 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/. */
 
+body {
+  background: none;
+}
+
 /* Panel styles */
 
 .panel {
   /* hide the extra margin space that the panel resizer now wants to show */
   overflow: hidden;
 }
 
 /* Notifications displayed over tabs */
@@ -21,17 +25,16 @@
 
 /* Tabs and tab selection buttons */
 
 .tab-view {
   display: flex;
   flex-direction: row;
   padding: 10px;
   border-bottom: 1px solid #ccc;
-  background-color: #fbfbfb;
   color: #000;
   border-top-right-radius: 2px;
   border-top-left-radius: 2px;
   list-style: none;
 }
 
 .tab-view > li {
   flex: 1;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.shared = loop.shared || {};
+loop.shared.actions = (function() {
+  "use strict";
+
+  /**
+   * Actions are events that are triggered by the user, e.g. clicking a button,
+   * or by an async event, e.g. status received.
+   *
+   * They should be dispatched to stores via the dispatcher.
+   */
+
+  function Action(name, schema, values) {
+    var validatedData = new loop.validate.Validator(schema || {})
+                                         .validate(values || {});
+    for (var prop in validatedData)
+      this[prop] = validatedData[prop];
+
+    this.name = name;
+  }
+
+  Action.define = function(name, schema) {
+    return Action.bind(null, name, schema);
+  };
+
+  return {
+    /**
+     * Used to trigger gathering of initial call data.
+     */
+    GatherCallData: Action.define("gatherCallData", {
+      // Specify the callId for an incoming call.
+      callId: [String, null],
+      outgoing: Boolean
+    }),
+
+    /**
+     * Used to cancel call setup.
+     */
+    CancelCall: Action.define("cancelCall", {
+    }),
+
+    /**
+     * Used to retry a failed call.
+     */
+    RetryCall: Action.define("retryCall", {
+    }),
+
+    /**
+     * Used to initiate connecting of a call with the relevant
+     * sessionData.
+     */
+    ConnectCall: Action.define("connectCall", {
+      // This object contains the necessary details for the
+      // connection of the websocket, and the SDK
+      sessionData: Object
+    }),
+
+    /**
+     * Used for hanging up the call at the end of a successful call.
+     */
+    HangupCall: Action.define("hangupCall", {
+    }),
+
+    /**
+     * Used to indicate the peer hung up the call.
+     */
+    PeerHungupCall: Action.define("peerHungupCall", {
+    }),
+
+    /**
+     * Used for notifying of connection progress state changes.
+     * The connection refers to the overall connection flow as indicated
+     * on the websocket.
+     */
+    ConnectionProgress: Action.define("connectionProgress", {
+      // The connection state from the websocket.
+      wsState: String
+    }),
+
+    /**
+     * Used for notifying of connection failures.
+     */
+    ConnectionFailure: Action.define("connectionFailure", {
+      // A string relating to the reason the connection failed.
+      reason: String
+    }),
+
+    /**
+     * Used by the ongoing views to notify stores about the elements
+     * required for the sdk.
+     */
+    SetupStreamElements: Action.define("setupStreamElements", {
+      // The configuration for the publisher/subscribe options
+      publisherConfig: Object,
+      // The local stream element
+      getLocalElementFunc: Function,
+      // The remote stream element
+      getRemoteElementFunc: Function
+    }),
+
+    /**
+     * Used for notifying that the media is now up for the call.
+     */
+    MediaConnected: Action.define("mediaConnected", {
+    }),
+
+    /**
+     * Used to mute or unmute a stream
+     */
+    SetMute: Action.define("setMute", {
+      // The part of the stream to enable, e.g. "audio" or "video"
+      type: String,
+      // Whether or not to enable the stream.
+      enabled: Boolean
+    })
+  };
+})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -0,0 +1,410 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.store = (function() {
+
+  var sharedActions = loop.shared.actions;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+
+  /**
+   * Websocket states taken from:
+   * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
+   */
+  var WS_STATES = {
+    // The call is starting, and the remote party is not yet being alerted.
+    INIT: "init",
+    // The called party is being alerted.
+    ALERTING: "alerting",
+    // The call is no longer being set up and has been aborted for some reason.
+    TERMINATED: "terminated",
+    // The called party has indicated that he has answered the call,
+    // but the media is not yet confirmed.
+    CONNECTING: "connecting",
+    // One of the two parties has indicated successful media set up,
+    // but the other has not yet.
+    HALF_CONNECTED: "half-connected",
+    // Both endpoints have reported successfully establishing media.
+    CONNECTED: "connected"
+  };
+
+  var CALL_STATES = {
+    // The initial state of the view.
+    INIT: "cs-init",
+    // The store is gathering the call data from the server.
+    GATHER: "cs-gather",
+    // The initial data has been gathered, the websocket is connecting, or has
+    // connected, and waiting for the other side to connect to the server.
+    CONNECTING: "cs-connecting",
+    // The websocket has received information that we're now alerting
+    // the peer.
+    ALERTING: "cs-alerting",
+    // The call is ongoing.
+    ONGOING: "cs-ongoing",
+    // The call ended successfully.
+    FINISHED: "cs-finished",
+    // The user has finished with the window.
+    CLOSE: "cs-close",
+    // The call was terminated due to an issue during connection.
+    TERMINATED: "cs-terminated"
+  };
+
+
+  var ConversationStore = Backbone.Model.extend({
+    defaults: {
+      // The current state of the call
+      callState: CALL_STATES.INIT,
+      // The reason if a call was terminated
+      callStateReason: undefined,
+      // The error information, if there was a failure
+      error: undefined,
+      // True if the call is outgoing, false if not, undefined if unknown
+      outgoing: undefined,
+      // The contact being called for outgoing calls
+      contact: undefined,
+      // The call type for the call.
+      // XXX Don't hard-code, this comes from the data in bug 1072323
+      callType: CALL_TYPES.AUDIO_VIDEO,
+
+      // Call Connection information
+      // The call id from the loop-server
+      callId: undefined,
+      // The connection progress url to connect the websocket
+      progressURL: undefined,
+      // The websocket token that allows connection to the progress url
+      websocketToken: undefined,
+      // SDK API key
+      apiKey: undefined,
+      // SDK session ID
+      sessionId: undefined,
+      // SDK session token
+      sessionToken: undefined,
+      // If the audio is muted
+      audioMuted: false,
+      // If the video is muted
+      videoMuted: false
+    },
+
+    /**
+     * Constructor
+     *
+     * Options:
+     * - {loop.Dispatcher} dispatcher The dispatcher for dispatching actions and
+     *                                registering to consume actions.
+     * - {Object} client              A client object for communicating with the server.
+     *
+     * @param  {Object} attributes Attributes object.
+     * @param  {Object} options    Options object.
+     */
+    initialize: function(attributes, options) {
+      options = options || {};
+
+      if (!options.dispatcher) {
+        throw new Error("Missing option dispatcher");
+      }
+      if (!options.client) {
+        throw new Error("Missing option client");
+      }
+      if (!options.sdkDriver) {
+        throw new Error("Missing option sdkDriver");
+      }
+
+      this.client = options.client;
+      this.dispatcher = options.dispatcher;
+      this.sdkDriver = options.sdkDriver;
+
+      this.dispatcher.register(this, [
+        "connectionFailure",
+        "connectionProgress",
+        "gatherCallData",
+        "connectCall",
+        "hangupCall",
+        "peerHungupCall",
+        "cancelCall",
+        "retryCall",
+        "mediaConnected",
+        "setMute"
+      ]);
+    },
+
+    /**
+     * Handles the connection failure action, setting the state to
+     * terminated.
+     *
+     * @param {sharedActions.ConnectionFailure} actionData The action data.
+     */
+    connectionFailure: function(actionData) {
+      this._endSession();
+      this.set({
+        callState: CALL_STATES.TERMINATED,
+        callStateReason: actionData.reason
+      });
+    },
+
+    /**
+     * Handles the connection progress action, setting the next state
+     * appropriately.
+     *
+     * @param {sharedActions.ConnectionProgress} actionData The action data.
+     */
+    connectionProgress: function(actionData) {
+      var callState = this.get("callState");
+
+      switch(actionData.wsState) {
+        case WS_STATES.INIT: {
+          if (callState === CALL_STATES.GATHER) {
+            this.set({callState: CALL_STATES.CONNECTING});
+          }
+          break;
+        }
+        case WS_STATES.ALERTING: {
+          this.set({callState: CALL_STATES.ALERTING});
+          break;
+        }
+        case WS_STATES.CONNECTING: {
+          this.sdkDriver.connectSession({
+            apiKey: this.get("apiKey"),
+            sessionId: this.get("sessionId"),
+            sessionToken: this.get("sessionToken")
+          });
+          this.set({callState: CALL_STATES.ONGOING});
+          break;
+        }
+        case WS_STATES.HALF_CONNECTED:
+        case WS_STATES.CONNECTED: {
+          this.set({callState: CALL_STATES.ONGOING});
+          break;
+        }
+        default: {
+          console.error("Unexpected websocket state passed to connectionProgress:",
+            actionData.wsState);
+        }
+      }
+    },
+
+    /**
+     * Handles the gather call data action, setting the state
+     * and starting to get the appropriate data for the type of call.
+     *
+     * @param {sharedActions.GatherCallData} actionData The action data.
+     */
+    gatherCallData: function(actionData) {
+      if (!actionData.outgoing) {
+        // XXX Other types aren't supported yet, but set the state for the
+        // view selection.
+        this.set({outgoing: false});
+        return;
+      }
+
+      var callData = navigator.mozLoop.getCallData(actionData.callId);
+      if (!callData) {
+        console.error("Failed to get the call data");
+        this.set({callState: CALL_STATES.TERMINATED});
+        return;
+      }
+
+      this.set({
+        contact: callData.contact,
+        outgoing: actionData.outgoing,
+        callId: actionData.callId,
+        callType: callData.callType,
+        callState: CALL_STATES.GATHER
+      });
+
+      this.set({videoMuted: this.get("callType") === CALL_TYPES.AUDIO_ONLY});
+
+      if (this.get("outgoing")) {
+        this._setupOutgoingCall();
+      } // XXX Else, other types aren't supported yet.
+    },
+
+    /**
+     * Handles the connect call action, this saves the appropriate
+     * data and starts the connection for the websocket to notify the
+     * server of progress.
+     *
+     * @param {sharedActions.ConnectCall} actionData The action data.
+     */
+    connectCall: function(actionData) {
+      this.set(actionData.sessionData);
+      this._connectWebSocket();
+    },
+
+    /**
+     * Hangs up an ongoing call.
+     */
+    hangupCall: function() {
+      if (this._websocket) {
+        // Let the server know the user has hung up.
+        this._websocket.mediaFail();
+      }
+
+      this._endSession();
+      this.set({callState: CALL_STATES.FINISHED});
+    },
+
+    /**
+     * The peer hungup the call.
+     */
+    peerHungupCall: function() {
+      this._endSession();
+      this.set({callState: CALL_STATES.FINISHED});
+    },
+
+    /**
+     * Cancels a call
+     */
+    cancelCall: function() {
+      var callState = this.get("callState");
+      if (this._websocket &&
+          (callState === CALL_STATES.CONNECTING ||
+           callState === CALL_STATES.ALERTING)) {
+         // Let the server know the user has hung up.
+        this._websocket.cancel();
+      }
+
+      this._endSession();
+      this.set({callState: CALL_STATES.CLOSE});
+    },
+
+    /**
+     * Retries a call
+     */
+    retryCall: function() {
+      var callState = this.get("callState");
+      if (callState !== CALL_STATES.TERMINATED) {
+        console.error("Unexpected retry in state", callState);
+        return;
+      }
+
+      this.set({callState: CALL_STATES.GATHER});
+      if (this.get("outgoing")) {
+        this._setupOutgoingCall();
+      }
+    },
+
+    /**
+     * Notifies that all media is now connected
+     */
+    mediaConnected: function() {
+      this._websocket.mediaUp();
+    },
+
+    /**
+     * Records the mute state for the stream.
+     *
+     * @param {sharedActions.setMute} actionData The mute state for the stream type.
+     */
+    setMute: function(actionData) {
+      var muteType = actionData.type + "Muted";
+      this.set(muteType, !actionData.enabled);
+    },
+
+    /**
+     * Obtains the outgoing call data from the server and handles the
+     * result.
+     */
+    _setupOutgoingCall: function() {
+      var contactAddresses = [];
+
+      this.get("contact").email.forEach(function(address) {
+        contactAddresses.push(address.value);
+      });
+
+      this.client.setupOutgoingCall(contactAddresses,
+        this.get("callType"),
+        function(err, result) {
+          if (err) {
+            console.error("Failed to get outgoing call data", err);
+            this.dispatcher.dispatch(
+              new sharedActions.ConnectionFailure({reason: "setup"}));
+            return;
+          }
+
+          // Success, dispatch a new action.
+          this.dispatcher.dispatch(
+            new sharedActions.ConnectCall({sessionData: result}));
+        }.bind(this)
+      );
+    },
+
+    /**
+     * Sets up and connects the websocket to the server. The websocket
+     * deals with sending and obtaining status via the server about the
+     * setup of the call.
+     */
+    _connectWebSocket: function() {
+      this._websocket = new loop.CallConnectionWebSocket({
+        url: this.get("progressURL"),
+        callId: this.get("callId"),
+        websocketToken: this.get("websocketToken")
+      });
+
+      this._websocket.promiseConnect().then(
+        function(progressState) {
+          this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
+            // This is the websocket call state, i.e. waiting for the
+            // other end to connect to the server.
+            wsState: progressState
+          }));
+        }.bind(this),
+        function(error) {
+          console.error("Websocket failed to connect", error);
+          this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+            reason: "websocket-setup"
+          }));
+        }.bind(this)
+      );
+
+      this.listenTo(this._websocket, "progress", this._handleWebSocketProgress);
+    },
+
+    /**
+     * Ensures the session is ended and the websocket is disconnected.
+     */
+    _endSession: function(nextState) {
+      this.sdkDriver.disconnectSession();
+      if (this._websocket) {
+        this.stopListening(this._websocket);
+
+        // Now close the websocket.
+        this._websocket.close();
+        delete this._websocket;
+      }
+    },
+
+    /**
+     * Used to handle any progressed received from the websocket. This will
+     * dispatch new actions so that the data can be handled appropriately.
+     */
+    _handleWebSocketProgress: function(progressData) {
+      var action;
+
+      switch(progressData.state) {
+        case WS_STATES.TERMINATED: {
+          action = new sharedActions.ConnectionFailure({
+            reason: progressData.reason
+          });
+          break;
+        }
+        default: {
+          action = new sharedActions.ConnectionProgress({
+            wsState: progressData.state
+          });
+          break;
+        }
+      }
+
+      this.dispatcher.dispatch(action);
+    }
+  });
+
+  return {
+    CALL_STATES: CALL_STATES,
+    ConversationStore: ConversationStore,
+    WS_STATES: WS_STATES
+  };
+})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/dispatcher.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+/**
+ * The dispatcher for actions. This dispatches actions to stores registered
+ * for those actions.
+ *
+ * If stores need to perform async operations for actions, they should return
+ * straight away, and set up a new action for the changes if necessary.
+ *
+ * It is an error if a returned promise rejects - they should always pass.
+ */
+var loop = loop || {};
+loop.Dispatcher = (function() {
+
+  function Dispatcher() {
+    this._eventData = {};
+    this._actionQueue = [];
+    this._debug = loop.shared.utils.getBoolPreference("debug.dispatcher");
+  }
+
+  Dispatcher.prototype = {
+    /**
+     * Register a store to receive notifications of specific actions.
+     *
+     * @param {Object} store The store object to register
+     * @param {Array} eventTypes An array of action names
+     */
+    register: function(store, eventTypes) {
+      eventTypes.forEach(function(type) {
+        if (this._eventData.hasOwnProperty(type)) {
+          this._eventData[type].push(store);
+        } else {
+          this._eventData[type] = [store];
+        }
+      }.bind(this));
+    },
+
+    /**
+     * Dispatches an action to all registered stores.
+     */
+    dispatch: function(action) {
+      // Always put it on the queue, to make it simpler.
+      this._actionQueue.push(action);
+      this._dispatchNextAction();
+    },
+
+    /**
+     * Dispatches the next action in the queue if one is not already active.
+     */
+    _dispatchNextAction: function() {
+      if (!this._actionQueue.length || this._active) {
+        return;
+      }
+
+      var action = this._actionQueue.shift();
+      var type = action.name;
+
+      var registeredStores = this._eventData[type];
+      if (!registeredStores) {
+        console.warn("No stores registered for event type ", type);
+        return;
+      }
+
+      this._active = true;
+
+      if (this._debug) {
+        console.log("[Dispatcher] Dispatching action", action);
+      }
+
+      registeredStores.forEach(function(store) {
+        store[type](action);
+      });
+
+      this._active = false;
+      this._dispatchNextAction();
+    }
+  };
+
+  return Dispatcher;
+})();
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -26,39 +26,49 @@ loop.shared.mixins = (function() {
     rootObject = obj;
   }
 
   /**
    * Dropdown menu mixin.
    * @type {Object}
    */
   var DropdownMenuMixin = {
+    get documentBody() {
+      return rootObject.document.body;
+    },
+
     getInitialState: function() {
       return {showMenu: false};
     },
 
     _onBodyClick: function() {
       this.setState({showMenu: false});
     },
 
     componentDidMount: function() {
-      rootObject.document.body.addEventListener("click", this._onBodyClick);
+      this.documentBody.addEventListener("click", this._onBodyClick);
+      this.documentBody.addEventListener("blur", this.hideDropdownMenu);
     },
 
     componentWillUnmount: function() {
-      rootObject.document.body.removeEventListener("click", this._onBodyClick);
+      this.documentBody.removeEventListener("click", this._onBodyClick);
+      this.documentBody.removeEventListener("blur", this.hideDropdownMenu);
     },
 
     showDropdownMenu: function() {
       this.setState({showMenu: true});
     },
 
     hideDropdownMenu: function() {
       this.setState({showMenu: false});
-    }
+    },
+
+    toggleDropdownMenu: function() {
+      this.setState({showMenu: !this.state.showMenu});
+    },
   };
 
   /**
    * Document visibility mixin. Allows defining the following hooks for when the
    * document visibility status changes:
    *
    * - {Function} onDocumentVisible For when the document becomes visible.
    * - {Function} onDocumentHidden  For when the document becomes hidden.
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -24,18 +24,18 @@ loop.shared.models = (function(l10n) {
       apiKey:       undefined,     // OT api key
       callId:       undefined,     // The callId on the server
       progressURL:  undefined,     // The websocket url to use for progress
       websocketToken: undefined,   // The token to use for websocket auth, this is
                                    // stored as a hex string which is what the server
                                    // requires.
       callType:     undefined,     // The type of incoming call selected by
                                    // other peer ("audio" or "audio-video")
-      selectedCallType: undefined, // The selected type for the call that was
-                                   // initiated ("audio" or "audio-video")
+      selectedCallType: "audio-video", // The selected type for the call that was
+                                       // initiated ("audio" or "audio-video")
       callToken:    undefined,     // Incoming call token.
                                    // Used for blocking a call url
       subscribedStream: false,     // Used to indicate that a stream has been
                                    // subscribed to
       publishedStream: false       // Used to indicate that a stream has been
                                    // published
     },
 
@@ -81,18 +81,23 @@ loop.shared.models = (function(l10n) {
      */
     accepted: function() {
       this.trigger("call:accepted");
     },
 
     /**
      * Used to indicate that an outgoing call should start any necessary
      * set-up.
+     *
+     * @param {String} selectedCallType Call type ("audio" or "audio-video")
      */
-    setupOutgoingCall: function() {
+    setupOutgoingCall: function(selectedCallType) {
+      if (selectedCallType) {
+        this.set("selectedCallType", selectedCallType);
+      }
       this.trigger("call:outgoing:setup");
     },
 
     /**
      * Starts an outgoing conversation.
      *
      * @param {Object} sessionData The session data received from the
      *                             server for the outgoing call.
@@ -322,16 +327,18 @@ loop.shared.models = (function(l10n) {
     },
   });
 
   /**
    * Notification model.
    */
   var NotificationModel = Backbone.Model.extend({
     defaults: {
+      details: "",
+      detailsButtonLabel: "",
       level: "info",
       message: ""
     }
   });
 
   /**
    * Notification collection
    */
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -0,0 +1,237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.OTSdkDriver = (function() {
+
+  var sharedActions = loop.shared.actions;
+
+  /**
+   * This is a wrapper for the OT sdk. It is used to translate the SDK events into
+   * actions, and instruct the SDK what to do as a result of actions.
+   */
+  var OTSdkDriver = function(options) {
+      if (!options.dispatcher) {
+        throw new Error("Missing option dispatcher");
+      }
+      if (!options.sdk) {
+        throw new Error("Missing option sdk");
+      }
+
+      this.dispatcher = options.dispatcher;
+      this.sdk = options.sdk;
+
+      this.dispatcher.register(this, [
+        "setupStreamElements",
+        "setMute"
+      ]);
+  };
+
+  OTSdkDriver.prototype = {
+    /**
+     * Handles the setupStreamElements action. Saves the required data and
+     * kicks off the initialising of the publisher.
+     *
+     * @param {sharedActions.SetupStreamElements} actionData The data associated
+     *   with the action. See action.js.
+     */
+    setupStreamElements: function(actionData) {
+      this.getLocalElement = actionData.getLocalElementFunc;
+      this.getRemoteElement = actionData.getRemoteElementFunc;
+      this.publisherConfig = actionData.publisherConfig;
+
+      // At this state we init the publisher, even though we might be waiting for
+      // the initial connect of the session. This saves time when setting up
+      // the media.
+      this.publisher = this.sdk.initPublisher(this.getLocalElement(),
+        this.publisherConfig,
+        this._onPublishComplete.bind(this));
+    },
+
+    /**
+     * Handles the setMute action. Informs the published stream to mute
+     * or unmute audio as appropriate.
+     *
+     * @param {sharedActions.SetMute} actionData The data associated with the
+     *                                           action. See action.js.
+     */
+    setMute: function(actionData) {
+      if (actionData.type === "audio") {
+        this.publisher.publishAudio(actionData.enabled);
+      } else {
+        this.publisher.publishVideo(actionData.enabled);
+      }
+    },
+
+    /**
+     * Connects a session for the SDK, listening to the required events.
+     *
+     * sessionData items:
+     * - sessionId: The OT session ID
+     * - apiKey: The OT API key
+     * - sessionToken: The token for the OT session
+     *
+     * @param {Object} sessionData The session data for setting up the OT session.
+     */
+    connectSession: function(sessionData) {
+      this.session = this.sdk.initSession(sessionData.sessionId);
+
+      this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
+      this.session.on("connectionDestroyed",
+        this._onConnectionDestroyed.bind(this));
+      this.session.on("sessionDisconnected",
+        this._onSessionDisconnected.bind(this));
+
+      // This starts the actual session connection.
+      this.session.connect(sessionData.apiKey, sessionData.sessionToken,
+        this._onConnectionComplete.bind(this));
+    },
+
+    /**
+     * Disconnects the sdk session.
+     */
+    disconnectSession: function() {
+      if (this.session) {
+        this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this));
+        this.session.off("connectionDestroyed",
+          this._onConnectionDestroyed.bind(this));
+        this.session.off("sessionDisconnected",
+          this._onSessionDisconnected.bind(this));
+
+        this.session.disconnect();
+        delete this.session;
+      }
+      if (this.publisher) {
+        this.publisher.destroy();
+        delete this.publisher;
+      }
+
+      // Also, tidy these variables ready for next time.
+      delete this._sessionConnected;
+      delete this._publisherReady;
+      delete this._publishedLocalStream;
+      delete this._subscribedRemoteStream;
+    },
+
+    /**
+     * Called once the session has finished connecting.
+     *
+     * @param {Error} error An OT error object, null if there was no error.
+     */
+    _onConnectionComplete: function(error) {
+      if (error) {
+        console.error("Failed to complete connection", error);
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "couldNotConnect"
+        }));
+        return;
+      }
+
+      this._sessionConnected = true;
+      this._maybePublishLocalStream();
+    },
+
+    /**
+     * Handles the connection event for a peer's connection being dropped.
+     *
+     * @param {SessionDisconnectEvent} event The event details
+     * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
+     */
+    _onConnectionDestroyed: function(event) {
+      var action;
+      if (event.reason === "clientDisconnected") {
+        action = new sharedActions.PeerHungupCall();
+      } else {
+        // Strictly speaking this isn't a failure on our part, but since our
+        // flow requires a full reconnection, then we just treat this as
+        // if a failure of our end had occurred.
+        action = new sharedActions.ConnectionFailure({
+          reason: "peerNetworkDisconnected"
+        });
+      }
+      this.dispatcher.dispatch(action);
+    },
+
+    /**
+     * Handles the session event for the connection for this client being
+     * destroyed.
+     *
+     * @param {SessionDisconnectEvent} event The event details:
+     * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
+     */
+    _onSessionDisconnected: function(event) {
+      // We only need to worry about the network disconnected reason here.
+      if (event.reason === "networkDisconnected") {
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "networkDisconnected"
+        }));
+      }
+    },
+
+    /**
+     * Handles the event when the remote stream is created.
+     *
+     * @param {StreamEvent} event The event details:
+     * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
+     */
+    _onRemoteStreamCreated: function(event) {
+      this.session.subscribe(event.stream,
+        this.getRemoteElement(), this.publisherConfig);
+
+      this._subscribedRemoteStream = true;
+      if (this._checkAllStreamsConnected()) {
+        this.dispatcher.dispatch(new sharedActions.MediaConnected());
+      }
+    },
+
+    /**
+     * Handles the publishing being complete.
+     *
+     * @param {Error} error An OT error object, null if there was no error.
+     */
+    _onPublishComplete: function(error) {
+      if (error) {
+        console.error("Failed to initialize publisher", error);
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "noMedia"
+        }));
+        return;
+      }
+
+      this._publisherReady = true;
+      this._maybePublishLocalStream();
+    },
+
+    /**
+     * Publishes the local stream if the session is connected
+     * and the publisher is ready.
+     */
+    _maybePublishLocalStream: function() {
+      if (this._sessionConnected && this._publisherReady) {
+        // We are clear to publish the stream to the session.
+        this.session.publish(this.publisher);
+
+        // Now record the fact, and check if we've got all media yet.
+        this._publishedLocalStream = true;
+        if (this._checkAllStreamsConnected()) {
+          this.dispatcher.dispatch(new sharedActions.MediaConnected());
+        }
+      }
+    },
+
+    /**
+     * Used to check if both local and remote streams are available
+     * and send an action if they are.
+     */
+    _checkAllStreamsConnected: function() {
+      return this._publishedLocalStream &&
+        this._subscribedRemoteStream;
+    }
+  };
+
+  return OTSdkDriver;
+
+})();
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -5,16 +5,24 @@
 /* global loop:true */
 
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.utils = (function() {
   "use strict";
 
   /**
+   * Call types used for determining if a call is audio/video or audio-only.
+   */
+  var CALL_TYPES = {
+    AUDIO_VIDEO: "audio-video",
+    AUDIO_ONLY: "audio"
+  };
+
+  /**
    * Used for adding different styles to the panel
    * @returns {String} Corresponds to the client platform
    * */
   function getTargetPlatform() {
     var platform="unknown_platform";
 
     if (navigator.platform.indexOf("Win") !== -1) {
       platform = "windows";
@@ -72,13 +80,14 @@ loop.shared.utils = (function() {
     },
 
     locationHash: function() {
       return window.location.hash;
     }
   };
 
   return {
+    CALL_TYPES: CALL_TYPES,
     Helper: Helper,
     getTargetPlatform: getTargetPlatform,
     getBoolPreference: getBoolPreference
   };
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/validate.js
@@ -0,0 +1,127 @@
+/* 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 unused:false */
+
+var loop = loop || {};
+loop.validate = (function() {
+  "use strict";
+
+  /**
+   * Computes the difference between two arrays.
+   *
+   * @param  {Array} arr1 First array
+   * @param  {Array} arr2 Second array
+   * @return {Array}      Array difference
+   */
+  function difference(arr1, arr2) {
+    return arr1.filter(function(item) {
+      return arr2.indexOf(item) === -1;
+    });
+  }
+
+  /**
+   * Retrieves the type name of an object or constructor. Fallback to "unknown"
+   * when it fails.
+   *
+   * @param  {Object} obj
+   * @return {String}
+   */
+  function typeName(obj) {
+    if (obj === null)
+      return "null";
+    if (typeof obj === "function")
+      return obj.name || obj.toString().match(/^function\s?([^\s(]*)/)[1];
+    if (typeof obj.constructor === "function")
+      return typeName(obj.constructor);
+    return "unknown";
+  }
+
+  /**
+   * Simple typed values validator.
+   *
+   * @constructor
+   * @param  {Object} schema Validation schema
+   */
+  function Validator(schema) {
+    this.schema = schema || {};
+  }
+
+  Validator.prototype = {
+    /**
+     * Validates all passed values against declared dependencies.
+     *
+     * @param  {Object} values  The values object
+     * @return {Object}         The validated values object
+     * @throws {TypeError}      If validation fails
+     */
+    validate: function(values) {
+      this._checkRequiredProperties(values);
+      this._checkRequiredTypes(values);
+      return values;
+    },
+
+    /**
+     * Checks if any of Object values matches any of current dependency type
+     * requirements.
+     *
+     * @param  {Object} values The values object
+     * @throws {TypeError}
+     */
+    _checkRequiredTypes: function(values) {
+      Object.keys(this.schema).forEach(function(name) {
+        var types = this.schema[name];
+        types = Array.isArray(types) ? types : [types];
+        if (!this._dependencyMatchTypes(values[name], types)) {
+          throw new TypeError("invalid dependency: " + name +
+                              "; expected " + types.map(typeName).join(", ") +
+                              ", got " + typeName(values[name]));
+        }
+      }, this);
+    },
+
+    /**
+     * Checks if a values object owns the required keys defined in dependencies.
+     * Values attached to these properties shouldn't be null nor undefined.
+     *
+     * @param  {Object} values The values object
+     * @throws {TypeError} If any dependency is missing.
+     */
+    _checkRequiredProperties: function(values) {
+      var definedProperties = Object.keys(values).filter(function(name) {
+        return typeof values[name] !== "undefined";
+      });
+      var diff = difference(Object.keys(this.schema), definedProperties);
+      if (diff.length > 0)
+        throw new TypeError("missing required " + diff.join(", "));
+    },
+
+    /**
+     * Checks if a given value matches any of the provided type requirements.
+     *
+     * @param  {Object} value  The value to check
+     * @param  {Array}  types  The list of types to check the value against
+     * @return {Boolean}
+     * @throws {TypeError} If the value doesn't match any types.
+     */
+    _dependencyMatchTypes: function(value, types) {
+      return types.some(function(Type) {
+        /*jshint eqeqeq:false*/
+        try {
+          return typeof Type === "undefined"         || // skip checking
+                 Type === null && value === null     || // null type
+                 value.constructor == Type           || // native type
+                 Type.prototype.isPrototypeOf(value) || // custom type
+                 typeName(value) === typeName(Type);    // type string eq.
+        } catch (e) {
+          return false;
+        }
+      });
+    }
+  };
+
+  return {
+    Validator: Validator
+  };
+})();
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        React.DOM.div({key: this.props.key, 
-             className: "alert alert-" + notification.get("level")}, 
-          React.DOM.span({className: "message"}, notification.get("message"))
+        React.DOM.div({className: "notificationContainer"}, 
+          React.DOM.div({key: this.props.key, 
+               className: "alert alert-" + notification.get("level")}, 
+            React.DOM.span({className: "message"}, notification.get("message"))
+          ), 
+          React.DOM.div({className: "detailsBar details-" + notification.get("level"), 
+               hidden: !notification.get("details")}, 
+            React.DOM.button({className: "detailsButton btn-info", 
+                    hidden: true || !notification.get("detailsButtonLabel")}, 
+              notification.get("detailsButtonLabel")
+            ), 
+            React.DOM.span({className: "details"}, notification.get("details"))
+          )
         )
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        <div key={this.props.key}
-             className={"alert alert-" + notification.get("level")}>
-          <span className="message">{notification.get("message")}</span>
+        <div className="notificationContainer">
+          <div key={this.props.key}
+               className={"alert alert-" + notification.get("level")}>
+            <span className="message">{notification.get("message")}</span>
+          </div>
+          <div className={"detailsBar details-" + notification.get("level")}
+               hidden={!notification.get("details")}>
+            <button className="detailsButton btn-info"
+                    hidden={true || !notification.get("detailsButtonLabel")}>
+              {notification.get("detailsButtonLabel")}
+            </button>
+            <span className="details">{notification.get("details")}</span>
+          </div>
         </div>
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/content/shared/js/websocket.js
+++ b/browser/components/loop/content/shared/js/websocket.js
@@ -94,20 +94,23 @@ loop.CallConnectionWebSocket = (function
       clearTimeout(this.connectDetails.timeout);
       delete this.connectDetails;
     },
 
     /**
      * Internal function called to resolve the connection promise.
      *
      * It will log an error if no promise is found.
+     *
+     * @param {String} progressState The current state of progress of the
+     *                               websocket.
      */
-    _completeConnection: function() {
+    _completeConnection: function(progressState) {
       if (this.connectDetails && this.connectDetails.resolve) {
-        this.connectDetails.resolve();
+        this.connectDetails.resolve(progressState);
         this._clearConnectionFlags();
         return;
       }
 
       console.error("Failed to complete connection promise - no promise available");
     },
 
     /**
@@ -167,16 +170,27 @@ loop.CallConnectionWebSocket = (function
       this._send({
         messageType: "action",
         event: "terminate",
         reason: "cancel"
       });
     },
 
     /**
+     * Notifies the server that something failed during setup.
+     */
+    mediaFail: function() {
+      this._send({
+        messageType: "action",
+        event: "terminate",
+        reason: "media-fail"
+      });
+    },
+
+    /**
      * Sends data on the websocket.
      *
      * @param {Object} data The data to send.
      */
     _send: function(data) {
       this._log("WS Sending", data);
 
       this.socket.send(JSON.stringify(data));
@@ -222,17 +236,17 @@ loop.CallConnectionWebSocket = (function
 
       this._log("WS Receiving", event.data);
 
       var previousState = this._lastServerState;
       this._lastServerState = msg.state;
 
       switch(msg.messageType) {
         case "hello":
-          this._completeConnection();
+          this._completeConnection(msg.state);
           break;
         case "progress":
           this.trigger("progress:" + msg.state);
           this.trigger("progress", msg, previousState);
           break;
       }
     },
 
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -11,16 +11,17 @@ browser.jar:
   content/browser/loop/libs/l10n.js                 (content/libs/l10n.js)
 
   # Desktop script
   content/browser/loop/js/client.js                 (content/js/client.js)
   content/browser/loop/js/conversation.js           (content/js/conversation.js)
   content/browser/loop/js/otconfig.js               (content/js/otconfig.js)
   content/browser/loop/js/panel.js                  (content/js/panel.js)
   content/browser/loop/js/contacts.js               (content/js/contacts.js)
+  content/browser/loop/js/conversationViews.js      (content/js/conversationViews.js)
 
   # Shared styles
   content/browser/loop/shared/css/reset.css         (content/shared/css/reset.css)
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/panel.css         (content/shared/css/panel.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
   content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
@@ -47,21 +48,26 @@ browser.jar:
   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/actions.js           (content/shared/js/actions.js)
+  content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
+  content/browser/loop/shared/js/dispatcher.js        (content/shared/js/dispatcher.js)
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
+  content/browser/loop/shared/js/otSdkDriver.js       (content/shared/js/otSdkDriver.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
+  content/browser/loop/shared/js/validate.js          (content/shared/js/validate.js)
   content/browser/loop/shared/js/websocket.js         (content/shared/js/websocket.js)
 
   # Shared libs
 #ifdef DEBUG
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1.js)
 #else
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1-prod.js)
 #endif
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -9,15 +9,16 @@ JAR_MANIFESTS += ['jar.mn']
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
 
 BROWSER_CHROME_MANIFESTS += [
     'test/mochitest/browser.ini',
 ]
 
 EXTRA_JS_MODULES.loop += [
     'CardDavImporter.jsm',
+    'GoogleImporter.jsm',
     'LoopContacts.jsm',
     'LoopStorage.jsm',
     'MozLoopAPI.jsm',
     'MozLoopPushHandler.jsm',
     'MozLoopService.jsm',
     'MozLoopWorker.js',
 ]
--- a/browser/components/loop/run-all-loop-tests.sh
+++ b/browser/components/loop/run-all-loop-tests.sh
@@ -4,8 +4,13 @@
 set -e
 
 # Main tests
 ./mach xpcshell-test browser/components/loop/
 ./mach marionette-test browser/components/loop/manifest.ini
 
 # The browser_parsable_css.js can fail if we add some css that isn't parsable.
 ./mach mochitest browser/components/loop/test/mochitest browser/base/content/test/general/browser_parsable_css.js
+
+# The check to make sure that the media devices can be used in Loop without
+# prompting is in browser_devices_get_user_media_about_urls.js. It's possible
+# to mess this up with CSP handling, and probably other changes, too.
+./mach mochitest browser/base/content/test/general/browser_devices_get_user_media_about_urls.js
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -110,18 +110,19 @@ body,
   font-weight: lighter;
 }
 
 .standalone-header-title {
   font-size: 1.8rem;
   line-height: 2.2rem;
 }
 
-.standalone-btn-label {
+p.standalone-btn-label {
   font-size: 1.2rem;
+  line-height: 1.5rem;
 }
 
 .light-color-font {
   opacity: .4;
   font-weight: normal;
 }
 
 .standalone-btn-chevron-menu-group {
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -9,19 +9,20 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
-  var sharedModels = loop.shared.models,
-      sharedViews = loop.shared.views,
-      sharedUtils = loop.shared.utils;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedUtils = loop.shared.utils;
 
   /**
    * Homepage view.
    */
   var HomeView = React.createClass({displayName: 'HomeView',
     render: function() {
       return (
         React.DOM.p(null, mozL10n.get("welcome"))
@@ -111,17 +112,18 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
     render: function() {
       return (
         React.DOM.h1({className: "standalone-header-title"}, 
-          React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
+          React.DOM.strong(null, mozL10n.get("brandShortname")), 
+          mozL10n.get("clientShortname")
         )
       );
     }
   });
 
   /**
    * The Firefox Marketplace exposes a web page that contains a postMesssage
    * based API that wraps a small set of functionality from the WebApps API
@@ -300,200 +302,193 @@ loop.webapp = (function($, _, OT, mozL10
                       onClick: this._cancelOutgoingCall}, 
                 React.DOM.span({className: "standalone-call-btn-text"}, 
                   mozL10n.get("initiate_call_cancel_button")
                 )
               ), 
               React.DOM.div({className: "flex-padding-1"})
             )
           ), 
+          ConversationFooter(null)
+        )
+      );
+    }
+  });
 
-          ConversationFooter(null)
+  var InitiateCallButton = React.createClass({displayName: 'InitiateCallButton',
+    mixins: [sharedMixins.DropdownMenuMixin],
+
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      startCall: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {disabled: false};
+    },
+
+    render: function() {
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showMenu
+      });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.props.disabled
+      });
+      return (
+        React.DOM.div({className: "standalone-btn-chevron-menu-group"}, 
+          React.DOM.div({className: "btn-group-chevron"}, 
+            React.DOM.div({className: "btn-group"}, 
+              React.DOM.button({className: "btn btn-large btn-accept", 
+                      onClick: this.props.startCall("audio-video"), 
+                      disabled: this.props.disabled, 
+                      title: mozL10n.get("initiate_audio_video_call_tooltip2")}, 
+                React.DOM.span({className: "standalone-call-btn-text"}, 
+                  this.props.caption
+                ), 
+                React.DOM.span({className: "standalone-call-btn-video-icon"})
+              ), 
+              React.DOM.div({className: chevronClasses, 
+                   onClick: this.toggleDropdownMenu}
+              )
+            ), 
+            React.DOM.ul({className: dropdownMenuClasses}, 
+              React.DOM.li(null, 
+                React.DOM.button({className: "start-audio-only-call", 
+                        onClick: this.props.startCall("audio"), 
+                        disabled: this.props.disabled}, 
+                  mozL10n.get("initiate_audio_call_button2")
+                )
+              )
+            )
+          )
         )
       );
     }
   });
 
   /**
-   * Conversation launcher view. A ConversationModel is associated and attached
-   * as a `model` property.
-   *
-   * Required properties:
-   * - {loop.shared.models.ConversationModel}    model    Conversation model.
-   * - {loop.shared.models.NotificationCollection} notifications
+   * Initiate conversation view.
    */
-  var StartConversationView = React.createClass({displayName: 'StartConversationView',
+  var InitiateConversationView = React.createClass({displayName: 'InitiateConversationView',
+    mixins: [Backbone.Events],
+
     propTypes: {
-      model: React.PropTypes.oneOfType([
-               React.PropTypes.instanceOf(sharedModels.ConversationModel),
-               React.PropTypes.instanceOf(FxOSConversationModel)
-             ]).isRequired,
+      conversation: React.PropTypes.oneOfType([
+                      React.PropTypes.instanceOf(sharedModels.ConversationModel),
+                      React.PropTypes.instanceOf(FxOSConversationModel)
+                    ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
-    getDefaultProps: function() {
-      return {showCallOptionsMenu: false};
+      client: React.PropTypes.object.isRequired,
+      title: React.PropTypes.string.isRequired,
+      callButtonLabel: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false,
-        showCallOptionsMenu: this.props.showCallOptionsMenu
+        disableCallButton: false
       };
     },
 
     componentDidMount: function() {
-      // Listen for events & hide dropdown menu if user clicks away
-      window.addEventListener("click", this.clickHandler);
-      this.props.model.listenTo(this.props.model, "session:error",
-                                this._onSessionError);
-      this.props.model.listenTo(this.props.model, "fxos:app-needed",
-                                this._onFxOSAppNeeded);
-      this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
-                                           this._setConversationTimestamp);
+      this.listenTo(this.props.conversation,
+                    "session:error", this._onSessionError);
+      this.listenTo(this.props.conversation,
+                    "fxos:app-needed", this._onFxOSAppNeeded);
+      this.props.client.requestCallUrlInfo(
+        this.props.conversation.get("loopToken"),
+        this._setConversationTimestamp);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.conversation);
+      localStorage.setItem("has-seen-tos", "true");
     },
 
     _onSessionError: function(error, l10nProps) {
       var errorL10n = error || "unable_retrieve_call_info";
       this.props.notifications.errorL10n(errorL10n, l10nProps);
       console.error(errorL10n);
     },
 
     _onFxOSAppNeeded: function() {
       this.setState({
-        marketplaceSrc: loop.config.marketplaceUrl
-      });
-      this.setState({
-        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
-          this.props.model
+        marketplaceSrc: loop.config.marketplaceUrl,
+        onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
+          this.props.conversation
         )
       });
      },
 
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function(callType) {
+    startCall: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
+        this.props.conversation.setupOutgoingCall(callType);
         this.setState({disableCallButton: true});
-        this.props.model.setupOutgoingCall();
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      localStorage.setItem("has-seen-tos", "true");
-    },
-
-    clickHandler: function(e) {
-      if (!e.target.classList.contains('btn-chevron') &&
-          this.state.showCallOptionsMenu) {
-            this._toggleCallOptionsMenu();
-      }
-    },
-
-    _toggleCallOptionsMenu: function() {
-      var state = this.state.showCallOptionsMenu;
-      this.setState({showCallOptionsMenu: !state});
-    },
-
     render: function() {
-      var tos_link_name = mozL10n.get("terms_of_use_link_text");
-      var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
+      var tosLinkName = mozL10n.get("terms_of_use_link_text");
+      var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
-          tos_link_name + "</a>",
+          tosLinkName + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
-          "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
+          "https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
       });
 
-      var dropdownMenuClasses = React.addons.classSet({
-        "native-dropdown-large-parent": true,
-        "standalone-dropdown-menu": true,
-        "visually-hidden": !this.state.showCallOptionsMenu
-      });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
-      var chevronClasses = React.addons.classSet({
-        "btn-chevron": true,
-        "disabled": this.state.disableCallButton
-      });
 
       return (
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
             React.DOM.p({className: "standalone-btn-label"}, 
-              mozL10n.get("initiate_call_button_label2")
+              this.props.title
             ), 
 
             React.DOM.div({id: "messages"}), 
 
             React.DOM.div({className: "btn-group"}, 
               React.DOM.div({className: "flex-padding-1"}), 
-              React.DOM.div({className: "standalone-btn-chevron-menu-group"}, 
-                React.DOM.div({className: "btn-group-chevron"}, 
-                  React.DOM.div({className: "btn-group"}, 
-
-                    React.DOM.button({className: "btn btn-large btn-accept", 
-                            onClick: this._initiateOutgoingCall("audio-video"), 
-                            disabled: this.state.disableCallButton, 
-                            title: mozL10n.get("initiate_audio_video_call_tooltip2")}, 
-                      React.DOM.span({className: "standalone-call-btn-text"}, 
-                        mozL10n.get("initiate_audio_video_call_button2")
-                      ), 
-                      React.DOM.span({className: "standalone-call-btn-video-icon"})
-                    ), 
-
-                    React.DOM.div({className: chevronClasses, 
-                         onClick: this._toggleCallOptionsMenu}
-                    )
-
-                  ), 
-
-                  React.DOM.ul({className: dropdownMenuClasses}, 
-                    React.DOM.li(null, 
-                      /*
-                       Button required for disabled state.
-                       */
-                      React.DOM.button({className: "start-audio-only-call", 
-                              onClick: this._initiateOutgoingCall("audio"), 
-                              disabled: this.state.disableCallButton}, 
-                        mozL10n.get("initiate_audio_call_button2")
-                      )
-                    )
-                  )
-
-                )
+              InitiateCallButton({
+                caption: this.props.callButtonLabel, 
+                disabled: this.state.disableCallButton, 
+                startCall: this.startCall}
               ), 
               React.DOM.div({className: "flex-padding-1"})
             ), 
 
             React.DOM.p({className: tosClasses, 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
@@ -533,16 +528,36 @@ loop.webapp = (function($, _, OT, mozL10
             audio: {enabled: false, visible: false}, 
             video: {enabled: false, visible: false}}
           )
         )
       );
     }
   });
 
+  var StartConversationView = React.createClass({displayName: 'StartConversationView',
+    render: function() {
+      return this.transferPropsTo(
+        InitiateConversationView({
+          title: mozL10n.get("initiate_call_button_label2"), 
+          callButtonLabel: mozL10n.get("initiate_audio_video_call_button2")})
+      );
+    }
+  });
+
+  var FailedConversationView = React.createClass({displayName: 'FailedConversationView',
+    render: function() {
+      return this.transferPropsTo(
+        InitiateConversationView({
+          title: mozL10n.get("call_failed_title"), 
+          callButtonLabel: mozL10n.get("retry_call_button")})
+      );
+    }
+  });
+
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
@@ -590,21 +605,29 @@ loop.webapp = (function($, _, OT, mozL10
       }.bind(this);
     },
 
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
-        case "failure":
         case "start": {
           return (
             StartConversationView({
-              model: this.props.conversation, 
+              conversation: this.props.conversation, 
+              notifications: this.props.notifications, 
+              client: this.props.client}
+            )
+          );
+        }
+        case "failure": {
+          return (
+            FailedConversationView({
+              conversation: this.props.conversation, 
               notifications: this.props.notifications, 
               client: this.props.client}
             )
           );
         }
         case "pending": {
           return PendingConversationView({websocket: this._websocket});
         }
@@ -770,28 +793,27 @@ loop.webapp = (function($, _, OT, mozL10
           break;
         }
       }
     },
 
     /**
      * Handles call rejection.
      *
-     * @param {String} reason The reason the call was terminated.
+     * @param {String} reason The reason the call was terminated (reject, busy,
+     *                        timeout, cancel, media-fail, user-unknown, closed)
      */
     _handleCallTerminated: function(reason) {
-      if (reason !== "cancel") {
-        // XXX This should really display the call failed view - bug 1046959
-        // will implement this.
-        this.props.notifications.errorL10n("call_timeout_notification_text");
+      if (reason === "cancel") {
+        this.setState({callStatus: "start"});
+        return;
       }
-      // redirects the user to the call start view
-      // XXX should switch callStatus to failed for specific reasons when we
-      // get the call failed view; for now, switch back to start.
-      this.setState({callStatus: "start"});
+      // XXX later, we'll want to display more meaningfull messages (needs UX)
+      this.props.notifications.errorL10n("call_timeout_notification_text");
+      this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -888,16 +910,17 @@ loop.webapp = (function($, _, OT, mozL10
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
+    FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappRootView: WebappRootView,
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -9,19 +9,20 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
-  var sharedModels = loop.shared.models,
-      sharedViews = loop.shared.views,
-      sharedUtils = loop.shared.utils;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedUtils = loop.shared.utils;
 
   /**
    * Homepage view.
    */
   var HomeView = React.createClass({
     render: function() {
       return (
         <p>{mozL10n.get("welcome")}</p>
@@ -111,17 +112,18 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var ConversationBranding = React.createClass({
     render: function() {
       return (
         <h1 className="standalone-header-title">
-          <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
+          <strong>{mozL10n.get("brandShortname")}</strong>
+          {mozL10n.get("clientShortname")}
         </h1>
       );
     }
   });
 
   /**
    * The Firefox Marketplace exposes a web page that contains a postMesssage
    * based API that wraps a small set of functionality from the WebApps API
@@ -229,17 +231,17 @@ loop.webapp = (function($, _, OT, mozL10
 
       return (
         <header className="standalone-header header-box container-box">
           <ConversationBranding />
           <div className="loop-logo" title="Firefox WebRTC! logo"></div>
           <h3 className="call-url">
             {conversationUrl}
           </h3>
-          <h4 className={urlCreationDateClasses} >
+          <h4 className={urlCreationDateClasses}>
             {callUrlCreationDateString}
           </h4>
         </header>
       );
     }
   });
 
   var ConversationFooter = React.createClass({
@@ -281,221 +283,214 @@ loop.webapp = (function($, _, OT, mozL10
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
         <div className="container">
           <div className="container-box">
             <header className="pending-header header-box">
               <ConversationBranding />
             </header>
 
-            <div id="cameraPreview"></div>
+            <div id="cameraPreview" />
 
-            <div id="messages"></div>
+            <div id="messages" />
 
             <p className="standalone-btn-label">
               {callState}
             </p>
 
             <div className="btn-pending-cancel-group btn-group">
-              <div className="flex-padding-1"></div>
+              <div className="flex-padding-1" />
               <button className="btn btn-large btn-cancel"
                       onClick={this._cancelOutgoingCall} >
                 <span className="standalone-call-btn-text">
                   {mozL10n.get("initiate_call_cancel_button")}
                 </span>
               </button>
-              <div className="flex-padding-1"></div>
+              <div className="flex-padding-1" />
             </div>
           </div>
+          <ConversationFooter />
+        </div>
+      );
+    }
+  });
 
-          <ConversationFooter />
+  var InitiateCallButton = React.createClass({
+    mixins: [sharedMixins.DropdownMenuMixin],
+
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      startCall: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {disabled: false};
+    },
+
+    render: function() {
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showMenu
+      });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.props.disabled
+      });
+      return (
+        <div className="standalone-btn-chevron-menu-group">
+          <div className="btn-group-chevron">
+            <div className="btn-group">
+              <button className="btn btn-large btn-accept"
+                      onClick={this.props.startCall("audio-video")}
+                      disabled={this.props.disabled}
+                      title={mozL10n.get("initiate_audio_video_call_tooltip2")}>
+                <span className="standalone-call-btn-text">
+                  {this.props.caption}
+                </span>
+                <span className="standalone-call-btn-video-icon" />
+              </button>
+              <div className={chevronClasses}
+                   onClick={this.toggleDropdownMenu}>
+              </div>
+            </div>
+            <ul className={dropdownMenuClasses}>
+              <li>
+                <button className="start-audio-only-call"
+                        onClick={this.props.startCall("audio")}
+                        disabled={this.props.disabled}>
+                  {mozL10n.get("initiate_audio_call_button2")}
+                </button>
+              </li>
+            </ul>
+          </div>
         </div>
       );
     }
   });
 
   /**
-   * Conversation launcher view. A ConversationModel is associated and attached
-   * as a `model` property.
-   *
-   * Required properties:
-   * - {loop.shared.models.ConversationModel}    model    Conversation model.
-   * - {loop.shared.models.NotificationCollection} notifications
+   * Initiate conversation view.
    */
-  var StartConversationView = React.createClass({
+  var InitiateConversationView = React.createClass({
+    mixins: [Backbone.Events],
+
     propTypes: {
-      model: React.PropTypes.oneOfType([
-               React.PropTypes.instanceOf(sharedModels.ConversationModel),
-               React.PropTypes.instanceOf(FxOSConversationModel)
-             ]).isRequired,
+      conversation: React.PropTypes.oneOfType([
+                      React.PropTypes.instanceOf(sharedModels.ConversationModel),
+                      React.PropTypes.instanceOf(FxOSConversationModel)
+                    ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
-    getDefaultProps: function() {
-      return {showCallOptionsMenu: false};
+      client: React.PropTypes.object.isRequired,
+      title: React.PropTypes.string.isRequired,
+      callButtonLabel: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false,
-        showCallOptionsMenu: this.props.showCallOptionsMenu
+        disableCallButton: false
       };
     },
 
     componentDidMount: function() {
-      // Listen for events & hide dropdown menu if user clicks away
-      window.addEventListener("click", this.clickHandler);
-      this.props.model.listenTo(this.props.model, "session:error",
-                                this._onSessionError);
-      this.props.model.listenTo(this.props.model, "fxos:app-needed",
-                                this._onFxOSAppNeeded);
-      this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
-                                           this._setConversationTimestamp);
+      this.listenTo(this.props.conversation,
+                    "session:error", this._onSessionError);
+      this.listenTo(this.props.conversation,
+                    "fxos:app-needed", this._onFxOSAppNeeded);
+      this.props.client.requestCallUrlInfo(
+        this.props.conversation.get("loopToken"),
+        this._setConversationTimestamp);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.conversation);
+      localStorage.setItem("has-seen-tos", "true");
     },
 
     _onSessionError: function(error, l10nProps) {
       var errorL10n = error || "unable_retrieve_call_info";
       this.props.notifications.errorL10n(errorL10n, l10nProps);
       console.error(errorL10n);
     },
 
     _onFxOSAppNeeded: function() {
       this.setState({
-        marketplaceSrc: loop.config.marketplaceUrl
-      });
-      this.setState({
-        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
-          this.props.model
+        marketplaceSrc: loop.config.marketplaceUrl,
+        onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
+          this.props.conversation
         )
       });
      },
 
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function(callType) {
+    startCall: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
+        this.props.conversation.setupOutgoingCall(callType);
         this.setState({disableCallButton: true});
-        this.props.model.setupOutgoingCall();
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      localStorage.setItem("has-seen-tos", "true");
-    },
-
-    clickHandler: function(e) {
-      if (!e.target.classList.contains('btn-chevron') &&
-          this.state.showCallOptionsMenu) {
-            this._toggleCallOptionsMenu();
-      }
-    },
-
-    _toggleCallOptionsMenu: function() {
-      var state = this.state.showCallOptionsMenu;
-      this.setState({showCallOptionsMenu: !state});
-    },
-
     render: function() {
-      var tos_link_name = mozL10n.get("terms_of_use_link_text");
-      var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
+      var tosLinkName = mozL10n.get("terms_of_use_link_text");
+      var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
-          tos_link_name + "</a>",
+          tosLinkName + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
-          "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
+          "https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
       });
 
-      var dropdownMenuClasses = React.addons.classSet({
-        "native-dropdown-large-parent": true,
-        "standalone-dropdown-menu": true,
-        "visually-hidden": !this.state.showCallOptionsMenu
-      });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
-      var chevronClasses = React.addons.classSet({
-        "btn-chevron": true,
-        "disabled": this.state.disableCallButton
-      });
 
       return (
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
             <p className="standalone-btn-label">
-              {mozL10n.get("initiate_call_button_label2")}
+              {this.props.title}
             </p>
 
             <div id="messages"></div>
 
             <div className="btn-group">
-              <div className="flex-padding-1"></div>
-              <div className="standalone-btn-chevron-menu-group">
-                <div className="btn-group-chevron">
-                  <div className="btn-group">
-
-                    <button className="btn btn-large btn-accept"
-                            onClick={this._initiateOutgoingCall("audio-video")}
-                            disabled={this.state.disableCallButton}
-                            title={mozL10n.get("initiate_audio_video_call_tooltip2")} >
-                      <span className="standalone-call-btn-text">
-                        {mozL10n.get("initiate_audio_video_call_button2")}
-                      </span>
-                      <span className="standalone-call-btn-video-icon"></span>
-                    </button>
-
-                    <div className={chevronClasses}
-                         onClick={this._toggleCallOptionsMenu}>
-                    </div>
-
-                  </div>
-
-                  <ul className={dropdownMenuClasses}>
-                    <li>
-                      {/*
-                       Button required for disabled state.
-                       */}
-                      <button className="start-audio-only-call"
-                              onClick={this._initiateOutgoingCall("audio")}
-                              disabled={this.state.disableCallButton} >
-                        {mozL10n.get("initiate_audio_call_button2")}
-                      </button>
-                    </li>
-                  </ul>
-
-                </div>
-              </div>
-              <div className="flex-padding-1"></div>
+              <div className="flex-padding-1" />
+              <InitiateCallButton
+                caption={this.props.callButtonLabel}
+                disabled={this.state.disableCallButton}
+                startCall={this.startCall}
+              />
+              <div className="flex-padding-1" />
             </div>
 
             <p className={tosClasses}
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
           <FxOSHiddenMarketplace
             marketplaceSrc={this.state.marketplaceSrc}
@@ -533,16 +528,36 @@ loop.webapp = (function($, _, OT, mozL10
             audio={{enabled: false, visible: false}}
             video={{enabled: false, visible: false}}
           />
         </div>
       );
     }
   });
 
+  var StartConversationView = React.createClass({
+    render: function() {
+      return this.transferPropsTo(
+        <InitiateConversationView
+          title={mozL10n.get("initiate_call_button_label2")}
+          callButtonLabel={mozL10n.get("initiate_audio_video_call_button2")} />
+      );
+    }
+  });
+
+  var FailedConversationView = React.createClass({
+    render: function() {
+      return this.transferPropsTo(
+        <InitiateConversationView
+          title={mozL10n.get("call_failed_title")}
+          callButtonLabel={mozL10n.get("retry_call_button")} />
+      );
+    }
+  });
+
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({
     propTypes: {
@@ -590,21 +605,29 @@ loop.webapp = (function($, _, OT, mozL10
       }.bind(this);
     },
 
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
-        case "failure":
         case "start": {
           return (
             <StartConversationView
-              model={this.props.conversation}
+              conversation={this.props.conversation}
+              notifications={this.props.notifications}
+              client={this.props.client}
+            />
+          );
+        }
+        case "failure": {
+          return (
+            <FailedConversationView
+              conversation={this.props.conversation}
               notifications={this.props.notifications}
               client={this.props.client}
             />
           );
         }
         case "pending": {
           return <PendingConversationView websocket={this._websocket} />;
         }
@@ -770,28 +793,27 @@ loop.webapp = (function($, _, OT, mozL10
           break;
         }
       }
     },
 
     /**
      * Handles call rejection.
      *
-     * @param {String} reason The reason the call was terminated.
+     * @param {String} reason The reason the call was terminated (reject, busy,
+     *                        timeout, cancel, media-fail, user-unknown, closed)
      */
     _handleCallTerminated: function(reason) {
-      if (reason !== "cancel") {
-        // XXX This should really display the call failed view - bug 1046959
-        // will implement this.
-        this.props.notifications.errorL10n("call_timeout_notification_text");
+      if (reason === "cancel") {
+        this.setState({callStatus: "start"});
+        return;
       }
-      // redirects the user to the call start view
-      // XXX should switch callStatus to failed for specific reasons when we
-      // get the call failed view; for now, switch back to start.
-      this.setState({callStatus: "start"});
+      // XXX later, we'll want to display more meaningfull messages (needs UX)
+      this.props.notifications.errorL10n("call_timeout_notification_text");
+      this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -888,16 +910,17 @@ loop.webapp = (function($, _, OT, mozL10
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
+    FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappRootView: WebappRootView,
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -1,15 +1,16 @@
 ## LOCALIZATION NOTE: In this file, don't translate the part between {{..}}
 restart_call=Rejoin
 conversation_has_ended=Your conversation has ended.
 call_timeout_notification_text=Your call did not go through.
 missing_conversation_info=Missing conversation information.
 network_disconnected=The network connection terminated abruptly.
 peer_ended_conversation2=The person you were calling has ended the conversation.
+call_failed_title=Call failed.
 connection_error_see_console_notification=Call failed; see console for details.
 generic_failure_title=Something went wrong.
 generic_failure_with_reason2=You can try again or email a link to be reached at later.
 generic_failure_no_reason2=Would you like to try again?
 retry_call_button=Retry
 feedback_report_user_button=Report User
 unable_retrieve_call_info=Unable to retrieve conversation information.
 hangup_button_title=Hang up
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -95,17 +95,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.deleteCallUrl(fakeToken, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
     });
 
     describe("#requestCallUrl", function() {
       it("should ensure loop is registered", function() {
         client.requestCallUrl("foo", callback);
 
@@ -213,17 +213,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, null, "{}");
 
@@ -248,10 +248,75 @@ describe("loop.Client", function() {
             sinon.assert.calledWith(mozLoop.telemetryAdd,
                                     "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
                                     false);
 
             done();
           });
         });
     });
+
+    describe("#setupOutgoingCall", function() {
+      var calleeIds, callType;
+
+      beforeEach(function() {
+        calleeIds = [
+          "fakeemail", "fake phone"
+        ];
+        callType = "audio";
+      });
+
+      it("should make a POST call to /calls", function() {
+        client.setupOutgoingCall(calleeIds, callType);
+
+        sinon.assert.calledOnce(hawkRequestStub);
+        sinon.assert.calledWith(hawkRequestStub,
+          mozLoop.LOOP_SESSION_TYPE.FXA,
+          "/calls",
+          "POST",
+          { calleeId: calleeIds, callType: callType }
+        );
+      });
+
+      it("should call the callback if the request is successful", function() {
+        var requestData = {
+          apiKey: "fake",
+          callId: "fakeCall",
+          progressURL: "fakeurl",
+          sessionId: "12345678",
+          sessionToken: "15263748",
+          websocketToken: "13572468"
+        };
+
+        hawkRequestStub.callsArgWith(4, null, JSON.stringify(requestData));
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithExactly(callback, null, requestData);
+      });
+
+      it("should send an error when the request fails", function() {
+        hawkRequestStub.callsArgWith(4, fakeErrorRes);
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
+          return err.code == 400 && "invalid token" == err.message;
+        }));
+      });
+
+      it("should send an error if the data is not valid", function() {
+        // Sets up the hawkRequest stub to trigger the callback with
+        // an error
+        hawkRequestStub.callsArgWith(4, null, "{}");
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
+          return /Invalid data received/.test(err.message);
+        }));
+      });
+    });
   });
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -0,0 +1,365 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.conversationViews", function () {
+  var sandbox, oldTitle, view, dispatcher, contact;
+
+  var CALL_STATES = loop.store.CALL_STATES;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    oldTitle = document.title;
+    sandbox.stub(document.mozL10n, "get", function(x) {
+      return x;
+    });
+
+    dispatcher = new loop.Dispatcher();
+    sandbox.stub(dispatcher, "dispatch");
+
+    contact = {
+      name: [ "mrsmith" ],
+      email: [{
+        type: "home",
+        value: "fakeEmail",
+        pref: true
+      }]
+    };
+  });
+
+  afterEach(function() {
+    document.title = oldTitle;
+    view = undefined;
+    sandbox.restore();
+  });
+
+  describe("ConversationDetailView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.ConversationDetailView(props));
+    }
+
+    it("should set the document title to the calledId", function() {
+      mountTestComponent({contact: contact});
+
+      expect(document.title).eql("mrsmith");
+    });
+
+    it("should set display the calledId", function() {
+      view = mountTestComponent({contact: contact});
+
+      expect(TestUtils.findRenderedDOMComponentWithTag(
+        view, "h2").props.children).eql("mrsmith");
+    });
+
+    it("should fallback to the email if the contact name is not defined",
+      function() {
+        delete contact.name;
+
+        view = mountTestComponent({contact: contact});
+
+        expect(TestUtils.findRenderedDOMComponentWithTag(
+          view, "h2").props.children).eql("fakeEmail");
+      }
+    );
+  });
+
+  describe("PendingConversationView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.PendingConversationView(props));
+    }
+
+    it("should set display connecting string when the state is not alerting",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          contact: contact,
+          dispatcher: dispatcher
+        });
+
+        var label = TestUtils.findRenderedDOMComponentWithClass(
+          view, "btn-label").props.children;
+
+        expect(label).to.have.string("connecting");
+    });
+
+    it("should set display ringing string when the state is alerting",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.ALERTING,
+          contact: contact,
+          dispatcher: dispatcher
+        });
+
+        var label = TestUtils.findRenderedDOMComponentWithClass(
+          view, "btn-label").props.children;
+
+        expect(label).to.have.string("ringing");
+    });
+
+    it("should disable the cancel button if enableCancelButton is false",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          contact: contact,
+          dispatcher: dispatcher,
+          enableCancelButton: false
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        expect(cancelBtn.classList.contains("disabled")).eql(true);
+      });
+
+    it("should enable the cancel button if enableCancelButton is false",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          contact: contact,
+          dispatcher: dispatcher,
+          enableCancelButton: true
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        expect(cancelBtn.classList.contains("disabled")).eql(false);
+      });
+
+    it("should dispatch a cancelCall action when the cancel button is pressed",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          contact: contact,
+          dispatcher: dispatcher
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        React.addons.TestUtils.Simulate.click(cancelBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "cancelCall"));
+      });
+  });
+
+  describe("CallFailedView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.CallFailedView({
+          dispatcher: dispatcher
+        }));
+    }
+
+    it("should dispatch a retryCall action when the retry button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        var retryBtn = view.getDOMNode().querySelector('.btn-retry');
+
+        React.addons.TestUtils.Simulate.click(retryBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "retryCall"));
+      });
+
+    it("should dispatch a cancelCall action when the cancel button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        React.addons.TestUtils.Simulate.click(cancelBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "cancelCall"));
+      });
+  });
+
+  describe("OngoingConversationView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.OngoingConversationView(props));
+    }
+
+    it("should dispatch a setupStreamElements action when the view is created",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setupStreamElements"));
+      });
+
+    it("should dispatch a hangupCall action when the hangup button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher
+        });
+
+        var hangupBtn = view.getDOMNode().querySelector('.btn-hangup');
+
+        React.addons.TestUtils.Simulate.click(hangupBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "hangupCall"));
+      });
+
+    it("should dispatch a setMute action when the audio mute button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher,
+          audio: {enabled: false}
+        });
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", true));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "audio"));
+      });
+
+    it("should dispatch a setMute action when the video mute button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher,
+          video: {enabled: true}
+        });
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", false));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "video"));
+      });
+
+    it("should set the mute button as mute off", function() {
+      view = mountTestComponent({
+        dispatcher: dispatcher,
+        video: {enabled: true}
+      });
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+      expect(muteBtn.classList.contains("muted")).eql(false);
+    });
+
+    it("should set the mute button as mute on", function() {
+      view = mountTestComponent({
+        dispatcher: dispatcher,
+        audio: {enabled: false}
+      });
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+      expect(muteBtn.classList.contains("muted")).eql(true);
+    });
+  });
+
+  describe("OutgoingConversationView", function() {
+    var store;
+
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.OutgoingConversationView({
+          dispatcher: dispatcher,
+          store: store
+        }));
+    }
+
+    beforeEach(function() {
+      navigator.mozLoop = {
+        getLoopCharPref: function() { return "fake"; },
+        appVersionInfo: sinon.spy()
+      };
+
+      store = new loop.store.ConversationStore({}, {
+        dispatcher: dispatcher,
+        client: {},
+        sdkDriver: {}
+      });
+    });
+
+    afterEach(function() {
+      delete navigator.mozLoop;
+    });
+
+    it("should render the CallFailedView when the call state is 'terminated'",
+      function() {
+        store.set({callState: CALL_STATES.TERMINATED});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.CallFailedView);
+    });
+
+    it("should render the PendingConversationView when the call state is 'init'",
+      function() {
+        store.set({
+          callState: CALL_STATES.INIT,
+          contact: contact
+        });
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.PendingConversationView);
+    });
+
+    it("should render the OngoingConversationView when the call state is 'ongoing'",
+      function() {
+        store.set({callState: CALL_STATES.ONGOING});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.OngoingConversationView);
+    });
+
+    it("should render the FeedbackView when the call state is 'finished'",
+      function() {
+        store.set({callState: CALL_STATES.FINISHED});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.shared.views.FeedbackView);
+    });
+
+    it("should update the rendered views when the state is changed.",
+      function() {
+        store.set({
+          callState: CALL_STATES.INIT,
+          contact: contact
+        });
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.PendingConversationView);
+
+        store.set({callState: CALL_STATES.TERMINATED});
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.CallFailedView);
+    });
+  });
+});
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -6,18 +6,17 @@
 
 var expect = chai.expect;
 
 describe("loop.conversation", function() {
   "use strict";
 
   var sharedModels = loop.shared.models,
       sharedView = loop.shared.views,
-      sandbox,
-      notifications;
+      sandbox;
 
   // XXX refactor to Just Work with "sandbox.stubComponent" or else
   // just pass in the sandbox and put somewhere generally usable
 
   function stubComponent(obj, component, mockTagName){
     var reactClass = React.createClass({
       render: function() {
         var mockTagName = mockTagName || "div";
@@ -25,28 +24,27 @@ describe("loop.conversation", function()
       }
     });
     return sandbox.stub(obj, component, reactClass);
   }
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
-    notifications = new loop.shared.models.NotificationCollection();
 
     navigator.mozLoop = {
       doNotDisturb: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
       get locale() {
         return "en-US";
       },
       setLoopCharPref: sinon.stub(),
-      getLoopCharPref: sinon.stub(),
+      getLoopCharPref: sinon.stub().returns(null),
       getLoopBoolPref: sinon.stub(),
       getCallData: sinon.stub(),
       releaseCallData: sinon.stub(),
       startAlerting: sinon.stub(),
       stopAlerting: sinon.stub(),
       ensureRegistered: sinon.stub(),
       get appVersionInfo() {
         return {
@@ -54,34 +52,40 @@ describe("loop.conversation", function()
           channel: "test",
           platform: "test"
         };
       }
     };
 
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
+    sandbox.stub(document.mozL10n, "get", function(x) {
+      return x;
+    });
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("#init", function() {
-    var oldTitle;
-
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(document.mozL10n, "initialize");
 
       sandbox.stub(loop.shared.models.ConversationModel.prototype,
         "initialize");
 
+      sandbox.stub(loop.Dispatcher.prototype, "dispatch");
+
+      sandbox.stub(loop.shared.utils.Helper.prototype,
+        "locationHash").returns("#incoming/42");
+
       window.OT = {
         overrideGuidStorage: sinon.stub()
       };
     });
 
     afterEach(function() {
       delete window.OT;
     });
@@ -89,38 +93,121 @@ describe("loop.conversation", function()
     it("should initalize L10n", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(document.mozL10n.initialize);
       sinon.assert.calledWithExactly(document.mozL10n.initialize,
         navigator.mozLoop);
     });
 
-    it("should create the IncomingConversationView", function() {
+    it("should create the ConversationControllerView", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(React.renderComponent);
       sinon.assert.calledWith(React.renderComponent,
         sinon.match(function(value) {
           return TestUtils.isDescriptorOfType(value,
-            loop.conversation.IncomingConversationView);
+            loop.conversation.ConversationControllerView);
       }));
     });
 
+    it("should trigger a gatherCallData action", function() {
+      loop.conversation.init();
+
+      sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+      sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+        new loop.shared.actions.GatherCallData({
+          callId: "42",
+          outgoing: false
+        }));
+    });
+
+    it("should trigger an outgoing gatherCallData action for outgoing calls",
+      function() {
+        loop.shared.utils.Helper.prototype.locationHash.returns("#outgoing/24");
+
+        loop.conversation.init();
+
+        sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+        sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+          new loop.shared.actions.GatherCallData({
+            callId: "24",
+            outgoing: true
+          }));
+      });
+  });
+
+  describe("ConversationControllerView", function() {
+    var store, conversation, client, ccView, oldTitle, dispatcher;
+
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        loop.conversation.ConversationControllerView({
+          client: client,
+          conversation: conversation,
+          sdk: {},
+          store: store
+        }));
+    }
+
+    beforeEach(function() {
+      oldTitle = document.title;
+      client = new loop.Client();
+      conversation = new loop.shared.models.ConversationModel({}, {
+        sdk: {}
+      });
+      dispatcher = new loop.Dispatcher();
+      store = new loop.store.ConversationStore({
+        contact: {
+          name: [ "Mr Smith" ],
+          email: [{
+            type: "home",
+            value: "fakeEmail",
+            pref: true
+          }]
+        }
+      }, {
+        client: client,
+        dispatcher: dispatcher,
+        sdkDriver: {}
+      });
+    });
+
+    afterEach(function() {
+      ccView = undefined;
+      document.title = oldTitle;
+    });
+
+    it("should display the OutgoingConversationView for outgoing calls", function() {
+      store.set({outgoing: true});
+
+      ccView = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(ccView,
+        loop.conversationViews.OutgoingConversationView);
+    });
+
+    it("should display the IncomingConversationView for incoming calls", function() {
+      store.set({outgoing: false});
+
+      ccView = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(ccView,
+        loop.conversation.IncomingConversationView);
+    });
   });
 
   describe("IncomingConversationView", function() {
     var conversation, client, icView, oldTitle;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.IncomingConversationView({
           client: client,
           conversation: conversation,
-          notifications: notifications,
           sdk: {}
         }));
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
@@ -132,19 +219,23 @@ describe("loop.conversation", function()
 
     afterEach(function() {
       icView = undefined;
       document.title = oldTitle;
     });
 
     describe("start", function() {
       it("should set the title to incoming_call_title2", function() {
-        sandbox.stub(document.mozL10n, "get", function(x) {
-          return x;
-        });
+        navigator.mozLoop.getCallData = function() {
+          return {
+            progressURL:    "fake",
+            websocketToken: "fake",
+            callId: 42
+          };
+        };
 
         icView = mountTestComponent();
 
         expect(document.title).eql("incoming_call_title2");
       });
     });
 
     describe("componentDidMount", function() {
@@ -226,35 +317,42 @@ describe("loop.conversation", function()
               rejectWebSocketConnect = reject;
             });
 
             sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect").returns(promise);
           });
 
           it("should set the state to incoming on success", function(done) {
             icView = mountTestComponent();
-            resolveWebSocketConnect();
+            resolveWebSocketConnect("incoming");
 
             promise.then(function () {
               expect(icView.state.callStatus).eql("incoming");
               done();
             });
           });
 
-          it("should display an error if the websocket failed to connect", function(done) {
-            sandbox.stub(notifications, "errorL10n");
+          it("should set the state to close on success if the progress " +
+            "state is terminated", function(done) {
+              icView = mountTestComponent();
+              resolveWebSocketConnect("terminated");
 
+              promise.then(function () {
+                expect(icView.state.callStatus).eql("close");
+                done();
+              });
+            });
+
+          // XXX implement me as part of bug 1047410
+          // see https://hg.mozilla.org/integration/fx-team/rev/5d2c69ebb321#l18.259
+          it.skip("should should switch view state to failed", function(done) {
             icView = mountTestComponent();
             rejectWebSocketConnect();
 
-            promise.then(function() {
-            }, function () {
-              sinon.assert.calledOnce(notifications.errorL10n);
-              sinon.assert.calledWithExactly(notifications.errorL10n,
-                "cannot_start_call_session_not_ready");
+            promise.then(function() {}, function() {
               done();
             });
           });
         });
 
         describe("WebSocket Events", function() {
           describe("Call cancelled or timed out before acceptance", function() {
             beforeEach(function() {
@@ -528,24 +626,31 @@ describe("loop.conversation", function()
           function() {
             conversation.trigger("session:peer-hungup");
 
               TestUtils.findRenderedComponentWithType(icView,
                 sharedView.FeedbackView);
           });
       });
 
-      describe("session:peer-hungup", function() {
+      describe("session:network-disconnected", function() {
         it("should navigate to call/feedback when network disconnects",
           function() {
             conversation.trigger("session:network-disconnected");
 
               TestUtils.findRenderedComponentWithType(icView,
                 sharedView.FeedbackView);
           });
+
+        it("should update the conversation window toolbar title",
+          function() {
+            conversation.trigger("session:network-disconnected");
+
+            expect(document.title).eql("generic_failure_title");
+          });
       });
 
       describe("Published and Subscribed Streams", function() {
         beforeEach(function() {
           icView._websocket = {
             mediaUp: sinon.spy()
           };
         });
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -29,29 +29,36 @@
     /*global chai,mocha */
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
+  <script src="../../content/shared/js/conversationStore.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
+  <script src="../../content/shared/js/actions.js"></script>
+  <script src="../../content/shared/js/validate.js"></script>
+  <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/js/client.js"></script>
+  <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
   <script src="panel_test.js"></script>
+  <script src="conversationViews_test.js"></script>
   <script>
     // Stop the default init functions running to avoid conflicts in tests
     document.removeEventListener('DOMContentLoaded', loop.panel.init);
     document.removeEventListener('DOMContentLoaded', loop.conversation.init);
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -20,27 +20,29 @@ describe("loop.panel", function() {
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function (xhr) {
       requests.push(xhr);
     };
     notifications = new loop.shared.models.NotificationCollection();
 
     navigator.mozLoop = {
       doNotDisturb: true,
+      fxAEnabled: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
       get locale() {
         return "en-US";
       },
       setLoopCharPref: sandbox.stub(),
       getLoopCharPref: sandbox.stub().returns("unseen"),
       copyString: sandbox.stub(),
       noteCallUrlExpiry: sinon.spy(),
       composeEmail: sinon.spy(),
+      telemetryAdd: sinon.spy(),
       contacts: {
         getAll: function(callback) {
           callback(null, []);
         },
         on: sandbox.stub()
       }
     };
 
@@ -135,17 +137,18 @@ describe("loop.panel", function() {
       fakeClient = {
         requestCallUrl: function(_, cb) {
           cb(null, callUrlData);
         }
       };
 
       view = TestUtils.renderIntoDocument(loop.panel.PanelView({
         notifications: notifications,
-        client: fakeClient
+        client: fakeClient,
+        showTabButtons: true,
       }));
 
       [callTab, contactsTab] =
         TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
     });
 
     describe('TabView', function() {
       it("should select contacts tab when clicking tab button", function() {
@@ -171,27 +174,49 @@ describe("loop.panel", function() {
           navigator.mozLoop.loggedInToFxA = false;
           navigator.mozLoop.logInToFxA = sandbox.stub();
 
           TestUtils.Simulate.click(
             view.getDOMNode().querySelector(".signin-link a"));
 
           sinon.assert.calledOnce(navigator.mozLoop.logInToFxA);
         });
+
+      it("should be hidden if FxA is not enabled",
+        function() {
+          navigator.mozLoop.fxAEnabled = false;
+          var view = TestUtils.renderIntoDocument(loop.panel.AuthLink());
+          expect(view.getDOMNode()).to.be.null;
       });
 
+      afterEach(function() {
+        navigator.mozLoop.fxAEnabled = true;
+      });
+    });
+
     describe("SettingsDropdown", function() {
       var view;
 
       beforeEach(function() {
         navigator.mozLoop.logInToFxA = sandbox.stub();
         navigator.mozLoop.logOutFromFxA = sandbox.stub();
         navigator.mozLoop.openFxASettings = sandbox.stub();
       });
 
+      afterEach(function() {
+        navigator.mozLoop.fxAEnabled = true;
+      });
+
+      it("should be hidden if FxA is not enabled",
+        function() {
+          navigator.mozLoop.fxAEnabled = false;
+          var view = TestUtils.renderIntoDocument(loop.panel.SettingsDropdown());
+          expect(view.getDOMNode()).to.be.null;
+      });
+
       it("should show a signin entry when user is not authenticated",
         function() {
           navigator.mozLoop.loggedInToFxA = false;
 
           var view = TestUtils.renderIntoDocument(loop.panel.SettingsDropdown());
 
           expect(view.getDOMNode().querySelectorAll(".icon-signout"))
             .to.have.length.of(0);
@@ -341,18 +366,18 @@ describe("loop.panel", function() {
         });
 
       it("should update CallUrlResult with the call url", function() {
         var urlField = view.getDOMNode().querySelector("input[type='url']");
 
         expect(urlField.value).eql(callUrlData.callUrl);
       });
 
-      it("should reset all pending notifications", function() {
-        sinon.assert.calledOnce(view.props.notifications.reset);
+      it("should have 0 pending notifications", function() {
+        expect(view.props.notifications.length).eql(0);
       });
 
       it("should display a share button for email", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
@@ -398,16 +423,37 @@ describe("loop.panel", function() {
 
           TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
 
           sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
           sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
             6000);
         });
 
+      it("should call mozLoop.telemetryAdd when the url is copied via button",
+        function() {
+          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(".button-copy"));
+
+          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
+          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
+                                  "LOOP_CLIENT_CALL_URL_SHARED",
+                                  true);
+        });
+
       it("should note the call url expiry when the url is emailed",
         function() {
           var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
             notifications: notifications,
             client: fakeClient
           }));
           view.setState({
             pending: false,
@@ -418,16 +464,37 @@ describe("loop.panel", function() {
 
           TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
 
           sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
           sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
             6000);
         });
 
+      it("should call mozLoop.telemetryAdd when the url is emailed",
+        function() {
+          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(".button-email"));
+
+          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
+          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
+                                  "LOOP_CLIENT_CALL_URL_SHARED",
+                                  true);
+        });
+
       it("should note the call url expiry when the url is copied manually",
         function() {
           var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
             notifications: notifications,
             client: fakeClient
           }));
           view.setState({
             pending: false,
@@ -439,16 +506,38 @@ describe("loop.panel", function() {
           var urlField = view.getDOMNode().querySelector("input[type='url']");
           TestUtils.Simulate.copy(urlField);
 
           sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
           sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
             6000);
         });
 
+      it("should call mozLoop.telemetryAdd when the url is copied manually",
+        function() {
+          var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
+            notifications: notifications,
+            client: fakeClient
+          }));
+          view.setState({
+            pending: false,
+            copied: false,
+            callUrl: "http://example.com",
+            callUrlExpiry: 6000
+          });
+
+          var urlField = view.getDOMNode().querySelector("input[type='url']");
+          TestUtils.Simulate.copy(urlField);
+
+          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
+          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
+                                  "LOOP_CLIENT_CALL_URL_SHARED",
+                                  true);
+        });
+
       it("should notify the user when the operation failed", function() {
         fakeClient.requestCallUrl = function(_, cb) {
           cb("fake error");
         };
         sandbox.stub(notifications, "errorL10n");
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,17 +1,22 @@
 [DEFAULT]
 support-files =
+    fixtures/google_auth.txt
+    fixtures/google_contacts.txt
+    fixtures/google_token.txt
+    google_service.sjs
     head.js
     loop_fxa.sjs
     ../../../../base/content/test/general/browser_fxa_oauth.html
 
 [browser_CardDavImporter.js]
 [browser_fxa_login.js]
-+skip-if = e10s
+[browser_GoogleImporter.js]
+skip-if = e10s
 [browser_loop_fxa_server.js]
 [browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 [browser_mozLoop_softStart.js]
 skip-if = buildapp == 'mulet'
 [browser_toolbarbutton.js]
--- a/browser/components/loop/test/mochitest/browser_CardDavImporter.js
+++ b/browser/components/loop/test/mochitest/browser_CardDavImporter.js
@@ -1,53 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {CardDavImporter} = Cu.import("resource:///modules/loop/CardDavImporter.jsm", {});
 
-const mockDb = {
-  _store: { },
-  _next_guid: 1,
-
-  add: function(details, callback) {
-    if (!("id" in details)) {
-      callback(new Error("No 'id' field present"));
-      return;
-    }
-    details._guid = this._next_guid++;
-    this._store[details._guid] = details;
-    callback(null, details);
-  },
-  remove: function(guid, callback) {
-    if (!guid in this._store) {
-      callback(new Error("Could not find _guid '" + guid + "' in database"));
-      return;
-    }
-    delete this._store[guid];
-    callback(null);
-  },
-  get: function(guid, callback) {
-    callback(null, this._store[guid]);
-  },
-  getByServiceId: function(serviceId, callback) {
-    for (let guid in this._store) {
-      if (serviceId === this._store[guid].id) {
-        callback(null, this._store[guid]);
-        return;
-      }
-    }
-    callback(null, null);
-  },
-  removeAll: function(callback) {
-    this._store = {};
-    this._next_guid = 1;
-    callback(null);
-  }
-};
-
 const kAuth = {
   "method": "basic",
   "user": "username",
   "password": "p455w0rd"
 }
 
 
 // "pid" for "provider ID"
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_GoogleImporter.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {GoogleImporter} = Cu.import("resource:///modules/loop/GoogleImporter.jsm", {});
+
+let importer = new GoogleImporter();
+
+function promiseImport() {
+  return new Promise(function(resolve, reject) {
+    importer.startImport({}, function(err, stats) {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(stats);
+      }
+    }, mockDb, window);
+  });
+}
+
+add_task(function* test_GoogleImport() {
+  let stats;
+  // An error may throw and the test will fail when that happens.
+  stats = yield promiseImport();
+
+  // Assert the world.
+  Assert.equal(stats.total, 5, "Five contacts should get processed");
+  Assert.equal(stats.success, 5, "Five contacts should be imported");
+
+  yield promiseImport();
+  Assert.equal(Object.keys(mockDb._store).length, 5, "Database should contain only five contact after reimport");
+
+  let c = mockDb._store[mockDb._next_guid - 5];
+  Assert.equal(c.name[0], "John Smith", "Full name should match");
+  Assert.equal(c.givenName[0], "John", "Given name should match");
+  Assert.equal(c.familyName[0], "Smith", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "john.smith@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/0", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 4];
+  Assert.equal(c.name[0], "Jane Smith", "Full name should match");
+  Assert.equal(c.givenName[0], "Jane", "Given name should match");
+  Assert.equal(c.familyName[0], "Smith", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "jane.smith@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/1", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 3];
+  Assert.equal(c.name[0], "Davy Randall Jones", "Full name should match");
+  Assert.equal(c.givenName[0], "Davy Randall", "Given name should match");
+  Assert.equal(c.familyName[0], "Jones", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "davy.jones@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/2", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 2];
+  Assert.equal(c.name[0], "noname@example.com", "Full name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "noname@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/3", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 1];
+  Assert.equal(c.name[0], "lycnix", "Full name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "lycnix", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/7", "UID should match and be scoped to provider");
+});
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -2,32 +2,55 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test FxA logins with Loop.
  */
 
 "use strict";
 
-const {
-  gFxAOAuthTokenData,
-  gFxAOAuthProfile,
-} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
+const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
+
+function* checkFxA401() {
+  let err = MozLoopService.errors.get("login");
+  ise(err.code, 401, "Check error code");
+  ise(err.friendlyMessage, getLoopString("could_not_authenticate"),
+      "Check friendlyMessage");
+  ise(err.friendlyDetails, getLoopString("password_changed_question"),
+      "Check friendlyDetails");
+  ise(err.friendlyDetailsButtonLabel, getLoopString("retry_button"),
+      "Check friendlyDetailsButtonLabel");
+  let loopButton = document.getElementById("loop-call-button");
+  is(loopButton.getAttribute("state"), "error",
+     "state of loop button should be error after a 401 with login");
 
-const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
+  let loopPanel = document.getElementById("loop-notification-panel");
+  yield loadLoopPanel({loopURL: BASE_URL });
+  let loopDoc = document.getElementById("loop").contentDocument;
+  is(loopDoc.querySelector(".alert-error .message").textContent,
+     getLoopString("could_not_authenticate"),
+     "Check error bar message");
+  is(loopDoc.querySelector(".details-error .details").textContent,
+     getLoopString("password_changed_question"),
+     "Check error bar details message");
+  is(loopDoc.querySelector(".details-error .detailsButton").textContent,
+     getLoopString("retry_button"),
+     "Check error bar details button");
+  loopPanel.hidePopup();
+}
 
 add_task(function* setup() {
   Services.prefs.setCharPref("loop.server", BASE_URL);
   Services.prefs.setCharPref("services.push.serverURL", "ws://localhost/");
   registerCleanupFunction(function* () {
     info("cleanup time");
     yield promiseDeletedOAuthParams(BASE_URL);
     Services.prefs.clearUserPref("loop.server");
     Services.prefs.clearUserPref("services.push.serverURL");
-    resetFxA();
+    yield resetFxA();
     Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST));
   });
 });
 
 add_task(function* checkOAuthParams() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
@@ -47,39 +70,39 @@ add_task(function* checkOAuthParams() {
 
 add_task(function* basicAuthorization() {
   let result = yield MozLoopServiceInternal.promiseFxAOAuthAuthorization();
   is(result.code, "code1", "Check code");
   is(result.state, "state", "Check state");
 });
 
 add_task(function* sameOAuthClientForTwoCalls() {
-  resetFxA();
+  yield resetFxA();
   let client1 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   let client2 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   ise(client1, client2, "The same client should be returned");
 });
 
 add_task(function* paramsInvalid() {
-  resetFxA();
+  yield resetFxA();
   // Delete the params so an empty object is returned.
   yield promiseDeletedOAuthParams(BASE_URL);
   let result = null;
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to invalid params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   is(result, null, "No token data should be returned");
 });
 
 add_task(function* params_no_hawk_session() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_no_hawk",
   };
@@ -96,78 +119,79 @@ add_task(function* params_no_hawk_sessio
   ise(Services.prefs.getPrefType(prefName),
       Services.prefs.PREF_INVALID,
       "Check FxA hawk token is not set");
 });
 
 add_task(function* params_nonJSON() {
   Services.prefs.setCharPref("loop.server", "https://loop.invalid");
   // Reset after changing the server so a new HawkClient is created
-  resetFxA();
+  yield resetFxA();
 
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to non-JSON params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   Services.prefs.setCharPref("loop.server", BASE_URL);
 });
 
 add_task(function* invalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.catch((error) => {
     ok(error, "The login promise should be rejected due to invalid state");
   });
 });
 
 add_task(function* basicRegistrationWithoutSession() {
-  resetFxA();
+  yield resetFxA();
   yield promiseDeletedOAuthParams(BASE_URL);
 
   let caught = false;
   yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state").catch((error) => {
     caught = true;
     is(error.code, 401, "Should have returned a 401");
   });
   ok(caught, "Should have caught the error requesting /token without a hawk session");
+  yield checkFxA401();
 });
 
 add_task(function* basicRegistration() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
-  resetFxA();
+  yield resetFxA();
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenData = yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   is(tokenData.access_token, "code1_access_token", "Check access_token");
   is(tokenData.scope, "profile", "Check scope");
   is(tokenData.token_type, "bearer", "Check token_type");
 });
 
 add_task(function* registrationWithInvalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -177,42 +201,48 @@ add_task(function* registrationWithInval
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 400, "Check error code");
+    checkFxAOAuthTokenData(null);
+    is(MozLoopService.userProfile, null, "Profile should be empty after invalid login");
   });
 });
 
 add_task(function* registrationWith401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 401, "Check error code");
+    checkFxAOAuthTokenData(null);
+    is(MozLoopService.userProfile, null, "Profile should be empty after invalid login");
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* basicAuthorizationAndRegistration() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -267,17 +297,17 @@ add_task(function* basicAuthorizationAnd
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response, null,
       "Check registration was deleted on the server");
   is(visibleEmail.textContent, "Guest", "Guest should be displayed on the panel again after logout");
   is(MozLoopService.userProfile, null, "userProfile should be null after logout");
 });
 
 add_task(function* loginWithParams401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_401",
   };
@@ -285,22 +315,24 @@ add_task(function* loginWithParams401() 
   yield MozLoopService.register(mockPushHandler);
 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
-    ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
+    checkFxAOAuthTokenData(null);
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* logoutWithIncorrectPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
@@ -313,51 +345,49 @@ add_task(function* logoutWithIncorrectPu
   });
   ok(caught, "Should have caught an error logging out with a mismatched push URL");
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* logoutWithNoPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
   mockPushHandler.pushUrl = null;
-  let caught = false;
-  yield MozLoopService.logOutFromFxA().catch((error) => {
-    caught = true;
-  });
-  ok(caught, "Should have caught an error logging out without a push URL");
+  yield MozLoopService.logOutFromFxA();
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* loginWithRegistration401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
-    ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
+    checkFxAOAuthTokenData(null);
   });
+
+  yield checkFxA401();
 });
--- a/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
@@ -3,34 +3,38 @@
 
 /*
  * This file contains tests for the mozLoop telemetry API.
  */
 
 add_task(loadLoopPanel);
 
 /**
+ * Enable local telemetry recording for the duration of the tests.
+ */
+add_task(function* test_initialize() {
+  let oldCanRecord = Services.telemetry.canRecord;
+  Services.telemetry.canRecord = true;
+  registerCleanupFunction(function () {
+    Services.telemetry.canRecord = oldCanRecord;
+  });
+});
+
+/**
  * Tests that boolean histograms exist and can be updated.
  */
 add_task(function* test_mozLoop_telemetryAdd_boolean() {
   for (let histogramId of [
     "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
+    "LOOP_CLIENT_CALL_URL_SHARED",
   ]) {
-    let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot();
+    let histogram = Services.telemetry.getHistogramById(histogramId);
 
-    let initialFalseCount = snapshot.counts[0];
-    let initialTrueCount = snapshot.counts[1];
-
+    histogram.clear();
     for (let value of [false, false, true]) {
       gMozLoopAPI.telemetryAdd(histogramId, value);
     }
 
-    // The telemetry service updates histograms asynchronously, so we need to
-    // poll for the final values and time out otherwise.
-    info("Waiting for update of " + histogramId);
-    do {
-      yield new Promise(resolve => setTimeout(resolve, 50));
-      snapshot = Services.telemetry.getHistogramById(histogramId).snapshot();
-    } while (snapshot.counts[0] == initialFalseCount + 2 &&
-             snapshot.counts[1] == initialTrueCount + 1);
-    ok(true, "Correctly updated " + histogramId);
+    let snapshot = histogram.snapshot();
+    is(snapshot.counts[0], 2, "snapshot.counts[0] == 2");
+    is(snapshot.counts[1], 1, "snapshot.counts[1] == 1");
   }
 });
--- a/browser/components/loop/test/mochitest/browser_toolbarbutton.js
+++ b/browser/components/loop/test/mochitest/browser_toolbarbutton.js
@@ -4,74 +4,76 @@
 /**
  * Test the toolbar button states.
  */
 
 "use strict";
 
 registerCleanupFunction(function*() {
   MozLoopService.doNotDisturb = false;
-  setInternalLoopGlobal("gFxAOAuthProfile", null);
+  MozLoopServiceInternal.fxAOAuthProfile = null;
   yield MozLoopServiceInternal.clearError("testing");
 });
 
 add_task(function* test_doNotDisturb() {
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
   yield MozLoopService.doNotDisturb = true;
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "disabled", "Check button is in disabled state");
   yield MozLoopService.doNotDisturb = false;
   Assert.notStrictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "disabled", "Check button is not in disabled state");
 });
 
 add_task(function* test_doNotDisturb_with_login() {
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
   yield MozLoopService.doNotDisturb = true;
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "disabled", "Check button is in disabled state");
-  setInternalLoopGlobal("gFxAOAuthProfile", {email: "test@example.com", uid: "abcd1234"});
+  MozLoopServiceInternal.fxAOAuthTokenData = {token_type:"bearer",access_token:"1bad3e44b12f77a88fe09f016f6a37c42e40f974bc7a8b432bb0d2f0e37e1752",scope:"profile"};
+  MozLoopServiceInternal.fxAOAuthProfile = {email: "test@example.com", uid: "abcd1234"};
   yield MozLoopServiceInternal.notifyStatusChanged("login");
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "active", "Check button is in active state");
   yield loadLoopPanel();
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "disabled", "Check button is in disabled state after opening panel");
   let loopPanel = document.getElementById("loop-notification-panel");
   loopPanel.hidePopup();
   yield MozLoopService.doNotDisturb = false;
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
-  setInternalLoopGlobal("gFxAOAuthProfile", null);
+  MozLoopServiceInternal.fxAOAuthTokenData = null;
   yield MozLoopServiceInternal.notifyStatusChanged();
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
 });
 
 add_task(function* test_error() {
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
   yield MozLoopServiceInternal.setError("testing", {});
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "error", "Check button is in error state");
   yield MozLoopServiceInternal.clearError("testing");
   Assert.notStrictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "error", "Check button is not in error state");
 });
 
 add_task(function* test_error_with_login() {
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
   yield MozLoopServiceInternal.setError("testing", {});
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "error", "Check button is in error state");
-  setInternalLoopGlobal("gFxAOAuthProfile", {email: "test@example.com", uid: "abcd1234"});
+  MozLoopServiceInternal.fxAOAuthProfile = {email: "test@example.com", uid: "abcd1234"};
   MozLoopServiceInternal.notifyStatusChanged("login");
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "error", "Check button is in error state");
   yield MozLoopServiceInternal.clearError("testing");
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
-  setInternalLoopGlobal("gFxAOAuthProfile", null);
+  MozLoopServiceInternal.fxAOAuthProfile = null;
   MozLoopServiceInternal.notifyStatusChanged();
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
 });
 
 add_task(function* test_active() {
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
-  setInternalLoopGlobal("gFxAOAuthProfile", {email: "test@example.com", uid: "abcd1234"});
+  MozLoopServiceInternal.fxAOAuthTokenData = {token_type:"bearer",access_token:"1bad3e44b12f77a88fe09f016f6a37c42e40f974bc7a8b432bb0d2f0e37e1752",scope:"profile"};
+  MozLoopServiceInternal.fxAOAuthProfile = {email: "test@example.com", uid: "abcd1234"};
   yield MozLoopServiceInternal.notifyStatusChanged("login");
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "active", "Check button is in active state");
   yield loadLoopPanel();
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state after opening panel");
   let loopPanel = document.getElementById("loop-notification-panel");
   loopPanel.hidePopup();
-  setInternalLoopGlobal("gFxAOAuthProfile", null);
+  MozLoopServiceInternal.fxAOAuthTokenData = null;
   MozLoopServiceInternal.notifyStatusChanged();
   Assert.strictEqual(LoopUI.toolbarButton.node.getAttribute("state"), "", "Check button is in default state");
 });
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_auth.txt
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html>
+<head><title>Success code=test-code</title></head>
+<body>Le Code.</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_contacts.txt
@@ -0,0 +1,94 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<feed gd:etag="W/&quot;DUQNRHc8cCt7I2A9XRdSF04.&quot;" xmlns="http://www.w3.org/2005/Atom" xmlns:batch="http://schemas.google.com/gdata/batch" xmlns:gContact="http://schemas.google.com/contact/2008" xmlns:gd="http://schemas.google.com/g/2005" xmlns:openSearch="http://a9.com/-/spec/opensearch/1.1/">
+  <id>tester@mochi.com</id>
+  <updated>2014-09-26T13:16:35.978Z</updated>
+  <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+  <title>Mochi Tester's Contacts</title>
+  <link href="http://www.google.com/" rel="alternate" type="text/html"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#feed" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#post" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/batch" rel="http://schemas.google.com/g/2005#batch" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full?max-results=25" rel="self" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full?start-index=26&amp;max-results=25" rel="next" type="application/atom+xml"/>
+  <author>
+    <name>Mochi Tester</name>
+    <email>tester@mochi.com</email>
+  </author>
+  <generator uri="http://www.google.com/m8/feeds" version="1.0">Contacts</generator>
+  <openSearch:totalResults>25</openSearch:totalResults>
+  <openSearch:startIndex>1</openSearch:startIndex>
+  <openSearch:itemsPerPage>10000000</openSearch:itemsPerPage>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/0</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>John Smith</title>
+    <link gd:etag="&quot;Ug92D34SfCt7I2BmLHJTRgVzTlgrJXEAU08.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/0" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/0" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/0" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>John Smith</gd:fullName>
+      <gd:givenName>John</gd:givenName>
+      <gd:familyName>Smith</gd:familyName>
+    </gd:name>
+    <gd:email address="john.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/109576547678240773721" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/1</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>Jane Smith</title>
+    <link gd:etag="&quot;WA9BY1xFWit7I2BhLEkieCxLHEYTGCYuNxo.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/1" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/1" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/1" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>Jane Smith</gd:fullName>
+      <gd:givenName>Jane</gd:givenName>
+      <gd:familyName>Smith</gd:familyName>
+    </gd:name>
+    <gd:email address="jane.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/112886528199784431028" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/2</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>Davy Randall Jones</title>
+    <link gd:etag="&quot;KiV2PkYRfCt7I2BuD1AzEBFxD1VcGjwBUyA.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/2" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/2" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/2" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>Davy Randall Jones</gd:fullName>
+      <gd:givenName>Davy Randall</gd:givenName>
+      <gd:familyName>Jones</gd:familyName>
+    </gd:name>
+    <gd:email address="davy.jones@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/109710625881478599011" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/3</id>
+    <updated>2007-08-01T05:45:52.203Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title/>
+    <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/3" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="edit" type="application/atom+xml"/>
+    <gd:email address="noname@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+  </entry>
+  <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/7</id>
+    <updated>2007-08-01T05:45:52.203Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title/>
+    <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/7" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="edit" type="application/atom+xml"/>
+    <gd:email address="lycnix" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+  </entry>
+</feed>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_token.txt
@@ -0,0 +1,3 @@
+{
+    "access_token": "test-token"
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/google_service.sjs
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, Constructor: CC} = Components;
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+                             "nsIBinaryInputStream",
+                             "setInputStream");
+
+function handleRequest(req, res) {
+  try {
+    reallyHandleRequest(req, res);
+  } catch (ex) {
+    res.setStatusLine("1.0", 200, "AlmostOK");
+    let msg = "Error handling request: " + ex + "\n" + ex.stack;
+    log(msg);
+    res.write(msg);
+  }
+}
+
+function log(msg) {
+  // dump("GOOGLE-SERVER-MOCK: " + msg + "\n");
+}
+
+const kBasePath = "browser/browser/components/loop/test/mochitest/fixtures/";
+
+const kStatusCodes = {
+  400: "Bad Request",
+  401: "Unauthorized",
+  403: "Forbidden",
+  404: "Not Found",
+  405: "Method Not Allowed",
+  500: "Internal Server Error",
+  501: "Not Implemented",
+  503: "Service Unavailable"
+};
+
+function HTTPError(code = 500, message) {
+  this.code = code;
+  this.name = kStatusCodes[code] || "HTTPError";
+  this.message = message || this.name;
+}
+HTTPError.prototype = new Error();
+HTTPError.prototype.constructor = HTTPError;
+
+function sendError(res, err) {
+  if (!(err instanceof HTTPError)) {
+    err = new HTTPError(typeof err == "number" ? err : 500,
+                        err.message || typeof err == "string" ? err : "");
+  }
+  res.setStatusLine("1.1", err.code, err.name);
+  res.write(err.message);
+}
+
+function parseQuery(query, params = {}) {
+  for (let param of query.replace(/^[?&]/, "").split(/(?:&|\?)/)) {
+    param = param.split("=");
+    if (!param[0])
+      continue;
+    params[unescape(param[0])] = unescape(param[1]);
+  }
+  return params;
+}
+
+function getRequestBody(req) {
+  let avail;
+  let bytes = [];
+  let body = new BinaryInputStream(req.bodyInputStream);
+
+  while ((avail = body.available()) > 0)
+    Array.prototype.push.apply(bytes, body.readByteArray(avail));
+
+  return String.fromCharCode.apply(null, bytes);
+}
+
+function getInputStream(path) {
+  let file = Cc["@mozilla.org/file/directory_service;1"]
+               .getService(Ci.nsIProperties)
+               .get("CurWorkD", Ci.nsILocalFile);
+  for (let part of path.split("/"))
+    file.append(part);
+  let fileStream  = Cc["@mozilla.org/network/file-input-stream;1"]
+                      .createInstance(Ci.nsIFileInputStream);
+  fileStream.init(file, 1, 0, false);
+  return fileStream;
+}
+
+function checkAuth(req) {
+  if (!req.hasHeader("Authorization"))
+    throw new HTTPError(401, "No Authorization header provided.");
+
+  let auth = req.getHeader("Authorization");
+  if (auth != "Bearer test-token")
+    throw new HTTPError(401, "Invalid Authorization header content: '" + auth + "'");
+}
+
+function reallyHandleRequest(req, res) {
+  log("method: " + req.method);
+
+  let body = getRequestBody(req);
+  log("body: " + body);
+
+  let contentType = req.hasHeader("Content-Type") ? req.getHeader("Content-Type") : null;
+  log("contentType: " + contentType);
+
+  let params = parseQuery(req.queryString);
+  parseQuery(body, params);
+  log("params: " + JSON.stringify(params));
+
+  // Delegate an authentication request to the correct handler.
+  if ("action" in params) {
+    methodHandlers[params.action](req, res, params);
+  } else {
+    sendError(res, 501);
+  }
+}
+
+function respondWithFile(res, fileName, mimeType) {
+  res.setStatusLine("1.1", 200, "OK");
+  res.setHeader("Content-Type", mimeType);
+
+  let inputStream = getInputStream(kBasePath + fileName);
+  res.bodyOutputStream.writeFrom(inputStream, inputStream.available());
+  inputStream.close();
+}
+
+const methodHandlers = {
+  auth: function(req, res, params) {
+    respondWithFile(res, "google_auth.txt", "text/html");
+  },
+
+  token: function(req, res, params) {
+    respondWithFile(res, "google_token.txt", "application/json");
+  },
+
+  contacts: function(req, res, params) {
+    try {
+      checkAuth(req);
+    } catch (ex) {
+      sendError(res, ex, ex.code);
+      return;
+    }
+
+    respondWithFile(res, "google_contacts.txt", "text/xml");
+  }
+};
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -14,20 +14,31 @@ const WAS_OFFLINE = Services.io.offline;
 
 var gMozLoopAPI;
 
 function promiseGetMozLoopAPI() {
   let deferred = Promise.defer();
   let loopPanel = document.getElementById("loop-notification-panel");
   let btn = document.getElementById("loop-call-button");
 
-  // Wait for the popup to be shown, then we can get the iframe and
+  // Wait for the popup to be shown if it's not already, then we can get the iframe and
   // wait for the iframe's load to be completed.
-  loopPanel.addEventListener("popupshown", function onpopupshown() {
-    loopPanel.removeEventListener("popupshown", onpopupshown, true);
+  if (loopPanel.state == "closing" || loopPanel.state == "closed") {
+    loopPanel.addEventListener("popupshown", () => {
+      loopPanel.removeEventListener("popupshown", onpopupshown, true);
+      onpopupshown();
+    }, true);
+
+    // Now we're setup, click the button.
+    btn.click();
+  } else {
+    setTimeout(onpopupshown, 0);
+  }
+
+  function onpopupshown() {
     let iframe = document.getElementById(btn.getAttribute("notificationFrameId"));
 
     if (iframe.contentDocument &&
         iframe.contentDocument.readyState == "complete") {
       gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
       deferred.resolve();
     } else {
@@ -36,20 +47,17 @@ function promiseGetMozLoopAPI() {
 
         gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
         // We do this in an execute soon to allow any other event listeners to
         // be handled, just in case.
         deferred.resolve();
       }, true);
     }
-  }, true);
-
-  // Now we're setup, click the button.
-  btn.click();
+  }
 
   // Remove the iframe after each test. This also avoids mochitest complaining
   // about leaks on shutdown as we intentionally hold the iframe open for the
   // life of the application.
   registerCleanupFunction(function() {
     loopPanel.hidePopup();
     let frameId = btn.getAttribute("notificationFrameId");
     let frame = document.getElementById(frameId);
@@ -102,38 +110,41 @@ function promiseOAuthParamsSetup(baseURL
   xhr.setRequestHeader("X-Params", JSON.stringify(params));
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", error => deferred.reject(error));
   xhr.send();
 
   return deferred.promise;
 }
 
-function resetFxA() {
+function* resetFxA() {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global.gHawkClient = null;
   global.gFxAOAuthClientPromise = null;
   global.gFxAOAuthClient = null;
-  global.gFxAOAuthTokenData = null;
-  global.gFxAOAuthProfile = null;
+  MozLoopServiceInternal.fxAOAuthProfile = null;
+  MozLoopServiceInternal.fxAOAuthTokenData = null;
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.clearUserPref(fxASessionPref);
+  MozLoopService.errors.clear();
+  let notified = promiseObserverNotified("loop-status-changed");
+  MozLoopServiceInternal.notifyStatusChanged();
+  yield notified;
 }
 
-function setInternalLoopGlobal(aName, aValue) {
-  let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
-  global[aName] = aValue;
+function checkFxAOAuthTokenData(aValue) {
+  ise(MozLoopServiceInternal.fxAOAuthTokenData, aValue, "fxAOAuthTokenData should be " + aValue);
 }
 
 function checkLoggedOutState() {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   ise(global.gFxAOAuthClientPromise, null, "gFxAOAuthClientPromise should be cleared");
-  ise(global.gFxAOAuthProfile, null, "gFxAOAuthProfile should be cleared");
+  ise(MozLoopService.userProfile, null, "fxAOAuthProfile should be cleared");
   ise(global.gFxAOAuthClient, null, "gFxAOAuthClient should be cleared");
-  ise(global.gFxAOAuthTokenData, null, "gFxAOAuthTokenData should be cleared");
+  checkFxAOAuthTokenData(null);
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   ise(Services.prefs.getPrefType(fxASessionPref), Services.prefs.PREF_INVALID,
       "FxA hawk session should be cleared anyways");
 }
 
 function promiseDeletedOAuthParams(baseURL) {
   let deferred = Promise.defer();
   let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
@@ -167,16 +178,20 @@ function promiseOAuthGetRegistration(bas
   xhr.responseType = "json";
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", deferred.reject);
   xhr.send();
 
   return deferred.promise;
 }
 
+function getLoopString(stringID) {
+  return MozLoopServiceInternal.localizedStrings[stringID].textContent;
+}
+
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
@@ -193,8 +208,56 @@ let mockPushHandler = {
 
   /**
    * Test-only API to simplify notifying a push notification result.
    */
   notify: function(version) {
     this._notificationCallback(version);
   }
 };
+
+const mockDb = {
+  _store: { },
+  _next_guid: 1,
+
+  add: function(details, callback) {
+    if (!("id" in details)) {
+      callback(new Error("No 'id' field present"));
+      return;
+    }
+    details._guid = this._next_guid++;
+    this._store[details._guid] = details;
+    callback(null, details);
+  },
+  remove: function(guid, callback) {
+    if (!guid in this._store) {
+      callback(new Error("Could not find _guid '" + guid + "' in database"));
+      return;
+    }
+    delete this._store[guid];
+    callback(null);
+  },
+  getAll: function(callback) {
+    callback(null, this._store);
+  },
+  get: function(guid, callback) {
+    callback(null, this._store[guid]);
+  },
+  getByServiceId: function(serviceId, callback) {
+    for (let guid in this._store) {
+      if (serviceId === this._store[guid].id) {
+        callback(null, this._store[guid]);
+        return;
+      }
+    }
+    callback(null, null);
+  },
+  removeAll: function(callback) {
+    this._store = {};
+    this._next_guid = 1;
+    callback(null);
+  },
+  promise: function(method, ...params) {
+    return new Promise(resolve => {
+      this[method](...params, (err, res) => err ? reject(err) : resolve(res));
+    });
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -0,0 +1,604 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.ConversationStore", function () {
+  "use strict";
+
+  var CALL_STATES = loop.store.CALL_STATES;
+  var WS_STATES = loop.store.WS_STATES;
+  var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
+  var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
+  var contact;
+  var connectPromise, resolveConnectPromise, rejectConnectPromise;
+  var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
+
+  function checkFailures(done, f) {
+    try {
+      f();
+      done();
+    } catch (err) {
+      done(err);
+    }
+  }
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    contact = {
+      name: [ "Mr Smith" ],
+      email: [{
+        type: "home",
+        value: "fakeEmail",
+        pref: true
+      }]
+    };
+
+    dispatcher = new loop.Dispatcher();
+    client = {
+      setupOutgoingCall: sinon.stub()
+    };
+    sdkDriver = {
+      connectSession: sinon.stub(),
+      disconnectSession: sinon.stub()
+    };
+
+    wsCancelSpy = sinon.spy();
+    wsCloseSpy = sinon.spy();
+    wsMediaUpSpy = sinon.spy();
+
+    fakeWebsocket = {
+      cancel: wsCancelSpy,
+      close: wsCloseSpy,
+      mediaUp: wsMediaUpSpy
+    };
+
+    store = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
+    });
+    fakeSessionData = {
+      apiKey: "fakeKey",
+      callId: "142536",
+      sessionId: "321456",
+      sessionToken: "341256",
+      websocketToken: "543216",
+      progressURL: "fakeURL"
+    };
+
+    var dummySocket = {
+      close: sinon.spy(),
+      send: sinon.spy()
+    };
+
+    connectPromise = new Promise(function(resolve, reject) {
+      resolveConnectPromise = resolve;
+      rejectConnectPromise = reject;
+    });
+
+    sandbox.stub(loop.CallConnectionWebSocket.prototype,
+      "promiseConnect").returns(connectPromise);
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#initialize", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {
+          client: client,
+          sdkDriver: sdkDriver
+        });
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if the client is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {
+          dispatcher: dispatcher,
+          sdkDriver: sdkDriver
+        });
+      }).to.Throw(/client/);
+    });
+
+    it("should throw an error if the sdkDriver is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {
+          client: client,
+          dispatcher: dispatcher
+        });
+      }).to.Throw(/sdkDriver/);
+    });
+  });
+
+  describe("#connectionFailure", function() {
+    beforeEach(function() {
+      store._websocket = fakeWebsocket;
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the state to 'terminated'", function() {
+      store.set({callState: CALL_STATES.ALERTING});
+
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
+      expect(store.get("callStateReason")).eql("fake");
+    });
+  });
+
+  describe("#connectionProgress", function() {
+    describe("progress: init", function() {
+      it("should change the state from 'gather' to 'connecting'", function() {
+        store.set({callState: CALL_STATES.GATHER});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.INIT}));
+
+        expect(store.get("callState")).eql(CALL_STATES.CONNECTING);
+      });
+    });
+
+    describe("progress: alerting", function() {
+      it("should change the state from 'gather' to 'alerting'", function() {
+        store.set({callState: CALL_STATES.GATHER});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+      });
+
+      it("should change the state from 'init' to 'alerting'", function() {
+        store.set({callState: CALL_STATES.INIT});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+      });
+    });
+
+    describe("progress: connecting", function() {
+      beforeEach(function() {
+        store.set({callState: CALL_STATES.ALERTING});
+      });
+
+      it("should change the state to 'ongoing'", function() {
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
+      });
+
+      it("should connect the session", function() {
+        store.set(fakeSessionData);
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
+
+        sinon.assert.calledOnce(sdkDriver.connectSession);
+        sinon.assert.calledWithExactly(sdkDriver.connectSession, {
+          apiKey: "fakeKey",
+          sessionId: "321456",
+          sessionToken: "341256"
+        });
+      });
+    });
+  });
+
+  describe("#gatherCallData", function() {
+    beforeEach(function() {
+      store.set({callState: CALL_STATES.INIT});
+
+      navigator.mozLoop = {
+        getCallData: function() {
+          return {
+            contact: contact,
+            callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO
+          };
+        }
+      };
+    });
+
+    afterEach(function() {
+      delete navigator.mozLoop;
+    });
+
+    it("should set the state to 'gather'", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          callId: "76543218",
+          outgoing: true
+        }));
+
+      expect(store.get("callState")).eql(CALL_STATES.GATHER);
+    });
+
+    it("should save the basic call information", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({