Merge changesets for loop uplift to fig default tip
authorRandell Jesup <rjesup@jesup.org>
Mon, 06 Oct 2014 14:59:42 -0400
changeset 218140 7a5f4d72e05dcdff5ca479c3c02ebcd78bdfffb4
parent 218113 bc87917b3b953afd921f20b11ada3264c7a110bc (current diff)
parent 218139 669ecc39ceae37972aa0732062b4f41b107ac023 (diff)
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone34.0a2
Merge changesets for loop uplift to fig
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1592,18 +1592,21 @@ 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);
+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");
 
 // 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);
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/GoogleImporter.jsm
@@ -0,0 +1,541 @@
+/* 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)];
+          contacts.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)) {
+        contact.org.push(orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0].firstChild.nodeValue);
+        contact.jobTitle.push(orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0].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: {
@@ -498,19 +530,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;
       }
     },
 
     /**
@@ -548,52 +580,16 @@ function injectLoopAPI(targetWindow) {
      */
     generateUUID: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.generateUUID();
       }
     },
-
-    /**
-     * Compose a URL pointing to the location of an avatar by email address.
-     * At the moment we use the Gravatar service to match email addresses with
-     * avatars. This might change in the future as avatars might come from another
-     * source.
-     *
-     * @param {String} emailAddress Users' email address
-     * @param {Number} size         Size of the avatar image to return in pixels.
-     *                              Optional. Default value: 40.
-     * @return the URL pointing to an avatar matching the provided email address.
-     */
-    getUserAvatar: {
-      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;
-      }
-    },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
     targetWindow.dispatchEvent(event)
   };
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
--- 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.
@@ -325,21 +325,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();
   },
@@ -406,17 +455,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
    *
    */
@@ -529,31 +601,23 @@ 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);
         throw error;
       }
     );
   },
 
   /**
@@ -563,26 +627,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;
       });
   },
@@ -1112,16 +1181,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");
     }
@@ -1191,16 +1261,20 @@ this.MozLoopService = {
   get userProfile() {
     return gFxAOAuthProfile;
   },
 
   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",
@@ -1326,62 +1400,75 @@ this.MozLoopService = {
       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");
         }
+        MozLoopServiceInternal.clearError("login");
+        MozLoopServiceInternal.clearError("profile");
         return gFxAOAuthTokenData;
       }));
     }).then(tokenData => {
       let client = new FxAccountsProfileClient({
         serverURL: gFxAOAuthClient.parameters.profile_uri,
         token: tokenData.access_token
       });
       client.fetchProfile().then(result => {
         gFxAOAuthProfile = result;
         MozLoopServiceInternal.notifyStatusChanged("login");
       }, error => {
         log.error("Failed to retrieve profile", error);
+        this.setError("profile", error);
         gFxAOAuthProfile = null;
         MozLoopServiceInternal.notifyStatusChanged();
       });
       return tokenData;
     }).catch(error => {
       gFxAOAuthTokenData = null;
       gFxAOAuthProfile = 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;
 
     // 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);
   },
 
--- 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
@@ -12,109 +12,240 @@ loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+  // 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,
+                              "disabled": 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,
+                              "disabled": 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,133 @@ 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":
+        case "block":
+        case "unblock":
+          // Invoke the API named like the action.
+          navigator.mozLoop.contacts[actionName](contact._guid, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          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 +405,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 +430,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 +468,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
@@ -12,109 +12,240 @@ loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({
+  // 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,
+                              "disabled": 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,
+                              "disabled": 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,133 @@ 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":
+        case "block":
+        case "unblock":
+          // Invoke the API named like the action.
+          navigator.mozLoop.contacts[actionName](contact._guid, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          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 +405,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 +430,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 +468,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,71 @@ 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 For now key this on the pref, but this should really be
+    // set by the information from the mozLoop API when we can get it (bug 1072323).
+    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
+
+    // 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;
+    if (locationHash) {
+      callId = locationHash.match(/\#incoming\/(.*)/)[1]
+      conversation.set("callId", callId);
+    }
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(conversation.get("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,
+      calleeId: outgoingEmail
+    }));
   }
 
   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,71 @@ 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 For now key this on the pref, but this should really be
+    // set by the information from the mozLoop API when we can get it (bug 1072323).
+    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
+
+    // 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;
+    if (locationHash) {
+      callId = locationHash.match(/\#incoming\/(.*)/)[1]
+      conversation.set("callId", callId);
+    }
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(conversation.get("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,
+      calleeId: outgoingEmail
+    }));
   }
 
   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,371 @@
+/** @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: {
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      document.title = this.props.calleeId;
+
+      return (
+        React.DOM.div({className: "call-window"}, 
+          React.DOM.h2(null, this.props.calleeId), 
+          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,
+      calleeId: React.PropTypes.string,
+      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({calleeId: this.props.calleeId}, 
+
+          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, 
+            calleeId: this.state.calleeId, 
+            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,371 @@
+/** @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: {
+      calleeId: React.PropTypes.string,
+    },
+
+    render: function() {
+      document.title = this.props.calleeId;
+
+      return (
+        <div className="call-window">
+          <h2>{this.props.calleeId}</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,
+      calleeId: React.PropTypes.string,
+      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 calleeId={this.props.calleeId}>
+
+          <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}
+            calleeId={this.state.calleeId}
+            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
@@ -307,20 +307,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();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _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() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      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, 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -307,20 +307,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();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _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() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      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>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -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 */
 
@@ -364,17 +385,17 @@ p {
 /* 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;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -0,0 +1,125 @@
+/* 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", {
+      // XXX This may change when bug 1072323 is implemented.
+      // Optional: Specify the calleeId for an outgoing call
+      calleeId: [String, null],
+      // Specify the callId for an incoming call.
+      callId: [String, null]
+    }),
+
+    /**
+     * 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,390 @@
+/* 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 id of the person being called for outgoing calls
+      calleeId: 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: true,
+      // If the video is muted
+      videoMuted: true
+    },
+
+    /**
+     * 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) {
+      this.set({
+        calleeId: actionData.calleeId,
+        outgoing: !!actionData.calleeId,
+        callId: actionData.callId,
+        callState: CALL_STATES.GATHER
+      });
+
+      this.videoMuted = this.get("callType") !== CALL_TYPES.AUDIO_VIDEO;
+
+      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() {
+      // XXX For now, we only have one calleeId, so just wrap that in an array.
+      this.client.setupOutgoingCall([this.get("calleeId")],
+        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/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,339 @@
+/* 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;
+
+  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");
+  });
+
+  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({calleeId: "mrsmith"});
+
+      expect(document.title).eql("mrsmith");
+    });
+
+    it("should set display the calledId", function() {
+      view = mountTestComponent({calleeId: "mrsmith"});
+
+      expect(TestUtils.findRenderedDOMComponentWithTag(
+        view, "h2").props.children).eql("mrsmith");
+    });
+  });
+
+  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,
+          calleeId: "mrsmith",
+          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,
+          calleeId: "mrsmith",
+          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,
+          calleeId: "mrsmith",
+          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,
+          calleeId: "mrsmith",
+          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,
+          calleeId: "mrsmith",
+          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});
+
+        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});
+
+        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,98 @@ 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({
+          calleeId: null,
+          callId: "42"
+        }));
+    });
+  });
+
+  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({}, {
+        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 +196,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 +294,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 +603,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
@@ -341,18 +341,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
         }));
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,16 +1,21 @@
 [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]
+[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'
--- 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
@@ -9,25 +9,53 @@
 
 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");
+
+  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 +75,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 +124,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);
@@ -181,17 +210,17 @@ add_task(function* registrationWithInval
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 400, "Check error code");
   });
 });
 
 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",
   };
@@ -199,20 +228,22 @@ add_task(function* registrationWith401()
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 401, "Check error code");
   });
+
+  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 +298,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",
   };
@@ -287,20 +318,22 @@ add_task(function* loginWithParams401() 
   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");
   });
+
+  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,40 +346,36 @@ 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",
   };
@@ -355,9 +384,11 @@ add_task(function* loginWithRegistration
   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");
   });
+
+  yield checkFxA401();
 });
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,25 +110,29 @@ 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;
   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 checkLoggedOutState() {
@@ -167,16 +179,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 +209,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,571 @@
+/* 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 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();
+
+    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});
+    });
+
+    it("should set the state to 'gather'", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          calleeId: "",
+          callId: "76543218"
+        }));
+
+      expect(store.get("callState")).eql(CALL_STATES.GATHER);
+    });
+
+    it("should save the basic call information", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          calleeId: "fake",
+          callId: "123456"
+        }));
+
+      expect(store.get("calleeId")).eql("fake");
+      expect(store.get("callId")).eql("123456");
+      expect(store.get("outgoing")).eql(true);
+    });
+
+    describe("outgoing calls", function() {
+      var outgoingCallData;
+
+      beforeEach(function() {
+        outgoingCallData = {
+          calleeId: "fake",
+          callId: "135246"
+        };
+      });
+
+      it("should request the outgoing call data", function() {
+        dispatcher.dispatch(
+          new sharedActions.GatherCallData(outgoingCallData));
+
+        sinon.assert.calledOnce(client.setupOutgoingCall);
+        sinon.assert.calledWith(client.setupOutgoingCall,
+          ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+      });
+
+      describe("server response handling", function() {
+        beforeEach(function() {
+          sandbox.stub(dispatcher, "dispatch");
+        });
+
+        it("should dispatch a connect call action on success", function() {
+          var callData = {
+            apiKey: "fakeKey"
+          };
+
+          client.setupOutgoingCall.callsArgWith(2, null, callData);
+
+          store.gatherCallData(
+            new sharedActions.GatherCallData(outgoingCallData));
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          // Can't use instanceof here, as that matches any action
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectCall"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("sessionData", callData));
+        });
+
+        it("should dispatch a connection failure action on failure", function() {
+          client.setupOutgoingCall.callsArgWith(2, {});
+
+          store.gatherCallData(
+            new sharedActions.GatherCallData(outgoingCallData));
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          // Can't use instanceof here, as that matches any action
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectionFailure"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("reason", "setup"));
+        });
+      });
+    });
+  });
+
+  describe("#connectCall", function() {
+    it("should save the call session data", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      expect(store.get("apiKey")).eql("fakeKey");
+      expect(store.get("callId")).eql("142536");
+      expect(store.get("sessionId")).eql("321456");
+      expect(store.get("sessionToken")).eql("341256");
+      expect(store.get("websocketToken")).eql("543216");
+      expect(store.get("progressURL")).eql("fakeURL");
+    });
+
+    it("should initialize the websocket", function() {
+      sandbox.stub(loop, "CallConnectionWebSocket").returns({
+        promiseConnect: function() { return connectPromise; },
+        on: sinon.spy()
+      });
+
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      sinon.assert.calledOnce(loop.CallConnectionWebSocket);
+      sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
+        url: "fakeURL",
+        callId: "142536",
+        websocketToken: "543216"
+      });
+    });
+
+    it("should connect the websocket to the server", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      sinon.assert.calledOnce(store._websocket.promiseConnect);
+    });
+
+    describe("WebSocket connection result", function() {
+      beforeEach(function() {
+        dispatcher.dispatch(
+          new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+        sandbox.stub(dispatcher, "dispatch");
+      });
+
+      it("should dispatch a connection progress action on success", function(done) {
+        resolveConnectPromise(WS_STATES.INIT);
+
+        connectPromise.then(function() {
+          checkFailures(done, function() {
+            sinon.assert.calledOnce(dispatcher.dispatch);
+            // Can't use instanceof here, as that matches any action
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("name", "connectionProgress"));
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("wsState", WS_STATES.INIT));
+          });
+        }, function() {
+          done(new Error("Promise should have been resolve, not rejected"));
+        });
+      });
+
+      it("should dispatch a connection failure action on failure", function(done) {
+        rejectConnectPromise();
+
+        connectPromise.then(function() {
+          done(new Error("Promise should have been rejected, not resolved"));
+        }, function() {
+          checkFailures(done, function() {
+            sinon.assert.calledOnce(dispatcher.dispatch);
+            // Can't use instanceof here, as that matches any action
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("name", "connectionFailure"));
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("reason", "websocket-setup"));
+           });
+        });
+      });
+    });
+  });
+
+  describe("#hangupCall", function() {
+    var wsMediaFailSpy, wsCloseSpy;
+    beforeEach(function() {
+      wsMediaFailSpy = sinon.spy();
+      wsCloseSpy = sinon.spy();
+
+      store._websocket = {
+        mediaFail: wsMediaFailSpy,
+        close: wsCloseSpy
+      };
+      store.set({callState: CALL_STATES.ONGOING});
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should send a media-fail message to the websocket if it is open", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(wsMediaFailSpy);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the callState to finished", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.FINISHED);
+    });
+  });
+
+  describe("#peerHungupCall", function() {
+    var wsMediaFailSpy, wsCloseSpy;
+    beforeEach(function() {
+      wsMediaFailSpy = sinon.spy();
+      wsCloseSpy = sinon.spy();
+
+      store._websocket = {
+        mediaFail: wsMediaFailSpy,
+        close: wsCloseSpy
+      };
+      store.set({callState: CALL_STATES.ONGOING});
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the callState to finished", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.FINISHED);
+    });
+  });
+
+  describe("#cancelCall", function() {
+    beforeEach(function() {
+      store._websocket = fakeWebsocket;
+
+      store.set({callState: CALL_STATES.CONNECTING});
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should send a cancel message to the websocket if it is open", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(wsCancelSpy);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the state to close if the call is connecting", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.CLOSE);
+    });
+
+    it("should set the state to close if the call has terminated already", function() {
+      store.set({callState: CALL_STATES.TERMINATED});
+
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.CLOSE);
+    });
+
+  });
+
+  describe("#retryCall", function() {
+    it("should set the state to gather", function() {
+      store.set({callState: CALL_STATES.TERMINATED});
+
+      dispatcher.dispatch(new sharedActions.RetryCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.GATHER);
+    });
+
+    it("should request the outgoing call data", function() {
+      store.set({
+        callState: CALL_STATES.TERMINATED,
+        outgoing: true,
+        callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
+        calleeId: "fake"
+      });
+
+      dispatcher.dispatch(new sharedActions.RetryCall());
+
+      sinon.assert.calledOnce(client.setupOutgoingCall);
+      sinon.assert.calledWith(client.setupOutgoingCall,
+        ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+    });
+  });
+
+  describe("#mediaConnected", function() {
+    it("should send mediaUp via the websocket", function() {
+      store._websocket = fakeWebsocket;
+
+      dispatcher.dispatch(new sharedActions.MediaConnected());
+
+      sinon.assert.calledOnce(wsMediaUpSpy);
+    });
+  });
+
+  describe("#setMute", function() {
+    it("should save the mute state for the audio stream", function() {
+      store.set({"audioMuted": false});
+
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "audio",
+        enabled: true
+      }));
+
+      expect(store.get("audioMuted")).eql(true);
+    });
+
+    it("should save the mute state for the video stream", function() {
+      store.set({"videoMuted": true});
+
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "video",
+        enabled: false
+      }));
+
+      expect(store.get("videoMuted")).eql(false);
+    });
+  });
+
+  describe("Events", function() {
+    describe("Websocket progress", function() {
+      beforeEach(function() {
+        dispatcher.dispatch(
+          new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+        sandbox.stub(dispatcher, "dispatch");
+      });
+
+      it("should dispatch a connection failure action on 'terminate'", function() {
+        store._websocket.trigger("progress", {
+          state: WS_STATES.TERMINATED,
+          reason: "reject"
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        // Can't use instanceof here, as that matches any action
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "reject"));
+      });
+
+      it("should dispatch a connection progress action on 'alerting'", function() {
+        store._websocket.trigger("progress", {state: WS_STATES.ALERTING});
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        // Can't use instanceof here, as that matches any action
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionProgress"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("wsState", WS_STATES.ALERTING));
+      });
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.Dispatcher", function () {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var dispatcher, sandbox;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    dispatcher = new loop.Dispatcher();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#register", function() {
+    it("should register a store against an action name", function() {
+      var object = { fake: true };
+
+      dispatcher.register(object, ["gatherCallData"]);
+
+      expect(dispatcher._eventData["gatherCallData"][0]).eql(object);
+    });
+
+    it("should register multiple store against an action name", function() {
+      var object1 = { fake: true };
+      var object2 = { fake2: true };
+
+      dispatcher.register(object1, ["gatherCallData"]);
+      dispatcher.register(object2, ["gatherCallData"]);
+
+      expect(dispatcher._eventData["gatherCallData"][0]).eql(object1);
+      expect(dispatcher._eventData["gatherCallData"][1]).eql(object2);
+    });
+  });
+
+  describe("#dispatch", function() {
+    var gatherStore1, gatherStore2, cancelStore1, connectStore1;
+    var gatherAction, cancelAction, connectAction, resolveCancelStore1;
+
+    beforeEach(function() {
+      gatherAction = new sharedActions.GatherCallData({
+        callId: "42",
+        calleeId: null
+      });
+
+      cancelAction = new sharedActions.CancelCall();
+      connectAction = new sharedActions.ConnectCall({
+        sessionData: {}
+      });
+
+      gatherStore1 = {
+        gatherCallData: sinon.stub()
+      };
+      gatherStore2 = {
+        gatherCallData: sinon.stub()
+      };
+      cancelStore1 = {
+        cancelCall: sinon.stub()
+      };
+      connectStore1 = {
+        connectCall: function() {}
+      };
+
+      dispatcher.register(gatherStore1, ["gatherCallData"]);
+      dispatcher.register(gatherStore2, ["gatherCallData"]);
+      dispatcher.register(cancelStore1, ["cancelCall"]);
+      dispatcher.register(connectStore1, ["connectCall"]);
+    });
+
+    it("should dispatch an action to the required object", function() {
+      dispatcher.dispatch(cancelAction);
+
+      sinon.assert.notCalled(gatherStore1.gatherCallData);
+
+      sinon.assert.calledOnce(cancelStore1.cancelCall);
+      sinon.assert.calledWithExactly(cancelStore1.cancelCall, cancelAction);
+
+      sinon.assert.notCalled(gatherStore2.gatherCallData);
+    });
+
+    it("should dispatch actions to multiple objects", function() {
+      dispatcher.dispatch(gatherAction);
+
+      sinon.assert.calledOnce(gatherStore1.gatherCallData);
+      sinon.assert.calledWithExactly(gatherStore1.gatherCallData, gatherAction);
+
+      sinon.assert.notCalled(cancelStore1.cancelCall);
+
+      sinon.assert.calledOnce(gatherStore2.gatherCallData);
+      sinon.assert.calledWithExactly(gatherStore2.gatherCallData, gatherAction);
+    });
+
+    it("should dispatch multiple actions", function() {
+      dispatcher.dispatch(cancelAction);
+      dispatcher.dispatch(gatherAction);
+
+      sinon.assert.calledOnce(cancelStore1.cancelCall);
+      sinon.assert.calledOnce(gatherStore1.gatherCallData);
+      sinon.assert.calledOnce(gatherStore2.gatherCallData);
+    });
+
+    describe("Queued actions", function() {
+      beforeEach(function() {
+        // Restore the stub, so that we can easily add a function to be
+        // returned. Unfortunately, sinon doesn't make this easy.
+        sandbox.stub(connectStore1, "connectCall", function() {
+          dispatcher.dispatch(gatherAction);
+
+          sinon.assert.notCalled(gatherStore1.gatherCallData);
+          sinon.assert.notCalled(gatherStore2.gatherCallData);
+        });
+      });
+
+      it("should not dispatch an action if the previous action hasn't finished", function() {
+        // Dispatch the first action. The action handler dispatches the second
+        // action - see the beforeEach above.
+        dispatcher.dispatch(connectAction);
+
+        sinon.assert.calledOnce(connectStore1.connectCall);
+      });
+
+      it("should dispatch an action when the previous action finishes", function() {
+        // Dispatch the first action. The action handler dispatches the second
+        // action - see the beforeEach above.
+        dispatcher.dispatch(connectAction);
+
+        sinon.assert.calledOnce(connectStore1.connectCall);
+        // These should be called, because the dispatcher synchronously queues actions.
+        sinon.assert.calledOnce(gatherStore1.gatherCallData);
+        sinon.assert.calledOnce(gatherStore2.gatherCallData);
+      });
+    });
+  });
+});
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -34,23 +34,32 @@
 
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.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/feedbackApiClient.js"></script>
+  <script src="../../content/shared/js/validate.js"></script>
+  <script src="../../content/shared/js/actions.js"></script>
+  <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
+  <script src="../../content/shared/js/conversationStore.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
+  <script src="validate_test.js"></script>
+  <script src="dispatcher_test.js"></script>
+  <script src="conversationStore_test.js"></script>
+  <script src="otSdkDriver_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
 </body>
 </html>
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -71,16 +71,29 @@ describe("loop.shared.models", function(
             done();
           });
 
           conversation.accepted();
         });
       });
 
       describe("#setupOutgoingCall", function() {
+        it("should set the a custom selected call type", function() {
+          conversation.setupOutgoingCall("audio");
+
+          expect(conversation.get("selectedCallType")).eql("audio");
+        });
+
+        it("should respect the default selected call type when none is passed",
+          function() {
+            conversation.setupOutgoingCall();
+
+            expect(conversation.get("selectedCallType")).eql("audio-video");
+          });
+
         it("should trigger a `call:outgoing:setup` event", function(done) {
           conversation.once("call:outgoing:setup", function() {
             done();
           });
 
           conversation.setupOutgoingCall();
         });
       });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -0,0 +1,300 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.OTSdkDriver", function () {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+
+  var sandbox;
+  var dispatcher, driver, publisher, sdk, session, sessionData;
+  var fakeLocalElement, fakeRemoteElement, publisherConfig;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    fakeLocalElement = {fake: 1};
+    fakeRemoteElement = {fake: 2};
+    publisherConfig = {
+      fake: "config"
+    };
+    sessionData = {
+      apiKey: "1234567890",
+      sessionId: "3216549870",
+      sessionToken: "1357924680"
+    };
+
+    dispatcher = new loop.Dispatcher();
+    session = _.extend({
+      connect: sinon.stub(),
+      disconnect: sinon.stub(),
+      publish: sinon.stub(),
+      subscribe: sinon.stub()
+    }, Backbone.Events);
+
+    publisher = {
+      destroy: sinon.stub(),
+      publishAudio: sinon.stub(),
+      publishVideo: sinon.stub()
+    };
+
+    sdk = {
+      initPublisher: sinon.stub(),
+      initSession: sinon.stub().returns(session)
+    };
+
+    driver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: sdk
+    });
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("Constructor", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.OTSdkDriver({sdk: sdk});
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if the sdk is missing", function() {
+      expect(function() {
+        new loop.OTSdkDriver({dispatcher: dispatcher});
+      }).to.Throw(/sdk/);
+    });
+  });
+
+  describe("#setupStreamElements", function() {
+    it("should call initPublisher", function() {
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+
+      sinon.assert.calledOnce(sdk.initPublisher);
+      sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
+    });
+
+    describe("On Publisher Complete", function() {
+      it("should publish the stream if the connection is ready", function() {
+        sdk.initPublisher.callsArgWith(2, null);
+
+        driver.session = session;
+        driver._sessionConnected = true;
+
+        dispatcher.dispatch(new sharedActions.SetupStreamElements({
+          getLocalElementFunc: function() {return fakeLocalElement;},
+          getRemoteElementFunc: function() {return fakeRemoteElement;},
+          publisherConfig: publisherConfig
+        }));
+
+        sinon.assert.calledOnce(session.publish);
+      });
+
+      it("should dispatch connectionFailure if connecting failed", function() {
+        sdk.initPublisher.callsArgWith(2, new Error("Failure"));
+
+        // Special stub, as we want to use the dispatcher, but also know that
+        // we've been called correctly for the second dispatch.
+        var dispatchStub = (function() {
+          var originalDispatch = dispatcher.dispatch.bind(dispatcher);
+          return sandbox.stub(dispatcher, "dispatch", function(action) {
+            originalDispatch(action);
+          });
+        }());
+
+        driver.session = session;
+        driver._sessionConnected = true;
+
+        dispatcher.dispatch(new sharedActions.SetupStreamElements({
+          getLocalElementFunc: function() {return fakeLocalElement;},
+          getRemoteElementFunc: function() {return fakeRemoteElement;},
+          publisherConfig: publisherConfig
+        }));
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "noMedia"));
+      });
+    });
+  });
+
+  describe("#setMute", function() {
+    beforeEach(function() {
+      sdk.initPublisher.returns(publisher);
+
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+    });
+
+    it("should publishAudio with the correct enabled value", function() {
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "audio",
+        enabled: false
+      }));
+
+      sinon.assert.calledOnce(publisher.publishAudio);
+      sinon.assert.calledWithExactly(publisher.publishAudio, false);
+    });
+
+    it("should publishVideo with the correct enabled value", function() {
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "video",
+        enabled: true
+      }));
+
+      sinon.assert.calledOnce(publisher.publishVideo);
+      sinon.assert.calledWithExactly(publisher.publishVideo, true);
+    });
+  });
+
+  describe("#connectSession", function() {
+    it("should initialise a new session", function() {
+      driver.connectSession(sessionData);
+
+      sinon.assert.calledOnce(sdk.initSession);
+      sinon.assert.calledWithExactly(sdk.initSession, "3216549870");
+    });
+
+    it("should connect the session", function () {
+      driver.connectSession(sessionData);
+
+      sinon.assert.calledOnce(session.connect);
+      sinon.assert.calledWith(session.connect, "1234567890", "1357924680");
+    });
+
+    describe("On connection complete", function() {
+      it("should publish the stream if the publisher is ready", function() {
+        driver._publisherReady = true;
+        session.connect.callsArg(2);
+
+        driver.connectSession(sessionData);
+
+        sinon.assert.calledOnce(session.publish);
+      });
+
+      it("should dispatch connectionFailure if connecting failed", function() {
+        session.connect.callsArgWith(2, new Error("Failure"));
+        sandbox.stub(dispatcher, "dispatch");
+
+        driver.connectSession(sessionData);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "couldNotConnect"));
+      });
+    });
+  });
+
+  describe("#disconnectionSession", function() {
+    it("should disconnect the session", function() {
+      driver.session = session;
+
+      driver.disconnectSession();
+
+      sinon.assert.calledOnce(session.disconnect);
+    });
+
+    it("should destroy the publisher", function() {
+      driver.publisher = publisher;
+
+      driver.disconnectSession();
+
+      sinon.assert.calledOnce(publisher.destroy);
+    });
+  });
+
+  describe("Events", function() {
+    beforeEach(function() {
+      driver.connectSession(sessionData);
+
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+
+      sandbox.stub(dispatcher, "dispatch");
+    });
+
+    describe("connectionDestroyed", function() {
+      it("should dispatch a peerHungupCall action if the client disconnected", function() {
+        session.trigger("connectionDestroyed", {
+          reason: "clientDisconnected"
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "peerHungupCall"));
+      });
+
+      it("should dispatch a connectionFailure action if the connection failed", function() {
+        session.trigger("connectionDestroyed", {
+          reason: "networkDisconnected"
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "peerNetworkDisconnected"));
+      });
+    });
+
+    describe("sessionDisconnected", function() {
+      it("should dispatch a connectionFailure action if the session was disconnected",
+        function() {
+          session.trigger("sessionDisconnected", {
+            reason: "networkDisconnected"
+          });
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectionFailure"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("reason", "networkDisconnected"));
+        });
+    });
+
+    describe("streamCreated", function() {
+      var fakeStream;
+
+      beforeEach(function() {
+        fakeStream = {
+          fakeStream: 3
+        };
+      });
+
+      it("should subscribe to the stream", function() {
+        session.trigger("streamCreated", {stream: fakeStream});
+
+        sinon.assert.calledOnce(session.subscribe);
+        sinon.assert.calledWithExactly(session.subscribe,
+          fakeStream, fakeRemoteElement, publisherConfig);
+      });
+
+      it("should dispach a mediaConnected action if both streams are up", function() {
+        driver._publishedLocalStream = true;
+
+        session.trigger("streamCreated", {stream: fakeStream});
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "mediaConnected"));
+      });
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/validate_test.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*global chai, validate */
+
+var expect = chai.expect;
+
+describe("Validator", function() {
+  "use strict";
+
+  // test helpers
+  function create(dependencies, values) {
+    var validator = new loop.validate.Validator(dependencies);
+    return validator.validate.bind(validator, values);
+  }
+
+  // test types
+  function X(){}
+  function Y(){}
+
+  describe("#validate", function() {
+    it("should check for a single required dependency when no option passed",
+      function() {
+        expect(create({x: Number}, {}))
+          .to.Throw(TypeError, /missing required x$/);
+      });
+
+    it("should check for a missing required dependency, undefined passed",
+      function() {
+        expect(create({x: Number}, {x: undefined}))
+          .to.Throw(TypeError, /missing required x$/);
+      });
+
+    it("should check for multiple missing required dependencies", function() {
+      expect(create({x: Number, y: String}, {}))
+        .to.Throw(TypeError, /missing required x, y$/);
+    });
+
+    it("should check for required dependency types", function() {
+      expect(create({x: Number}, {x: "woops"})).to.Throw(
+        TypeError, /invalid dependency: x; expected Number, got String$/);
+    });
+
+    it("should check for a dependency to match at least one of passed types",
+      function() {
+        expect(create({x: [X, Y]}, {x: 42})).to.Throw(
+          TypeError, /invalid dependency: x; expected X, Y, got Number$/);
+        expect(create({x: [X, Y]}, {x: new Y()})).to.not.Throw();
+      });
+
+    it("should skip type check if required dependency type is undefined",
+      function() {
+        expect(create({x: undefined}, {x: /whatever/})).not.to.Throw();
+      });
+
+    it("should check for a String dependency", function() {
+      expect(create({foo: String}, {foo: 42})).to.Throw(
+        TypeError, /invalid dependency: foo/);
+    });
+
+    it("should check for a Number dependency", function() {
+      expect(create({foo: Number}, {foo: "x"})).to.Throw(
+        TypeError, /invalid dependency: foo/);
+    });
+
+    it("should check for a custom constructor dependency", function() {
+      expect(create({foo: X}, {foo: null})).to.Throw(
+        TypeError, /invalid dependency: foo; expected X, got null$/);
+    });
+
+    it("should check for a native constructor dependency", function() {
+      expect(create({foo: mozRTCSessionDescription}, {foo: "x"}))
+        .to.Throw(TypeError,
+                  /invalid dependency: foo; expected mozRTCSessionDescription/);
+    });
+
+    it("should check for a null dependency", function() {
+      expect(create({foo: null}, {foo: "x"})).to.Throw(
+        TypeError, /invalid dependency: foo; expected null, got String$/);
+    });
+  });
+});
--- a/browser/components/loop/test/shared/websocket_test.js
+++ b/browser/components/loop/test/shared/websocket_test.js
@@ -123,18 +123,21 @@ describe("loop.CallConnectionWebSocket",
       it("should resolve the promise when the 'hello' is received",
         function(done) {
           var promise = callWebSocket.promiseConnect();
 
           dummySocket.onmessage({
             data: '{"messageType":"hello", "state":"init"}'
           });
 
-          promise.then(function() {
+          promise.then(function(state) {
+            expect(state).eql("init");
             done();
+          }, function() {
+            done(new Error("shouldn't have rejected the promise"));
           });
         });
     });
 
     describe("#close", function() {
       it("should close the socket", function() {
         callWebSocket.promiseConnect();
 
@@ -198,16 +201,32 @@ describe("loop.CallConnectionWebSocket",
           sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
             messageType: "action",
             event: "terminate",
             reason: "cancel"
           }));
         });
     });
 
+    describe("#mediaFail", function() {
+      it("should send a terminate message to the server with a reason of media-fail",
+        function() {
+          callWebSocket.promiseConnect();
+
+          callWebSocket.mediaFail();
+
+          sinon.assert.calledOnce(dummySocket.send);
+          sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
+            messageType: "action",
+            event: "terminate",
+            reason: "media-fail"
+          }));
+        });
+    });
+
     describe("Events", function() {
       beforeEach(function() {
         sandbox.stub(callWebSocket, "trigger");
 
         callWebSocket.promiseConnect();
       });
 
       describe("Progress", function() {
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -191,24 +191,24 @@ describe("loop.webapp", function() {
         });
 
         describe("Progress", function() {
           describe("state: terminate, reason: reject", function() {
             beforeEach(function() {
               sandbox.stub(notifications, "errorL10n");
             });
 
-            it("should display the StartConversationView", function() {
+            it("should display the FailedConversationView", function() {
               ocView._websocket.trigger("progress", {
                 state: "terminated",
                 reason: "reject"
               });
 
               TestUtils.findRenderedComponentWithType(ocView,
-                loop.webapp.StartConversationView);
+                loop.webapp.FailedConversationView);
             });
 
             it("should display an error message if the reason is not 'cancel'",
               function() {
                 ocView._websocket.trigger("progress", {
                   state: "terminated",
                   reason: "reject"
                 });
@@ -266,24 +266,24 @@ describe("loop.webapp", function() {
         sandbox.stub(notifications, "errorL10n");
         sandbox.stub(notifications, "warnL10n");
         promiseConnectStub =
           sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect");
         promiseConnectStub.returns(new Promise(function(resolve, reject) {}));
       });
 
       describe("call:outgoing", function() {
-        it("should set display the StartConversationView if session token is missing",
+        it("should display FailedConversationView if session token is missing",
           function() {
             conversation.set("loopToken", "");
 
             ocView.startCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.FailedConversationView);
           });
 
         it("should notify the user if session token is missing", function() {
           conversation.set("loopToken", "");
 
           ocView.startCall();
 
           sinon.assert.calledOnce(notifications.errorL10n);
@@ -395,39 +395,38 @@ describe("loop.webapp", function() {
       });
 
       describe("#setupOutgoingCall", function() {
         describe("No loop token", function() {
           beforeEach(function() {
             conversation.set("loopToken", "");
           });
 
-          it("should set display the StartConversationView", function() {
+          it("should display the FailedConversationView", function() {
             conversation.setupOutgoingCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.FailedConversationView);
           });
 
           it("should display an error", function() {
             conversation.setupOutgoingCall();
 
             sinon.assert.calledOnce(notifications.errorL10n);
           });
         });
 
         describe("Has loop token", function() {
           beforeEach(function() {
-            conversation.set("selectedCallType", "audio-video");
             sandbox.stub(conversation, "outgoing");
           });
 
           it("should call requestCallInfo on the client",
             function() {
-              conversation.setupOutgoingCall();
+              conversation.setupOutgoingCall("audio-video");
 
               sinon.assert.calledOnce(client.requestCallInfo);
               sinon.assert.calledWith(client.requestCallInfo, "fakeToken",
                                       "audio-video");
             });
 
           describe("requestCallInfo response handling", function() {
             it("should set display the CallUrlExpiredView if the call has expired",
@@ -435,24 +434,24 @@ describe("loop.webapp", function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 105});
 
                 conversation.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
                   loop.webapp.CallUrlExpiredView);
               });
 
-            it("should set display the StartConversationView on any other error",
+            it("should set display the FailedConversationView on any other error",
                function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 104});
 
                 conversation.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
-                  loop.webapp.StartConversationView);
+                  loop.webapp.FailedConversationView);
               });
 
             it("should notify the user on any other error", function() {
               client.requestCallInfo.callsArgWith(2, {errno: 104});
 
               conversation.setupOutgoingCall();
 
               sinon.assert.calledOnce(notifications.errorL10n);
@@ -580,59 +579,61 @@ describe("loop.webapp", function() {
           expect(view.state.callState).to.be.equal("ringing");
         });
       });
     });
   });
 
   describe("StartConversationView", function() {
     describe("#initiate", function() {
-      var conversation, setupOutgoingCall, view, fakeSubmitEvent,
-          requestCallUrlInfo;
+      var conversation, view, fakeSubmitEvent, requestCallUrlInfo;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({}, {
           sdk: {}
         });
 
         fakeSubmitEvent = {preventDefault: sinon.spy()};
-        setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
 
         var standaloneClientStub = {
           requestCallUrlInfo: function(token, cb) {
             cb(null, {urlCreationDate: 0});
           },
           settings: {baseServerUrl: loop.webapp.baseServerUrl}
         };
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: standaloneClientStub
             })
         );
       });
 
       it("should start the audio-video conversation establishment process",
         function() {
+          var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
+
           var button = view.getDOMNode().querySelector(".btn-accept");
           React.addons.TestUtils.Simulate.click(button);
 
           sinon.assert.calledOnce(setupOutgoingCall);
-          sinon.assert.calledWithExactly(setupOutgoingCall);
+          sinon.assert.calledWithExactly(setupOutgoingCall, "audio-video");
       });
 
       it("should start the audio-only conversation establishment process",
         function() {
+          var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
+
           var button = view.getDOMNode().querySelector(".start-audio-only-call");
           React.addons.TestUtils.Simulate.click(button);
 
           sinon.assert.calledOnce(setupOutgoingCall);
-          sinon.assert.calledWithExactly(setupOutgoingCall);
+          sinon.assert.calledWithExactly(setupOutgoingCall, "audio");
         });
 
       it("should disable audio-video button once session is initiated",
          function() {
            conversation.set("loopToken", "fake");
 
            var button = view.getDOMNode().querySelector(".btn-accept");
            React.addons.TestUtils.Simulate.click(button);
@@ -645,45 +646,45 @@ describe("loop.webapp", function() {
            conversation.set("loopToken", "fake");
 
            var button = view.getDOMNode().querySelector(".start-audio-only-call");
            React.addons.TestUtils.Simulate.click(button);
 
            expect(button.disabled).to.eql(true);
          });
 
-         it("should set selectedCallType to audio", function() {
-           conversation.set("loopToken", "fake");
-
-           var button = view.getDOMNode().querySelector(".start-audio-only-call");
-           React.addons.TestUtils.Simulate.click(button);
+      it("should set selectedCallType to audio", function() {
+        conversation.set("loopToken", "fake");
 
-           expect(conversation.get("selectedCallType")).to.eql("audio");
-         });
-
-         it("should set selectedCallType to audio-video", function() {
-           conversation.set("loopToken", "fake");
+         var button = view.getDOMNode().querySelector(".start-audio-only-call");
+         React.addons.TestUtils.Simulate.click(button);
 
-           var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
-           React.addons.TestUtils.Simulate.click(button);
-
-           expect(conversation.get("selectedCallType")).to.eql("audio-video");
-         });
+         expect(conversation.get("selectedCallType")).to.eql("audio");
+       });
 
-      it("should set state.urlCreationDateString to a locale date string",
-         function() {
-        // wrap in a jquery object because text is broken up
-        // into several span elements
-        var date = new Date(0);
-        var options = {year: "numeric", month: "long", day: "numeric"};
-        var timestamp = date.toLocaleDateString(navigator.language, options);
+       it("should set selectedCallType to audio-video", function() {
+         conversation.set("loopToken", "fake");
 
-        expect(view.state.urlCreationDateString).to.eql(timestamp);
+         var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
+         React.addons.TestUtils.Simulate.click(button);
+
+         expect(conversation.get("selectedCallType")).to.eql("audio-video");
       });
 
+      // XXX this test breaks while the feature actually works; find a way to
+      // test this properly.
+      it.skip("should set state.urlCreationDateString to a locale date string",
+        function() {
+          var date = new Date();
+          var options = {year: "numeric", month: "long", day: "numeric"};
+          var timestamp = date.toLocaleDateString(navigator.language, options);
+          var dateElem = view.getDOMNode().querySelector(".call-url-date");
+
+          expect(dateElem.textContent).to.eql(timestamp);
+        });
     });
 
     describe("Events", function() {
       var conversation, view, StandaloneClient, requestCallUrlInfo;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({
           loopToken: "fake"
@@ -692,17 +693,17 @@ describe("loop.webapp", function() {
         });
 
         conversation.onMarketplaceMessage = function() {};
         sandbox.stub(notifications, "errorL10n");
         requestCallUrlInfo = sandbox.stub();
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: {requestCallUrlInfo: requestCallUrlInfo}
             })
           );
 
         loop.config.marketplaceUrl = "http://market/";
       });
 
@@ -777,33 +778,33 @@ describe("loop.webapp", function() {
           localStorage.setItem("has-seen-tos", oldLocalStorageValue);
       });
 
       it("should show the TOS", function() {
         var tos;
 
         view = React.addons.TestUtils.renderIntoDocument(
           loop.webapp.StartConversationView({
-            model: conversation,
+            conversation: conversation,
             notifications: notifications,
             client: {requestCallUrlInfo: requestCallUrlInfo}
           })
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(false);
       });
 
       it("should not show the TOS if it has already been seen", function() {
         var tos;
 
         localStorage.setItem("has-seen-tos", "true");
         view = React.addons.TestUtils.renderIntoDocument(
           loop.webapp.StartConversationView({
-            model: conversation,
+            conversation: conversation,
             notifications: notifications,
             client: {requestCallUrlInfo: requestCallUrlInfo}
           })
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(true);
       });
@@ -883,17 +884,17 @@ describe("loop.webapp", function() {
           requestCallUrlInfo: function(token, cb) {
             cb(null, {urlCreationDate: 0});
           },
           settings: {baseServerUrl: loop.webapp.baseServerUrl}
         };
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: standaloneClientStub
             })
         );
       });
 
       it("should start the conversation establishment process", function() {
         var button = view.getDOMNode().querySelector("button");
@@ -998,17 +999,17 @@ describe("loop.webapp", function() {
       });
 
       describe("onMarketplaceMessage", function() {
         var view, setupOutgoingCall, trigger;
 
         before(function() {
           view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: model,
+              conversation: model,
               notifications: notifications,
               client: {requestCallUrlInfo: sandbox.stub()}
             })
           );
         });
 
         beforeEach(function() {
           setupOutgoingCall = sandbox.stub(model, "setupOutgoingCall");
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -2,19 +2,18 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Http.jsm");
 Cu.import("resource://testing-common/httpd.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
-                                  "resource:///modules/loop/MozLoopService.jsm");
+Cu.import("resource:///modules/loop/MozLoopService.jsm");
+const { MozLoopServiceInternal } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 const kMockWebSocketChannelName = "Mock WebSocket Channel";
 const kWebSocketChannelContractID = "@mozilla.org/network/protocol;1?name=wss";
 
 const kServerPushUrl = "http://localhost:3456";
@@ -57,16 +56,20 @@ function waitForCondition(aConditionFn, 
     do_timeout(aCheckInterval, tryNow);
   }
   let deferred = Promise.defer();
   let tries = 0;
   tryAgain();
   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.
--- a/browser/components/loop/test/xpcshell/test_loopapi_hawk_request.js
+++ b/browser/components/loop/test/xpcshell/test_loopapi_hawk_request.js
@@ -33,17 +33,17 @@ function generateSessionTypeVerification
 
       resolve();
     });
   };
 
   return hawkRequestStub;
 }
 
-const origHawkRequest = MozLoopService.oldHawkRequest;
+const origHawkRequest = MozLoopService.hawkRequest;
 do_register_cleanup(function() {
   MozLoopService.hawkRequest = origHawkRequest;
 });
 
 add_task(function* hawk_request_scope_passthrough() {
 
   // add a stub that verifies the parameter we want
   MozLoopService.hawkRequest =
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Unit tests for the error handling for hawkRequest via setError.
+ *
+ * hawkRequest calls setError itself for 401. Consumers need to report other
+ * errors to setError themseleves.
+ */
+
+"use strict";
+
+const { INVALID_AUTH_TOKEN } = Cu.import("resource:///modules/loop/MozLoopService.jsm");
+
+/**
+ * An HTTP request for /NNN responds with a request with a status of NNN.
+ */
+function errorRequestHandler(request, response) {
+  let responseCode = request.path.substring(1);
+  response.setStatusLine(null, responseCode, "Error");
+  if (responseCode == 401) {
+    response.write(JSON.stringify({
+      code: parseInt(responseCode),
+      errno: INVALID_AUTH_TOKEN,
+      error: "INVALID_AUTH_TOKEN",
+      message: "INVALID_AUTH_TOKEN",
+    }));
+  }
+}
+
+add_task(function* setup_server() {
+  loopServer.registerPathHandler("/401", errorRequestHandler);
+  loopServer.registerPathHandler("/404", errorRequestHandler);
+  loopServer.registerPathHandler("/500", errorRequestHandler);
+  loopServer.registerPathHandler("/503", errorRequestHandler);
+});
+
+add_task(function* error_offline() {
+  Services.io.offline = true;
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/offline", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      // Network errors are converted to the "network" errorType.
+      let err = MozLoopService.errors.get("network");
+      Assert.strictEqual(err.code, null);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_connect"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("check_internet_connection"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+  Services.io.offline = false;
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* guest_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token"),
+                         Services.prefs.PREF_INVALID,
+                         "Guest session token should have been cleared");
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token.fxa"),
+                         "fxa",
+                         "FxA session token should NOT have been cleared");
+
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("registration");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("session_expired_error_description"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* fxa_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.FXA, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token"),
+                         "guest",
+                         "Guest session token should NOT have been cleared");
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token.fxa"),
+                         Services.prefs.PREF_INVALID,
+                         "Fxa session token should have been cleared");
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("login");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_authenticate"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("password_changed_question"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_404() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/40